From: Zachary Vance Date: Wed, 20 May 2015 02:16:29 +0000 (-0700) Subject: Integrate Shortcut library with Backbone (bubbling is in wrong order) X-Git-Url: https://git.za3k.com/?a=commitdiff_plain;h=43af0b088652774802c5ea9711ad88715883f929;p=flowy.git Integrate Shortcut library with Backbone (bubbling is in wrong order) --- diff --git a/dist/flowy.js b/dist/flowy.js index c717510..3563e04 100644 --- a/dist/flowy.js +++ b/dist/flowy.js @@ -60,8 +60,255 @@ function setRangeAtMarker(markerElement, options) { } } +// TODO: More documentation +// TODO: Removing objects/shortcuts/rebinding won't work until this bug is fixed in mousetrap: https://github.com/ccampbell/mousetrap/issues/267 + +// firstShortcut = Shortcut.registerShortcut("Control + k", function() { console.log("Shortcut pressed."); }); +// firstShortcut.rebind("Control + m"); +// Shortcut.registerShortcut({keybinding: "L", objectType: "textbox", action: function() { console.log("L pressed while inside the textbox."); }}); +// o = Shortcut.addObject(aTextbox, "textbox"); // Can come before or after keyboard binding +// o.remove() // Once we lose textbox focus, stop keeping track of it, for example + +var Shortcut = (function(document, _) { + _.mixin({ + guid : function(){ + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); + return v.toString(16); + }); + } + }); + + var globalMousetrap = new Mousetrap(); + var globalObject = {element: document, type: "global", isGlobal: true, mousetrap: globalMousetrap}; + var Shortcut = { + _globalObject: globalObject, // The object passed to callbacks when a global keybinding is invoked. + globalMousetrap: globalMousetrap, + boundObjects: [globalObject], + shortcuts: {}, + registerShortcut: function(shortcut) { + if (arguments.length > 1) { + shortcut = { + keybinding: arguments[0], + action: arguments[1], + object: arguments[2], + }; + } + var manager = this; + if (!shortcut.ownedByUs) shortcut = _.clone(shortcut); + _.defaults(shortcut, { + register: function() { manager.registerShortcut(this); }, + unregister: function() { manager.unregisterShortcut(this); }, + bind: function(object) { manager.bindShortcut(this, object); }, + unbind: function(object) { manager.unbindShortcut(this, object); }, + rebind: function(keybinding) { manager.rebindShortcut(this, keybinding); }, + object: "global", + + _keybinding: shortcut.keybinding || "", + action: function() {}, + boundObjects: [], + ownedByUs: true, + id: _.guid(), + }); + Object.defineProperty(shortcut, "keybinding", { + set: function (newKeybinding) { manager.rebindShortcut(shortcut, newKeybinding); }, + get: function () { return shortcut._keybinding; }, + }); + if (this.shortcuts[shortcut.id]) return this.shortcuts[shortcut.id]; + this.shortcuts[shortcut.id] = shortcut; + _.chain(this.boundObjects).where({'type':shortcut.object}).each(_.partial(this.bindShortcut, shortcut, _), this).value(); + return shortcut; + }, + unregisterShortcut: function(shortcut) { + _.each(_.clone(shortcut.boundObjects), _.partial(this.unbindShortcut, shortcut, _), this); + delete this.shortcuts[shortcut.id]; + }, + bindShortcut: function(shortcut, object) { // Do not call directly + if (!_.contains(shortcut.boundObjects, object)) { + object.mousetrap.bind(shortcut.keybinding, _.bind(this.shortcutPressed, this, shortcut, object)); + shortcut.boundObjects.push(object); + } + }, + unbindShortcut: function(shortcut, object) { // Do not call directly with an object + if (!object){ + _.each(shortcut.boundObjects, _.partial(this.unbindShortcut, shortcut, _), this); + } + object.mousetrap.unbind(shortcut.keybinding); + shortcut.boundObjects = _.without(shortcut.boundObjects, object); + }, + rebindShortcut: function(shortcut, keybinding) { + if (keybinding === shortcut.keybinding) return; + var oldKeybinding = shortcut.keybinding; + var objects = shortcut.boundObjects; + shortcut.unbind(); + shorcut._keybinding = keybinding; + _.each(objects, shortcut.bind, shortcut); + if (this.onRebindShortcut) this.onRebindShortcut(shortcut, keybinding, oldKeybinding); + }, + userRebindShortcut: function(shortcut, options) { + // Cancels on focus loss or if the user presses the escape key + options = _.defaults({}, options, { + element: undefined, // If the user clicks outside this element, cancel + onCancel: function() {}, + }); + var manager = this; + var cancelled = false; + var cancel = function() { + if (!cancelled) { + cancelled = true; + if (options.onCancel) options.onCancel(); + } + }; + if (element) element.addEventListener("blur", cancel); + Mousetrap.record(function (keybinding) { // TODO: requires plugin + if (_.equal(keybinding, ["ESCAPE"])) { // TODO: Test + cancel(); + } else { + manager.rebindShortcut(shortcut, keybinding); + if (element) element.removeEventListener("blur", cancel); + } + }); + }, + shortcutPressed: function(shortcut, object) { + if (shortcut.action) shortcut.action(object.element, shortcut, object.type); + }, + onRebindShortcut: function(shortcut, keybinding, oldKeybinding) { + // NOTE: You may want to hook into this if you want to save/load user preferences + // shortcut.id is probably what you want and not the whole shortcut + }, + addObject: function(element, type) { + var manager = this; + object = { + element:element, + type:type, + mousetrap: Mousetrap(element), + remove: function() { manager.removeObject(this); }, + add: function() { manager.addObject(this.element, this.type); }, + }; + _.chain(this.shortcuts).where({'object': type}).each(_.partial(this.bindShortcut, _, object), this).value(); + if (!_.contains(this.boundObjects)) this.boundObjects.push(object); + return object; + }, + removeObject: function(object) { + _.each(this.shortcuts, _.partial(this.unbindShortcut, _, object), this); + this.boundObjects = _.without(this.boundObjects, object); + }, + get globalObject() { + return this._globalObject; + }, + set globalObject(newElement) { + this.removeObject(this.globalObject, "global"); + this._globalObject = { + element: newElement, + type: "global", + isGlobal: true, + mousetrap: this.globalMousetrap, + }; + _.chain(this.shortcuts).where({'object': "global"}).each(_.partial(this.bindShortcut, _, this.globalObject), this).value(); + if (!_.contains(this.boundObjects)) this.boundObjects.push(this.globalObject); + return this.globalObject; + }, + pause: function() { // TODO: These require plugin, test + this.boundObjects.each(function(object) { + object.mousetrap.pause(); + }); + }, + unpause: function() { + this.boundObjects.each(function(object) { + object.mousetrap.unpause(); + }); + }, + displayShortcuts: function(shortcuts, options) { + options = _.defaults({}, options, { + objectGrouping: "default", // Display one list, or group under headers by object type? "true" "false", or "default" + displayMultiple: true, // Display multiple shortcuts or just the canonical one? + allowRebind: false, // Insert javascript to allow rebinding shortcuts + highlightRepeats: true, // Warn about shortcuts which are bound to multiple actions (if user can remap shortcuts) + }); + if (!shortcuts) shortcuts = this.shortcuts; + if (options.objectGrouping === "default") { + var multipleObjectTypes = _.chain(shortcuts).pluck('object').unique().count().value() > 1; + options.objectGrouping = multipleObjectTypes; + } + // TODO: Return HTML + return ""; + }, + _displayKeybinding: function(keybinding, options) { + options = _.defaults({}, options, { displayMultiple: true }); + // TODO: Return HTML + return ""; + }, + }; + return Shortcut; +})(document, _); + +/** + * @depend ../library/shortcut.js + */ + + +// Integrates "Shortcuts" library with backbone views, to allow events in the form "Shortcut(id, description, default keycombo)": action +// Shortcut selectors are not supported +(function (View, Shortcut) { + var delegateEvents = View.delegateEvents; + var undelegateEvents = View.undelegateEvents; + var ShortcutRegex = /^Shortcut\("([^")]*)", ?"([^")]*)", ?"([^")]*)"\) (.*)$/; + function delegate(id, description, defaultKeybinding, objectType, callback){ + var shortcut = Shortcut.registerShortcut({ + id: id, + description: description, + keybinding: defaultKeybinding, // TODO: Add support for rebinding + object: objectType, + action: callback + }); + } + function delegateMousetrapEvents(events, objectType) { + for (var key in events) { + var method = events[key]; + if (!_.isFunction(method)) method = this[events[key]]; + if (!method) continue; + var match = key.match(ShortcutRegex); + delegate.call(this, match[1], match[2], match[3], objectType, _.bind(method, this)); + } + this.shortcutObjects = this.shortcutObjects || []; + if (objectType === "global") { + Shortcut.globalObject = this.$el[0]; + this._shortcutObject = Shortcut.globalObject; + } else { + this._shortcutObject = Shortcut.addObject(this.$el[0], objectType); + } + } + View.delegateEvents = function(events) { + if (!(events || (events = _.result(this, 'events')))) return this; + var mousetrapEvents = {}; + var nonMousetrapEvents = {}; + for (var key in events) { + if (key.match(ShortcutRegex)) { + mousetrapEvents[key] = events[key]; + } else { + nonMousetrapEvents[key] = events[key]; + } + } + delegateEvents.call(this, nonMousetrapEvents); + if (_.size(mousetrapEvents) > 0) { + var type = this.shortcutObject || this.className; + if (!type) { + throw "Mousetrap events need to specify a type on the view object via .className or .shortcutObject. 'global' will catch all keystrokes rather than binding to the selector"; + } + delegateMousetrapEvents.call(this, mousetrapEvents, type); + } + return this; + }; + View.undelegateEvents = function() { + if (this._shortcutObject) Shortcut.removeObject(this._shortcutObject); + // Also undelegate mousetrap events + undelegateEvents.apply(this, arguments); + }; +})(Backbone.View.prototype, Shortcut); + /** * @depend ../library/cursorToEnd.js + * @depend ../library/viewShortcuts.js */ @@ -69,21 +316,18 @@ var TodoView = Backbone.View.extend({ tagName: 'div', className: 'todo', events: { - //"click > .checkbox": "toggleComplete", "input > .text": "textChange", "blur > .text": "render", // Because the model shouldn't update the view during active editing, add a re-render at the end "keydown > .text": "keydown", + 'Shortcut("toggleComplete", "Mark an item as complete or not", "ctrl+enter") > .text': "toggleComplete", + 'Shortcut("backspace", "Combine an item with the previous item", "backspace") > .text': "backspace", + 'Shortcut("delete", "Combine an item with the next item", "del") > .text': "delete", }, initialize: function() { this.childViewPositions = []; this.listenTo(this.model, "change", this.render); this.listenTo(this.model, 'destroy', this.remove); }, - toggleComplete: function(e) { - this.model.toggleComplete(); - e.stopPropagation(); - return this; - }, startEditingText: function(options) { options = options || {}; if (options.atEnd) { @@ -110,16 +354,7 @@ var TodoView = Backbone.View.extend({ decodeText: function(encodedText) { return $("
").html(encodedText).text(); }, - keydown: function(e) { - if (e.keyCode == 46 /* backspace */ || e.keyCode == 8 /* delete */) { - return this.backspace(e); - } else if (e.keyCode == 17) { - return this.control(e); - } else { - console.log(e.keyCode + " key pressed"); - } - }, - control: function(e) { + toggleComplete: function(e) { this.stopEditingText(); this.model.toggleComplete(); var next = this.model.nextNode(this.model.collection, {"childrenAllowed":false}); @@ -137,7 +372,7 @@ var TodoView = Backbone.View.extend({ e.preventDefault(); this.model.remove(this.model.collection); previousNode.getView().startEditingText({"atEnd":true}); - } else if (this.isFocusAtBeginning() && e.keyCode !== 46 /* backspace only */) { + } else if (this.isFocusAtBeginning()) { e.preventDefault(); var text = this.model.get("text"); this.model.remove(this.model.collection); @@ -146,6 +381,20 @@ var TodoView = Backbone.View.extend({ previousNode.getView().startEditingText({"atMarker": ".focus"}); } }, + "delete": function(e) { + if (this.model.hasChildren()) { + return; + } + var previousNode = this.model.previousNode(this.model.collection); + if (!previousNode) { + return; + } + if (this.model.get("text") === "") { + e.preventDefault(); + this.model.remove(this.model.collection); + previousNode.getView().startEditingText({"atEnd":true}); + } + }, textChange: function(e) { var collection = this.model.collection; var lines = $(e.target).html().split(//); @@ -193,7 +442,7 @@ var TodoView = Backbone.View.extend({ } return this; }, - template: "
{{{text}}}
", + template: "
{{{text}}}
", addChild: function(el, position) { if(typeof position === 'undefined' || position === -1) { console.log("TodoView:addChild called without a position"); @@ -260,12 +509,14 @@ var TodoModel = Backbone.Model.extend({ }, getPreviousSibling: function(collection) { var parent = this.getParent(collection); + if (!parent) return undefined; var index = parent.findChild(this.id); if (index < 0 || index === 0) return undefined; return parent.getChild(collection, index - 1); }, getNextSibling: function(collection) { var parent = this.getParent(collection); + if (!parent) return undefined; var index = parent.findChild(this.id); var numChildren = parent.getChildrenCount(); if (index < 0 || index === numChildren - 1) return undefined; @@ -377,181 +628,11 @@ var FlowyDocModel = Backbone.Collection.extend({ comparator: 'id', }); -// TODO: More documentation -// TODO: Removing objects/shortcuts/rebinding won't work until this bug is fixed in mousetrap: https://github.com/ccampbell/mousetrap/issues/267 - -// firstShortcut = Shortcut.registerShortcut("Control + k", function() { console.log("Shortcut pressed."); }); -// firstShortcut.rebind("Control + m"); -// Shortcut.registerShortcut({keybinding: "L", objectType: "textbox", action: function() { console.log("L pressed while inside the textbox."); }}); -// o = Shortcut.addObject(aTextbox, "textbox"); // Can come before or after keyboard binding -// o.remove() // Once we lose textbox focus, stop keeping track of it, for example - -var Shortcut = (function(document, _) { - var globalMousetrap = new Mousetrap(); - var globalObject = {element: document, type: "global", isGlobal: true, mousetrap: globalMousetrap}; - var Shortcut = { - _globalObject: globalObject, // The object passed to callbacks when a global keybinding is invoked. - globalMousetrap: globalMousetrap, - boundObjects: [globalObject], - shortcuts: [], - registerShortcut: function(shortcut) { - if (arguments.length > 1) { - shortcut = { - keybinding: arguments[0], - action: arguments[1], - object: arguments[2], - }; - } - var manager = this; - if (!shortcut.ownedByUs) shortcut = _.clone(shortcut); - _.defaults(shortcut, { - register: function() { manager.registerShortcut(this); }, - unregister: function() { manager.unregisterShortcut(this); }, - bind: function(object) { manager.bindShortcut(this, object); }, - unbind: function(object) { manager.unbindShortcut(this, object); }, - rebind: function(keybinding) { manager.rebindShortcut(this, keybinding); }, - object: "global", - - _keybinding: shortcut.keybinding || "", - action: function() {}, - boundObjects: [], - ownedByUs: true - }); - Object.defineProperty(shortcut, "keybinding", { - set: function (newKeybinding) { manager.rebindShortcut(shortcut, newKeybinding); }, - get: function () { return shortcut._keybinding; }, - }); - this.shortcuts.push(shortcut); - _.chain(this.boundObjects).where({'type':shortcut.object}).each(_.partial(this.bindShortcut, shortcut, _), this).value(); - return shortcut; - }, - unregisterShortcut: function(shortcut) { - _.each(_.clone(shortcut.boundObjects), _.partial(this.unbindShortcut, shortcut, _), this); - }, - bindShortcut: function(shortcut, object) { // Do not call directly - if (!_.contains(shortcut.boundObjects, object)) { - object.mousetrap.bind(shortcut.keybinding, _.bind(this.shortcutPressed, this, shortcut, object)); - shortcut.boundObjects.push(object); - } - }, - unbindShortcut: function(shortcut, object) { // Do not call directly with an object - if (!object){ - _.each(shortcut.boundObjects, _.partial(this.unbindShortcut, shortcut, _), this); - } - object.mousetrap.unbind(shortcut.keybinding); - shortcut.boundObjects = _.without(shortcut.boundObjects, object); - }, - rebindShortcut: function(shortcut, keybinding) { - if (keybinding === shortcut.keybinding) return; - var oldKeybinding = shortcut.keybinding; - var objects = shortcut.boundObjects; - shortcut.unbind(); - shorcut._keybinding = keybinding; - _.each(objects, shortcut.bind, shortcut); - if (this.onRebindShortcut) this.onRebindShortcut(shortcut, keybinding, oldKeybinding); - }, - userRebindShortcut: function(shortcut, options) { - // Cancels on focus loss or if the user presses the escape key - options = _.defaults({}, options, { - element: undefined, // If the user clicks outside this element, cancel - onCancel: function() {}, - }); - var manager = this; - var cancelled = false; - var cancel = function() { - if (!cancelled) { - cancelled = true; - if (options.onCancel) options.onCancel(); - } - }; - if (element) element.addEventListener("blur", cancel); - Mousetrap.record(function (keybinding) { // TODO: requires plugin - if (_.equal(keybinding, ["ESCAPE"])) { // TODO: Test - cancel(); - } else { - manager.rebindShortcut(shortcut, keybinding); - if (element) element.removeEventListener("blur", cancel); - } - }); - }, - shortcutPressed: function(shortcut, object) { - if (shortcut.action) shortcut.action(object.element, shortcut, object.type); - }, - onRebindShortcut: function(shortcut, keybinding, oldKeybinding) { - // NOTE: You may want to hook into this if you want to save/load user preferences - // shortcut.id is probably what you want and not the whole shortcut - }, - addObject: function(element, type) { - var manager = this; - object = { - element:element, - type:type, - mousetrap: new Mousetrap(element), - remove: function() { manager.removeObject(this); }, - add: function() { manager.addObject(this.element, this.type); }, - }; - _.chain(this.shortcuts).where({'object': type}).each(_.partial(this.bindShortcut, _, object), this).value(); - if (!_.contains(this.boundObjects)) this.boundObjects.push(object); - return object; - }, - removeObject: function(object) { - _.each(this.shortcuts, _.partial(this.unbindShortcut, _, object), this); - this.boundObjects = _.without(this.boundObjects, object); - }, - get globalObject() { - return this._globalObject; - }, - set globalObject(newElement) { - this.removeObject(this.globalObject, "global"); - this._globalObject = { - element: newElement, - type: "global", - isGlobal: true, - mousetrap: this.globalMousetrap, - }; - _.chain(this.shortcuts).where({'object': "global"}).each(_.partial(this.bindShortcut, _, this.globalObject), this).value(); - if (!_.contains(this.boundObjects)) this.boundObjects.push(this.globalObject); - return this.globalObject; - }, - pause: function() { // TODO: These require plugin, test - this.boundObjects.each(function(object) { - object.mousetrap.pause(); - }); - }, - unpause: function() { - this.boundObjects.each(function(object) { - object.mousetrap.unpause(); - }); - }, - displayShortcuts: function(shortcuts, options) { - options = _.defaults({}, options, { - objectGrouping: "default", // Display one list, or group under headers by object type? "true" "false", or "default" - displayMultiple: true, // Display multiple shortcuts or just the canonical one? - allowRebind: false, // Insert javascript to allow rebinding shortcuts - highlightRepeats: true, // Warn about shortcuts which are bound to multiple actions (if user can remap shortcuts) - }); - if (!shortcuts) shortcuts = this.shortcuts; - if (options.objectGrouping === "default") { - var multipleObjectTypes = _.chain(shortcuts).pluck('object').unique().count().value() > 1; - options.objectGrouping = multipleObjectTypes; - } - // TODO: Return HTML - return ""; - }, - _displayKeybinding: function(keybinding, options) { - options = _.defaults({}, options, { displayMultiple: true }); - // TODO: Return HTML - return ""; - }, - }; - return Shortcut; -})(document, _); - /** * @depend ../views/todo.js * @depend ../models/flowyDoc.js * @depend ../models/todo.js - * @depend ../library/shortcut.js + * @depend ../library/viewShortcuts.js */ var testTodos = [ @@ -610,6 +691,7 @@ var todos = new FlowyDocModel({ var appDefaults = { list: todos }; var AppView = Backbone.View.extend({ el: $("#todo-app"), + shortcutObject: "global", initialize: function(options) { options = _.defaults({}, options, appDefaults); this.list = options.list || new FlowyDocModel(); diff --git a/dist/flowy.unwrapped.js b/dist/flowy.unwrapped.js index 7ca8ada..86ed1df 100644 --- a/dist/flowy.unwrapped.js +++ b/dist/flowy.unwrapped.js @@ -59,8 +59,255 @@ function setRangeAtMarker(markerElement, options) { } } +// TODO: More documentation +// TODO: Removing objects/shortcuts/rebinding won't work until this bug is fixed in mousetrap: https://github.com/ccampbell/mousetrap/issues/267 + +// firstShortcut = Shortcut.registerShortcut("Control + k", function() { console.log("Shortcut pressed."); }); +// firstShortcut.rebind("Control + m"); +// Shortcut.registerShortcut({keybinding: "L", objectType: "textbox", action: function() { console.log("L pressed while inside the textbox."); }}); +// o = Shortcut.addObject(aTextbox, "textbox"); // Can come before or after keyboard binding +// o.remove() // Once we lose textbox focus, stop keeping track of it, for example + +var Shortcut = (function(document, _) { + _.mixin({ + guid : function(){ + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); + return v.toString(16); + }); + } + }); + + var globalMousetrap = new Mousetrap(); + var globalObject = {element: document, type: "global", isGlobal: true, mousetrap: globalMousetrap}; + var Shortcut = { + _globalObject: globalObject, // The object passed to callbacks when a global keybinding is invoked. + globalMousetrap: globalMousetrap, + boundObjects: [globalObject], + shortcuts: {}, + registerShortcut: function(shortcut) { + if (arguments.length > 1) { + shortcut = { + keybinding: arguments[0], + action: arguments[1], + object: arguments[2], + }; + } + var manager = this; + if (!shortcut.ownedByUs) shortcut = _.clone(shortcut); + _.defaults(shortcut, { + register: function() { manager.registerShortcut(this); }, + unregister: function() { manager.unregisterShortcut(this); }, + bind: function(object) { manager.bindShortcut(this, object); }, + unbind: function(object) { manager.unbindShortcut(this, object); }, + rebind: function(keybinding) { manager.rebindShortcut(this, keybinding); }, + object: "global", + + _keybinding: shortcut.keybinding || "", + action: function() {}, + boundObjects: [], + ownedByUs: true, + id: _.guid(), + }); + Object.defineProperty(shortcut, "keybinding", { + set: function (newKeybinding) { manager.rebindShortcut(shortcut, newKeybinding); }, + get: function () { return shortcut._keybinding; }, + }); + if (this.shortcuts[shortcut.id]) return this.shortcuts[shortcut.id]; + this.shortcuts[shortcut.id] = shortcut; + _.chain(this.boundObjects).where({'type':shortcut.object}).each(_.partial(this.bindShortcut, shortcut, _), this).value(); + return shortcut; + }, + unregisterShortcut: function(shortcut) { + _.each(_.clone(shortcut.boundObjects), _.partial(this.unbindShortcut, shortcut, _), this); + delete this.shortcuts[shortcut.id]; + }, + bindShortcut: function(shortcut, object) { // Do not call directly + if (!_.contains(shortcut.boundObjects, object)) { + object.mousetrap.bind(shortcut.keybinding, _.bind(this.shortcutPressed, this, shortcut, object)); + shortcut.boundObjects.push(object); + } + }, + unbindShortcut: function(shortcut, object) { // Do not call directly with an object + if (!object){ + _.each(shortcut.boundObjects, _.partial(this.unbindShortcut, shortcut, _), this); + } + object.mousetrap.unbind(shortcut.keybinding); + shortcut.boundObjects = _.without(shortcut.boundObjects, object); + }, + rebindShortcut: function(shortcut, keybinding) { + if (keybinding === shortcut.keybinding) return; + var oldKeybinding = shortcut.keybinding; + var objects = shortcut.boundObjects; + shortcut.unbind(); + shorcut._keybinding = keybinding; + _.each(objects, shortcut.bind, shortcut); + if (this.onRebindShortcut) this.onRebindShortcut(shortcut, keybinding, oldKeybinding); + }, + userRebindShortcut: function(shortcut, options) { + // Cancels on focus loss or if the user presses the escape key + options = _.defaults({}, options, { + element: undefined, // If the user clicks outside this element, cancel + onCancel: function() {}, + }); + var manager = this; + var cancelled = false; + var cancel = function() { + if (!cancelled) { + cancelled = true; + if (options.onCancel) options.onCancel(); + } + }; + if (element) element.addEventListener("blur", cancel); + Mousetrap.record(function (keybinding) { // TODO: requires plugin + if (_.equal(keybinding, ["ESCAPE"])) { // TODO: Test + cancel(); + } else { + manager.rebindShortcut(shortcut, keybinding); + if (element) element.removeEventListener("blur", cancel); + } + }); + }, + shortcutPressed: function(shortcut, object) { + if (shortcut.action) shortcut.action(object.element, shortcut, object.type); + }, + onRebindShortcut: function(shortcut, keybinding, oldKeybinding) { + // NOTE: You may want to hook into this if you want to save/load user preferences + // shortcut.id is probably what you want and not the whole shortcut + }, + addObject: function(element, type) { + var manager = this; + object = { + element:element, + type:type, + mousetrap: Mousetrap(element), + remove: function() { manager.removeObject(this); }, + add: function() { manager.addObject(this.element, this.type); }, + }; + _.chain(this.shortcuts).where({'object': type}).each(_.partial(this.bindShortcut, _, object), this).value(); + if (!_.contains(this.boundObjects)) this.boundObjects.push(object); + return object; + }, + removeObject: function(object) { + _.each(this.shortcuts, _.partial(this.unbindShortcut, _, object), this); + this.boundObjects = _.without(this.boundObjects, object); + }, + get globalObject() { + return this._globalObject; + }, + set globalObject(newElement) { + this.removeObject(this.globalObject, "global"); + this._globalObject = { + element: newElement, + type: "global", + isGlobal: true, + mousetrap: this.globalMousetrap, + }; + _.chain(this.shortcuts).where({'object': "global"}).each(_.partial(this.bindShortcut, _, this.globalObject), this).value(); + if (!_.contains(this.boundObjects)) this.boundObjects.push(this.globalObject); + return this.globalObject; + }, + pause: function() { // TODO: These require plugin, test + this.boundObjects.each(function(object) { + object.mousetrap.pause(); + }); + }, + unpause: function() { + this.boundObjects.each(function(object) { + object.mousetrap.unpause(); + }); + }, + displayShortcuts: function(shortcuts, options) { + options = _.defaults({}, options, { + objectGrouping: "default", // Display one list, or group under headers by object type? "true" "false", or "default" + displayMultiple: true, // Display multiple shortcuts or just the canonical one? + allowRebind: false, // Insert javascript to allow rebinding shortcuts + highlightRepeats: true, // Warn about shortcuts which are bound to multiple actions (if user can remap shortcuts) + }); + if (!shortcuts) shortcuts = this.shortcuts; + if (options.objectGrouping === "default") { + var multipleObjectTypes = _.chain(shortcuts).pluck('object').unique().count().value() > 1; + options.objectGrouping = multipleObjectTypes; + } + // TODO: Return HTML + return ""; + }, + _displayKeybinding: function(keybinding, options) { + options = _.defaults({}, options, { displayMultiple: true }); + // TODO: Return HTML + return ""; + }, + }; + return Shortcut; +})(document, _); + +/** + * @depend ../library/shortcut.js + */ + + +// Integrates "Shortcuts" library with backbone views, to allow events in the form "Shortcut(id, description, default keycombo)": action +// Shortcut selectors are not supported +(function (View, Shortcut) { + var delegateEvents = View.delegateEvents; + var undelegateEvents = View.undelegateEvents; + var ShortcutRegex = /^Shortcut\("([^")]*)", ?"([^")]*)", ?"([^")]*)"\) (.*)$/; + function delegate(id, description, defaultKeybinding, objectType, callback){ + var shortcut = Shortcut.registerShortcut({ + id: id, + description: description, + keybinding: defaultKeybinding, // TODO: Add support for rebinding + object: objectType, + action: callback + }); + } + function delegateMousetrapEvents(events, objectType) { + for (var key in events) { + var method = events[key]; + if (!_.isFunction(method)) method = this[events[key]]; + if (!method) continue; + var match = key.match(ShortcutRegex); + delegate.call(this, match[1], match[2], match[3], objectType, _.bind(method, this)); + } + this.shortcutObjects = this.shortcutObjects || []; + if (objectType === "global") { + Shortcut.globalObject = this.$el[0]; + this._shortcutObject = Shortcut.globalObject; + } else { + this._shortcutObject = Shortcut.addObject(this.$el[0], objectType); + } + } + View.delegateEvents = function(events) { + if (!(events || (events = _.result(this, 'events')))) return this; + var mousetrapEvents = {}; + var nonMousetrapEvents = {}; + for (var key in events) { + if (key.match(ShortcutRegex)) { + mousetrapEvents[key] = events[key]; + } else { + nonMousetrapEvents[key] = events[key]; + } + } + delegateEvents.call(this, nonMousetrapEvents); + if (_.size(mousetrapEvents) > 0) { + var type = this.shortcutObject || this.className; + if (!type) { + throw "Mousetrap events need to specify a type on the view object via .className or .shortcutObject. 'global' will catch all keystrokes rather than binding to the selector"; + } + delegateMousetrapEvents.call(this, mousetrapEvents, type); + } + return this; + }; + View.undelegateEvents = function() { + if (this._shortcutObject) Shortcut.removeObject(this._shortcutObject); + // Also undelegate mousetrap events + undelegateEvents.apply(this, arguments); + }; +})(Backbone.View.prototype, Shortcut); + /** * @depend ../library/cursorToEnd.js + * @depend ../library/viewShortcuts.js */ @@ -68,21 +315,18 @@ var TodoView = Backbone.View.extend({ tagName: 'div', className: 'todo', events: { - //"click > .checkbox": "toggleComplete", "input > .text": "textChange", "blur > .text": "render", // Because the model shouldn't update the view during active editing, add a re-render at the end "keydown > .text": "keydown", + 'Shortcut("toggleComplete", "Mark an item as complete or not", "ctrl+enter") > .text': "toggleComplete", + 'Shortcut("backspace", "Combine an item with the previous item", "backspace") > .text': "backspace", + 'Shortcut("delete", "Combine an item with the next item", "del") > .text': "delete", }, initialize: function() { this.childViewPositions = []; this.listenTo(this.model, "change", this.render); this.listenTo(this.model, 'destroy', this.remove); }, - toggleComplete: function(e) { - this.model.toggleComplete(); - e.stopPropagation(); - return this; - }, startEditingText: function(options) { options = options || {}; if (options.atEnd) { @@ -109,16 +353,7 @@ var TodoView = Backbone.View.extend({ decodeText: function(encodedText) { return $("
").html(encodedText).text(); }, - keydown: function(e) { - if (e.keyCode == 46 /* backspace */ || e.keyCode == 8 /* delete */) { - return this.backspace(e); - } else if (e.keyCode == 17) { - return this.control(e); - } else { - console.log(e.keyCode + " key pressed"); - } - }, - control: function(e) { + toggleComplete: function(e) { this.stopEditingText(); this.model.toggleComplete(); var next = this.model.nextNode(this.model.collection, {"childrenAllowed":false}); @@ -136,7 +371,7 @@ var TodoView = Backbone.View.extend({ e.preventDefault(); this.model.remove(this.model.collection); previousNode.getView().startEditingText({"atEnd":true}); - } else if (this.isFocusAtBeginning() && e.keyCode !== 46 /* backspace only */) { + } else if (this.isFocusAtBeginning()) { e.preventDefault(); var text = this.model.get("text"); this.model.remove(this.model.collection); @@ -145,6 +380,20 @@ var TodoView = Backbone.View.extend({ previousNode.getView().startEditingText({"atMarker": ".focus"}); } }, + "delete": function(e) { + if (this.model.hasChildren()) { + return; + } + var previousNode = this.model.previousNode(this.model.collection); + if (!previousNode) { + return; + } + if (this.model.get("text") === "") { + e.preventDefault(); + this.model.remove(this.model.collection); + previousNode.getView().startEditingText({"atEnd":true}); + } + }, textChange: function(e) { var collection = this.model.collection; var lines = $(e.target).html().split(//); @@ -192,7 +441,7 @@ var TodoView = Backbone.View.extend({ } return this; }, - template: "
{{{text}}}
", + template: "
{{{text}}}
", addChild: function(el, position) { if(typeof position === 'undefined' || position === -1) { console.log("TodoView:addChild called without a position"); @@ -259,12 +508,14 @@ var TodoModel = Backbone.Model.extend({ }, getPreviousSibling: function(collection) { var parent = this.getParent(collection); + if (!parent) return undefined; var index = parent.findChild(this.id); if (index < 0 || index === 0) return undefined; return parent.getChild(collection, index - 1); }, getNextSibling: function(collection) { var parent = this.getParent(collection); + if (!parent) return undefined; var index = parent.findChild(this.id); var numChildren = parent.getChildrenCount(); if (index < 0 || index === numChildren - 1) return undefined; @@ -376,181 +627,11 @@ var FlowyDocModel = Backbone.Collection.extend({ comparator: 'id', }); -// TODO: More documentation -// TODO: Removing objects/shortcuts/rebinding won't work until this bug is fixed in mousetrap: https://github.com/ccampbell/mousetrap/issues/267 - -// firstShortcut = Shortcut.registerShortcut("Control + k", function() { console.log("Shortcut pressed."); }); -// firstShortcut.rebind("Control + m"); -// Shortcut.registerShortcut({keybinding: "L", objectType: "textbox", action: function() { console.log("L pressed while inside the textbox."); }}); -// o = Shortcut.addObject(aTextbox, "textbox"); // Can come before or after keyboard binding -// o.remove() // Once we lose textbox focus, stop keeping track of it, for example - -var Shortcut = (function(document, _) { - var globalMousetrap = new Mousetrap(); - var globalObject = {element: document, type: "global", isGlobal: true, mousetrap: globalMousetrap}; - var Shortcut = { - _globalObject: globalObject, // The object passed to callbacks when a global keybinding is invoked. - globalMousetrap: globalMousetrap, - boundObjects: [globalObject], - shortcuts: [], - registerShortcut: function(shortcut) { - if (arguments.length > 1) { - shortcut = { - keybinding: arguments[0], - action: arguments[1], - object: arguments[2], - }; - } - var manager = this; - if (!shortcut.ownedByUs) shortcut = _.clone(shortcut); - _.defaults(shortcut, { - register: function() { manager.registerShortcut(this); }, - unregister: function() { manager.unregisterShortcut(this); }, - bind: function(object) { manager.bindShortcut(this, object); }, - unbind: function(object) { manager.unbindShortcut(this, object); }, - rebind: function(keybinding) { manager.rebindShortcut(this, keybinding); }, - object: "global", - - _keybinding: shortcut.keybinding || "", - action: function() {}, - boundObjects: [], - ownedByUs: true - }); - Object.defineProperty(shortcut, "keybinding", { - set: function (newKeybinding) { manager.rebindShortcut(shortcut, newKeybinding); }, - get: function () { return shortcut._keybinding; }, - }); - this.shortcuts.push(shortcut); - _.chain(this.boundObjects).where({'type':shortcut.object}).each(_.partial(this.bindShortcut, shortcut, _), this).value(); - return shortcut; - }, - unregisterShortcut: function(shortcut) { - _.each(_.clone(shortcut.boundObjects), _.partial(this.unbindShortcut, shortcut, _), this); - }, - bindShortcut: function(shortcut, object) { // Do not call directly - if (!_.contains(shortcut.boundObjects, object)) { - object.mousetrap.bind(shortcut.keybinding, _.bind(this.shortcutPressed, this, shortcut, object)); - shortcut.boundObjects.push(object); - } - }, - unbindShortcut: function(shortcut, object) { // Do not call directly with an object - if (!object){ - _.each(shortcut.boundObjects, _.partial(this.unbindShortcut, shortcut, _), this); - } - object.mousetrap.unbind(shortcut.keybinding); - shortcut.boundObjects = _.without(shortcut.boundObjects, object); - }, - rebindShortcut: function(shortcut, keybinding) { - if (keybinding === shortcut.keybinding) return; - var oldKeybinding = shortcut.keybinding; - var objects = shortcut.boundObjects; - shortcut.unbind(); - shorcut._keybinding = keybinding; - _.each(objects, shortcut.bind, shortcut); - if (this.onRebindShortcut) this.onRebindShortcut(shortcut, keybinding, oldKeybinding); - }, - userRebindShortcut: function(shortcut, options) { - // Cancels on focus loss or if the user presses the escape key - options = _.defaults({}, options, { - element: undefined, // If the user clicks outside this element, cancel - onCancel: function() {}, - }); - var manager = this; - var cancelled = false; - var cancel = function() { - if (!cancelled) { - cancelled = true; - if (options.onCancel) options.onCancel(); - } - }; - if (element) element.addEventListener("blur", cancel); - Mousetrap.record(function (keybinding) { // TODO: requires plugin - if (_.equal(keybinding, ["ESCAPE"])) { // TODO: Test - cancel(); - } else { - manager.rebindShortcut(shortcut, keybinding); - if (element) element.removeEventListener("blur", cancel); - } - }); - }, - shortcutPressed: function(shortcut, object) { - if (shortcut.action) shortcut.action(object.element, shortcut, object.type); - }, - onRebindShortcut: function(shortcut, keybinding, oldKeybinding) { - // NOTE: You may want to hook into this if you want to save/load user preferences - // shortcut.id is probably what you want and not the whole shortcut - }, - addObject: function(element, type) { - var manager = this; - object = { - element:element, - type:type, - mousetrap: new Mousetrap(element), - remove: function() { manager.removeObject(this); }, - add: function() { manager.addObject(this.element, this.type); }, - }; - _.chain(this.shortcuts).where({'object': type}).each(_.partial(this.bindShortcut, _, object), this).value(); - if (!_.contains(this.boundObjects)) this.boundObjects.push(object); - return object; - }, - removeObject: function(object) { - _.each(this.shortcuts, _.partial(this.unbindShortcut, _, object), this); - this.boundObjects = _.without(this.boundObjects, object); - }, - get globalObject() { - return this._globalObject; - }, - set globalObject(newElement) { - this.removeObject(this.globalObject, "global"); - this._globalObject = { - element: newElement, - type: "global", - isGlobal: true, - mousetrap: this.globalMousetrap, - }; - _.chain(this.shortcuts).where({'object': "global"}).each(_.partial(this.bindShortcut, _, this.globalObject), this).value(); - if (!_.contains(this.boundObjects)) this.boundObjects.push(this.globalObject); - return this.globalObject; - }, - pause: function() { // TODO: These require plugin, test - this.boundObjects.each(function(object) { - object.mousetrap.pause(); - }); - }, - unpause: function() { - this.boundObjects.each(function(object) { - object.mousetrap.unpause(); - }); - }, - displayShortcuts: function(shortcuts, options) { - options = _.defaults({}, options, { - objectGrouping: "default", // Display one list, or group under headers by object type? "true" "false", or "default" - displayMultiple: true, // Display multiple shortcuts or just the canonical one? - allowRebind: false, // Insert javascript to allow rebinding shortcuts - highlightRepeats: true, // Warn about shortcuts which are bound to multiple actions (if user can remap shortcuts) - }); - if (!shortcuts) shortcuts = this.shortcuts; - if (options.objectGrouping === "default") { - var multipleObjectTypes = _.chain(shortcuts).pluck('object').unique().count().value() > 1; - options.objectGrouping = multipleObjectTypes; - } - // TODO: Return HTML - return ""; - }, - _displayKeybinding: function(keybinding, options) { - options = _.defaults({}, options, { displayMultiple: true }); - // TODO: Return HTML - return ""; - }, - }; - return Shortcut; -})(document, _); - /** * @depend ../views/todo.js * @depend ../models/flowyDoc.js * @depend ../models/todo.js - * @depend ../library/shortcut.js + * @depend ../library/viewShortcuts.js */ var testTodos = [ @@ -609,6 +690,7 @@ var todos = new FlowyDocModel({ var appDefaults = { list: todos }; var AppView = Backbone.View.extend({ el: $("#todo-app"), + shortcutObject: "global", initialize: function(options) { options = _.defaults({}, options, appDefaults); this.list = options.list || new FlowyDocModel(); diff --git a/src/library/viewShortcuts.js b/src/library/viewShortcuts.js new file mode 100644 index 0000000..e4f90ed --- /dev/null +++ b/src/library/viewShortcuts.js @@ -0,0 +1,63 @@ +/** + * @depend ../library/shortcut.js + */ + + +// Integrates "Shortcuts" library with backbone views, to allow events in the form "Shortcut(id, description, default keycombo)": action +// Shortcut selectors are not supported +(function (View, Shortcut) { + var delegateEvents = View.delegateEvents; + var undelegateEvents = View.undelegateEvents; + var ShortcutRegex = /^Shortcut\("([^")]*)", ?"([^")]*)", ?"([^")]*)"\) (.*)$/; + function delegate(id, description, defaultKeybinding, objectType, callback){ + var shortcut = Shortcut.registerShortcut({ + id: id, + description: description, + keybinding: defaultKeybinding, // TODO: Add support for rebinding + object: objectType, + action: callback + }); + } + function delegateMousetrapEvents(events, objectType) { + for (var key in events) { + var method = events[key]; + if (!_.isFunction(method)) method = this[events[key]]; + if (!method) continue; + var match = key.match(ShortcutRegex); + delegate.call(this, match[1], match[2], match[3], objectType, _.bind(method, this)); + } + this.shortcutObjects = this.shortcutObjects || []; + if (objectType === "global") { + Shortcut.globalObject = this.$el[0]; + this._shortcutObject = Shortcut.globalObject; + } else { + this._shortcutObject = Shortcut.addObject(this.$el[0], objectType); + } + } + View.delegateEvents = function(events) { + if (!(events || (events = _.result(this, 'events')))) return this; + var mousetrapEvents = {}; + var nonMousetrapEvents = {}; + for (var key in events) { + if (key.match(ShortcutRegex)) { + mousetrapEvents[key] = events[key]; + } else { + nonMousetrapEvents[key] = events[key]; + } + } + delegateEvents.call(this, nonMousetrapEvents); + if (_.size(mousetrapEvents) > 0) { + var type = this.shortcutObject || this.className; + if (!type) { + throw "Mousetrap events need to specify a type on the view object via .className or .shortcutObject. 'global' will catch all keystrokes rather than binding to the selector"; + } + delegateMousetrapEvents.call(this, mousetrapEvents, type); + } + return this; + }; + View.undelegateEvents = function() { + if (this._shortcutObject) Shortcut.removeObject(this._shortcutObject); + // Also undelegate mousetrap events + undelegateEvents.apply(this, arguments); + }; +})(Backbone.View.prototype, Shortcut); diff --git a/src/models/todo.js b/src/models/todo.js index 8c03109..08b0410 100644 --- a/src/models/todo.js +++ b/src/models/todo.js @@ -36,12 +36,14 @@ var TodoModel = Backbone.Model.extend({ }, getPreviousSibling: function(collection) { var parent = this.getParent(collection); + if (!parent) return undefined; var index = parent.findChild(this.id); if (index < 0 || index === 0) return undefined; return parent.getChild(collection, index - 1); }, getNextSibling: function(collection) { var parent = this.getParent(collection); + if (!parent) return undefined; var index = parent.findChild(this.id); var numChildren = parent.getChildrenCount(); if (index < 0 || index === numChildren - 1) return undefined; diff --git a/src/views/app.js b/src/views/app.js index fba6451..1bb884a 100644 --- a/src/views/app.js +++ b/src/views/app.js @@ -2,7 +2,7 @@ * @depend ../views/todo.js * @depend ../models/flowyDoc.js * @depend ../models/todo.js - * @depend ../library/shortcut.js + * @depend ../library/viewShortcuts.js */ var testTodos = [ @@ -61,6 +61,7 @@ var todos = new FlowyDocModel({ var appDefaults = { list: todos }; var AppView = Backbone.View.extend({ el: $("#todo-app"), + shortcutObject: "global", initialize: function(options) { options = _.defaults({}, options, appDefaults); this.list = options.list || new FlowyDocModel(); diff --git a/src/views/todo.js b/src/views/todo.js index 81cbb5c..dc9451f 100644 --- a/src/views/todo.js +++ b/src/views/todo.js @@ -1,5 +1,6 @@ /** * @depend ../library/cursorToEnd.js + * @depend ../library/viewShortcuts.js */ @@ -7,21 +8,18 @@ var TodoView = Backbone.View.extend({ tagName: 'div', className: 'todo', events: { - //"click > .checkbox": "toggleComplete", "input > .text": "textChange", "blur > .text": "render", // Because the model shouldn't update the view during active editing, add a re-render at the end "keydown > .text": "keydown", + 'Shortcut("toggleComplete", "Mark an item as complete or not", "ctrl+enter") > .text': "toggleComplete", + 'Shortcut("backspace", "Combine an item with the previous item", "backspace") > .text': "backspace", + 'Shortcut("delete", "Combine an item with the next item", "del") > .text': "delete", }, initialize: function() { this.childViewPositions = []; this.listenTo(this.model, "change", this.render); this.listenTo(this.model, 'destroy', this.remove); }, - toggleComplete: function(e) { - this.model.toggleComplete(); - e.stopPropagation(); - return this; - }, startEditingText: function(options) { options = options || {}; if (options.atEnd) { @@ -48,16 +46,7 @@ var TodoView = Backbone.View.extend({ decodeText: function(encodedText) { return $("
").html(encodedText).text(); }, - keydown: function(e) { - if (e.keyCode == 46 /* backspace */ || e.keyCode == 8 /* delete */) { - return this.backspace(e); - } else if (e.keyCode == 17) { - return this.control(e); - } else { - console.log(e.keyCode + " key pressed"); - } - }, - control: function(e) { + toggleComplete: function(e) { this.stopEditingText(); this.model.toggleComplete(); var next = this.model.nextNode(this.model.collection, {"childrenAllowed":false}); @@ -75,7 +64,7 @@ var TodoView = Backbone.View.extend({ e.preventDefault(); this.model.remove(this.model.collection); previousNode.getView().startEditingText({"atEnd":true}); - } else if (this.isFocusAtBeginning() && e.keyCode !== 46 /* backspace only */) { + } else if (this.isFocusAtBeginning()) { e.preventDefault(); var text = this.model.get("text"); this.model.remove(this.model.collection); @@ -84,6 +73,20 @@ var TodoView = Backbone.View.extend({ previousNode.getView().startEditingText({"atMarker": ".focus"}); } }, + "delete": function(e) { + if (this.model.hasChildren()) { + return; + } + var previousNode = this.model.previousNode(this.model.collection); + if (!previousNode) { + return; + } + if (this.model.get("text") === "") { + e.preventDefault(); + this.model.remove(this.model.collection); + previousNode.getView().startEditingText({"atEnd":true}); + } + }, textChange: function(e) { var collection = this.model.collection; var lines = $(e.target).html().split(//); @@ -131,7 +134,7 @@ var TodoView = Backbone.View.extend({ } return this; }, - template: "
{{{text}}}
", + template: "
{{{text}}}
", addChild: function(el, position) { if(typeof position === 'undefined' || position === -1) { console.log("TodoView:addChild called without a position");