Commit 767324dd by Anton Stupak

Merge pull request #6744 from edx/anton/edxnotes-caret-navigation

TNL-716: Add possibility for notes creation via keyboard.
parents 0f19d1fc d795ec2f
......@@ -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;
}
......
;(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);
......@@ -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
......
......@@ -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;
/**
......
......@@ -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;
}
});
});
......
......@@ -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
......@@ -55,6 +55,9 @@ define([
'annotationCreated', this.plugin.addDescriptions
);
expect(this.annotator.unsubscribe).toHaveBeenCalledWith(
'annotationCreated', this.plugin.focusOnHighlightedText
);
expect(this.annotator.unsubscribe).toHaveBeenCalledWith(
'annotationDeleted', this.plugin.removeDescription
);
expect($.fn.off).toHaveBeenCalledWith('.accessibility');
......@@ -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 () {
......
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 = $('<span />', {'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();
});
});
});
});
......@@ -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'
]);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment