diff --git a/lms/static/js/edxnotes/plugins/accessibility.js b/lms/static/js/edxnotes/plugins/accessibility.js index 6349ecb8aa..4044160182 100644 --- a/lms/static/js/edxnotes/plugins/accessibility.js +++ b/lms/static/js/edxnotes/plugins/accessibility.js @@ -8,7 +8,7 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { _.bindAll(this, 'addAriaAttributes', 'onHighlightKeyDown', 'onViewerKeyDown', 'onEditorKeyDown', 'addDescriptions', 'removeDescription', - 'saveCurrentHighlight', 'focusOnGrabber', 'showViewer', 'onClose' + 'focusOnGrabber', 'showViewer', 'onClose', 'focusOnHighlightedText' ); // Call the Annotator.Plugin constructor this sets up the element and // options properties. @@ -20,6 +20,7 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { this.annotator.subscribe('annotationViewerTextField', this.addAriaAttributes); this.annotator.subscribe('annotationsLoaded', this.addDescriptions); this.annotator.subscribe('annotationCreated', this.addDescriptions); + this.annotator.subscribe('annotationCreated', this.focusOnHighlightedText); this.annotator.subscribe('annotationDeleted', this.removeDescription); this.annotator.element.on('keydown.accessibility.hl', '.annotator-hl', this.onHighlightKeyDown); this.annotator.element.on('keydown.accessibility.viewer', '.annotator-viewer', this.onViewerKeyDown); @@ -32,10 +33,10 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { this.annotator.unsubscribe('annotationViewerTextField', this.addAriaAttributes); this.annotator.unsubscribe('annotationsLoaded', this.addDescriptions); this.annotator.unsubscribe('annotationCreated', this.addDescriptions); + this.annotator.unsubscribe('annotationCreated', this.focusOnHighlightedText); this.annotator.unsubscribe('annotationDeleted', this.removeDescription); this.annotator.element.off('.accessibility'); this.removeFocusGrabber(); - this.savedHighlights = null; }, addTabIndex: function () { @@ -98,17 +99,19 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { }); }, - saveCurrentHighlight: function (annotation) { - if (annotation && annotation.highlights) { - this.savedHighlights = annotation.highlights[0]; - } - }, - focusOnHighlightedText: function () { - if (this.savedHighlights) { - this.savedHighlights.focus(); - this.savedHighlights = null; - } + var viewer = this.annotator.viewer, + editor = this.annotator.editor, + highlight; + + try { + if (viewer.isShown()) { + highlight = viewer.annotations[0].highlights[0]; + } else if (editor.isShown()) { + highlight = editor.annotation.highlights[0]; + } + highlight.focus(); + } catch (err) {} }, getViewerTabControls: function () { @@ -168,7 +171,6 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { showViewer: function (position, annotation) { annotation = $.makeArray(annotation); - this.saveCurrentHighlight(annotation[0]); this.annotator.showViewer(annotation, position); this.annotator.element.find('.annotator-listing').focus(); this.annotator.subscribe('annotationDeleted', this.focusOnGrabber); @@ -190,6 +192,8 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { // This happens only when coming from notes page if (this.annotator.viewer.isShown()) { this.annotator.element.find('.annotator-listing').focus(); + event.preventDefault(); + event.stopPropagation(); } break; case KEY.ENTER: @@ -197,17 +201,16 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { if (!this.annotator.viewer.isShown()) { position = target.position(); this.showViewer(position, target.data('annotation')); + event.preventDefault(); + event.stopPropagation(); } break; case KEY.ESCAPE: this.annotator.viewer.hide(); + event.preventDefault(); + event.stopPropagation(); break; } - // We do not stop propagation and default behavior on a TAB keypress - if (event.keyCode !== KEY.TAB || (event.keyCode === KEY.TAB && this.annotator.viewer.isShown())) { - event.preventDefault(); - event.stopPropagation(); - } }, onViewerKeyDown: function (event) { @@ -241,14 +244,14 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { case KEY.ENTER: case KEY.SPACE: if (target.hasClass('annotator-close')) { - this.annotator.viewer.hide(); this.onClose(); + this.annotator.viewer.hide(); event.preventDefault(); } break; case KEY.ESCAPE: - this.annotator.viewer.hide(); this.onClose(); + this.annotator.viewer.hide(); event.preventDefault(); break; } @@ -288,29 +291,31 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { break; case KEY.ENTER: if (target.is(save) || event.metaKey || event.ctrlKey) { + this.onClose(); this.annotator.editor.submit(); } else if (target.is(cancel)) { + this.onClose(); this.annotator.editor.hide(); } else { break; } - this.onClose(); event.preventDefault(); break; case KEY.SPACE: if (target.is(save)) { + this.onClose(); this.annotator.editor.submit(); } else if (target.is(cancel)) { + this.onClose(); this.annotator.editor.hide(); } else { break; } - this.onClose(); event.preventDefault(); break; case KEY.ESCAPE: - this.annotator.editor.hide(); this.onClose(); + this.annotator.editor.hide(); event.preventDefault(); break; } diff --git a/lms/static/js/edxnotes/plugins/caret_navigation.js b/lms/static/js/edxnotes/plugins/caret_navigation.js new file mode 100644 index 0000000000..df3dfb0175 --- /dev/null +++ b/lms/static/js/edxnotes/plugins/caret_navigation.js @@ -0,0 +1,117 @@ +;(function (define, undefined) { +'use strict'; +define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { + /** + * The CaretNavigation Plugin which allows notes creation when users use + * caret navigation to select the text. + * Use `Ctrl + SPACE` or `Ctrl + ENTER` to open the editor. + **/ + Annotator.Plugin.CaretNavigation = function () { + // Call the Annotator.Plugin constructor this sets up the element and + // options properties. + _.bindAll(this, 'onKeyUp'); + Annotator.Plugin.apply(this, arguments); + }; + + $.extend(Annotator.Plugin.CaretNavigation.prototype, new Annotator.Plugin(), { + pluginInit: function () { + $(document).on('keyup', this.onKeyUp); + }, + + destroy: function () { + $(document).off('keyup', this.onKeyUp); + }, + + isShortcut: function (event) { + // Character ']' has keyCode 221 + return event.keyCode === 221 && event.ctrlKey && event.shiftKey; + }, + + hasSelection: function (ranges) { + return (ranges || []).length; + }, + + saveSelection: function () { + this.savedRange = Annotator.Util.getGlobal().getSelection().getRangeAt(0); + }, + + restoreSelection: function () { + if (this.savedRange) { + var browserRange = new Annotator.Range.BrowserRange(this.savedRange), + normedRange = browserRange.normalize().limit(this.annotator.wrapper[0]); + + Annotator.Util.readRangeViaSelection(normedRange); + this.savedRange = null; + } + }, + + onKeyUp: function (event) { + var annotator = this.annotator, + self = this, + isAnnotator, annotation, highlights, position, save, cancel, cleanup; + + // Do nothing if not a shortcut. + if (!this.isShortcut(event)) { + return true; + } + // Get the currently selected ranges. + annotator.selectedRanges = annotator.getSelectedRanges(); + // Do nothing if there is no selection + if (!this.hasSelection(annotator.selectedRanges)) { + return true; + } + + isAnnotator = _.some(annotator.selectedRanges, function (range) { + return annotator.isAnnotator(range.commonAncestor); + }); + + // Do nothing if we are in Annotator. + if (isAnnotator) { + return true; + } + // Show a temporary highlight so the user can see what they selected + // Also extract the quotation and serialize the ranges + annotation = annotator.setupAnnotation(annotator.createAnnotation()); + highlights = $(annotation.highlights).addClass('annotator-hl-temporary'); + + if (annotator.adder.is(':visible')) { + position = annotator.adder.position(); + annotator.adder.hide(); + } else { + position = highlights.last().position(); + } + + // Subscribe to the editor events + // Make the highlights permanent if the annotation is saved + save = function () { + cleanup(); + highlights.removeClass('annotator-hl-temporary'); + // Fire annotationCreated events so that plugins can react to them + annotator.publish('annotationCreated', [annotation]); + }; + + // Remove the highlights if the edit is cancelled + cancel = function () { + self.restoreSelection(); + cleanup(); + annotator.deleteAnnotation(annotation); + }; + + // Don't leak handlers at the end + cleanup = function () { + annotator.unsubscribe('annotationEditorHidden', cancel); + annotator.unsubscribe('annotationEditorSubmit', save); + self.savedRange = null; + }; + + annotator.subscribe('annotationEditorHidden', cancel); + annotator.subscribe('annotationEditorSubmit', save); + + this.saveSelection(); + // Display the editor. + annotator.showEditor(annotation, position); + event.preventDefault(); + } + }); +}); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/edxnotes/plugins/scroller.js b/lms/static/js/edxnotes/plugins/scroller.js index a56b7e6ef3..cb3da72da4 100644 --- a/lms/static/js/edxnotes/plugins/scroller.js +++ b/lms/static/js/edxnotes/plugins/scroller.js @@ -47,13 +47,10 @@ define(['jquery', 'underscore', 'annotator_1.2.9'], function ($, _, Annotator) { highlight = $(note.highlights[0]); offset = highlight.position(); // Open the note - this.annotator.plugins.Accessibility.showViewer( - { - top: offset.top + 0.5 * highlight.height(), - left: offset.left + 0.5 * highlight.width() - }, - note - ); + this.annotator.showFrozenViewer([note], { + top: offset.top + 0.5 * highlight.height(), + left: offset.left + 0.5 * highlight.width() + }); // Freeze the viewer this.annotator.freezeAll(); // Scroll to highlight diff --git a/lms/static/js/edxnotes/views/notes_factory.js b/lms/static/js/edxnotes/views/notes_factory.js index 800bfac966..8180543c78 100644 --- a/lms/static/js/edxnotes/views/notes_factory.js +++ b/lms/static/js/edxnotes/views/notes_factory.js @@ -3,9 +3,10 @@ define([ 'jquery', 'underscore', 'annotator_1.2.9', 'js/edxnotes/utils/logger', 'js/edxnotes/views/shim', 'js/edxnotes/plugins/scroller', - 'js/edxnotes/plugins/events', 'js/edxnotes/plugins/accessibility' + 'js/edxnotes/plugins/events', 'js/edxnotes/plugins/accessibility', + 'js/edxnotes/plugins/caret_navigation' ], function ($, _, Annotator, NotesLogger) { - var plugins = ['Auth', 'Store', 'Scroller', 'Events', 'Accessibility'], + var plugins = ['Auth', 'Store', 'Scroller', 'Events', 'Accessibility', 'CaretNavigation'], getOptions, setupPlugins, updateHeaders, getAnnotator; /** diff --git a/lms/static/js/edxnotes/views/shim.js b/lms/static/js/edxnotes/views/shim.js index 325d0b5867..8fcdcafdc0 100644 --- a/lms/static/js/edxnotes/views/shim.js +++ b/lms/static/js/edxnotes/views/shim.js @@ -310,6 +310,12 @@ define([ unfreezeAll: function () { _.invoke(Annotator._instances, 'unfreeze'); return this; + }, + + showFrozenViewer: function (annotations, location) { + this.showViewer(annotations, location); + this.freezeAll(); + return this; } }); }); diff --git a/lms/static/js/spec/edxnotes/plugins/accessibility_spec.js b/lms/static/js/spec/edxnotes/plugins/accessibility_spec.js index f5d3090add..9174fbdb0b 100644 --- a/lms/static/js/spec/edxnotes/plugins/accessibility_spec.js +++ b/lms/static/js/spec/edxnotes/plugins/accessibility_spec.js @@ -43,7 +43,7 @@ define([ describe('destroy', function () { it('should unbind all events', function () { spyOn($.fn, 'off'); - spyOn(this.annotator, 'unsubscribe'); + spyOn(this.annotator, 'unsubscribe').andCallThrough(); this.plugin.destroy(); expect(this.annotator.unsubscribe).toHaveBeenCalledWith( 'annotationViewerTextField', this.plugin.addAriaAttributes @@ -54,6 +54,9 @@ define([ expect(this.annotator.unsubscribe).toHaveBeenCalledWith( 'annotationCreated', this.plugin.addDescriptions ); + expect(this.annotator.unsubscribe).toHaveBeenCalledWith( + 'annotationCreated', this.plugin.focusOnHighlightedText + ); expect(this.annotator.unsubscribe).toHaveBeenCalledWith( 'annotationDeleted', this.plugin.removeDescription ); @@ -136,11 +139,9 @@ define([ it('should focus highlighted text after closing', function () { var note; highlight.trigger(keyDownEvent(this.KEY.ENTER)); - expect(this.plugin.savedHighlights).toBeDefined(); note = this.annotator.element.find('.annotator-edit'); note.trigger(keyDownEvent(this.KEY.ESCAPE)); expect(highlight).toBeFocused(); - expect(this.plugin.savedHighlights).toBeNull(); }); it('should focus on grabber after being deleted', function () { diff --git a/lms/static/js/spec/edxnotes/plugins/caret_navigation_spec.js b/lms/static/js/spec/edxnotes/plugins/caret_navigation_spec.js new file mode 100644 index 0000000000..79820dd252 --- /dev/null +++ b/lms/static/js/spec/edxnotes/plugins/caret_navigation_spec.js @@ -0,0 +1,200 @@ +define([ + 'jquery', 'underscore', 'annotator_1.2.9', 'logger', 'js/edxnotes/views/notes_factory' +], function($, _, Annotator, Logger, NotesFactory) { + 'use strict'; + describe('EdxNotes CaretNavigation Plugin', function() { + + beforeEach(function() { + loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); + this.annotator = NotesFactory.factory( + $('div#edx-notes-wrapper-123').get(0), { + endpoint: 'http://example.com/' + } + ); + this.plugin = this.annotator.plugins.CaretNavigation; + spyOn(Logger, 'log'); + }); + + afterEach(function () { + _.invoke(Annotator._instances, 'destroy'); + }); + + describe('destroy', function () { + it('should unbind all events', function () { + spyOn($.fn, 'off'); + this.plugin.destroy(); + expect($.fn.off).toHaveBeenCalledWith('keyup', this.plugin.onKeyUp); + }); + }); + + describe('isShortcut', function () { + it('should return `true` if it is a shortcut', function () { + expect(this.plugin.isShortcut($.Event('keyup', { + ctrlKey: true, + shiftKey: true, + keyCode: 221 + }))).toBeTruthy(); + }); + + it('should return `false` if it is not a shortcut', function () { + expect(this.plugin.isShortcut($.Event('keyup', { + ctrlKey: false, + shiftKey: true, + keyCode: 221 + }))).toBeFalsy(); + + expect(this.plugin.isShortcut($.Event('keyup', { + ctrlKey: true, + shiftKey: true, + keyCode: $.ui.keyCode.TAB + }))).toBeFalsy(); + }); + }); + + describe('hasSelection', function () { + it('should return `true` if has selection', function () { + expect(this.plugin.hasSelection([{}, {}])).toBeTruthy(); + }); + + it('should return `false` if does not have selection', function () { + expect(this.plugin.hasSelection([])).toBeFalsy(); + expect(this.plugin.hasSelection()).toBeFalsy(); + }); + }); + + describe('onKeyUp', function () { + var triggerEvent = function (element, props) { + var eventProps = $.extend({ + ctrlKey: true, + shiftKey: true, + keyCode: 221 + }, props); + element.trigger($.Event('keyup', eventProps)); + }; + + beforeEach(function() { + this.element = $('', {'class': 'annotator-hl'}).appendTo(this.annotator.element); + + this.annotation = { + text: "test", + highlights: [this.element.get(0)] + }; + + this.mockOffset = {top: 0, left:0}; + + this.mockSubscriber = jasmine.createSpy(); + this.annotator.subscribe('annotationCreated', this.mockSubscriber); + + spyOn($.fn, 'position').andReturn(this.mockOffset); + spyOn(this.annotator, 'createAnnotation').andReturn(this.annotation); + spyOn(this.annotator, 'setupAnnotation').andReturn(this.annotation); + spyOn(this.annotator, 'getSelectedRanges').andReturn([{}]); + spyOn(this.annotator, 'deleteAnnotation'); + spyOn(this.annotator, 'showEditor'); + spyOn(Annotator.Util, 'readRangeViaSelection'); + spyOn(this.plugin, 'saveSelection'); + spyOn(this.plugin, 'restoreSelection'); + }); + + it('should create a new annotation', function () { + triggerEvent(this.element); + expect(this.annotator.createAnnotation.callCount).toBe(1); + }); + + it('should set up the annotation', function () { + triggerEvent(this.element); + expect(this.annotator.setupAnnotation).toHaveBeenCalledWith( + this.annotation + ); + }); + + it('should display the Annotation#editor correctly if the Annotation#adder is hidden', function () { + spyOn($.fn, 'is').andReturn(false); + triggerEvent(this.element); + expect($('annotator-hl-temporary').position.callCount).toBe(1); + expect(this.annotator.showEditor).toHaveBeenCalledWith( + this.annotation, this.mockOffset + ); + }); + + it('should display the Annotation#editor in the same place as the Annotation#adder', function () { + spyOn($.fn, 'is').andReturn(true); + triggerEvent(this.element); + expect(this.annotator.adder.position.callCount).toBe(1); + expect(this.annotator.showEditor).toHaveBeenCalledWith( + this.annotation, this.mockOffset + ); + }); + + it('should hide the Annotation#adder', function () { + spyOn($.fn, 'is').andReturn(true); + spyOn($.fn, 'hide'); + triggerEvent(this.element); + expect(this.annotator.adder.hide).toHaveBeenCalled(); + }); + + it('should add temporary highlights to the document to show the user what they selected', function () { + triggerEvent(this.element); + expect(this.element).toHaveClass('annotator-hl'); + expect(this.element).toHaveClass('annotator-hl-temporary'); + }); + + it('should persist the temporary highlights if the annotation is saved', function () { + triggerEvent(this.element); + this.annotator.publish('annotationEditorSubmit'); + expect(this.element).toHaveClass('annotator-hl'); + expect(this.element).not.toHaveClass('annotator-hl-temporary'); + }); + + it('should trigger the `annotationCreated` event if the edit\'s saved', function () { + triggerEvent(this.element); + this.annotator.onEditorSubmit(this.annotation); + expect(this.mockSubscriber).toHaveBeenCalledWith(this.annotation); + }); + + it('should call Annotator#deleteAnnotation if editing is cancelled', function () { + triggerEvent(this.element); + this.annotator.onEditorHide(); + expect(this.mockSubscriber).not.toHaveBeenCalledWith('annotationCreated'); + expect(this.annotator.deleteAnnotation).toHaveBeenCalledWith( + this.annotation + ); + }); + + it('should restore selection if editing is cancelled', function () { + triggerEvent(this.element); + this.plugin.savedRange = 'range'; + expect(this.plugin.saveSelection).toHaveBeenCalled(); + this.annotator.onEditorHide(); + expect(this.plugin.restoreSelection).toHaveBeenCalled(); + }); + + it('should do nothing if the edit\'s saved', function () { + triggerEvent(this.element); + expect(this.plugin.saveSelection).toHaveBeenCalled(); + this.plugin.savedRange = 'range'; + this.annotator.onEditorSubmit(); + expect(Annotator.Util.readRangeViaSelection).not.toHaveBeenCalled(); + expect(this.plugin.savedRange).toBeNull(); + expect(this.plugin.restoreSelection).not.toHaveBeenCalled(); + }); + + it('should do nothing if it is not a shortcut', function () { + triggerEvent(this.element, {ctrlKey: false}); + expect(this.annotator.showEditor).not.toHaveBeenCalled(); + }); + + it('should do nothing if empty selection', function () { + this.annotator.getSelectedRanges.andReturn([]); + triggerEvent(this.element); + expect(this.annotator.showEditor).not.toHaveBeenCalled(); + }); + + it('should do nothing if selection is in Annotator', function () { + spyOn(this.annotator, 'isAnnotator').andReturn(true); + triggerEvent(this.element); + expect(this.annotator.showEditor).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 177e71df4c..cec33b55a1 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -572,6 +572,7 @@ 'lms/include/js/spec/edxnotes/plugins/accessibility_spec.js', 'lms/include/js/spec/edxnotes/plugins/events_spec.js', 'lms/include/js/spec/edxnotes/plugins/scroller_spec.js', + 'lms/include/js/spec/edxnotes/plugins/caret_navigation_spec.js', 'lms/include/js/spec/edxnotes/collections/notes_spec.js', 'lms/include/js/spec/search/search_spec.js' ]);