From 6c5d01a99d916f59f6486073375af1151ddd69b1 Mon Sep 17 00:00:00 2001 From: Mo Bitar Date: Sat, 11 May 2019 21:31:59 -0500 Subject: [PATCH] Keyboard shortcuts for modifying options menu, creating new note, and deleting note --- .../javascripts/app/controllers/editor.js | 82 ++++++++++------ .../javascripts/app/controllers/notes.js | 13 ++- .../app/services/keyboardManager.js | 94 +++++++++++++++++++ app/assets/templates/editor.html.haml | 8 +- 4 files changed, 164 insertions(+), 33 deletions(-) create mode 100644 app/assets/javascripts/app/services/keyboardManager.js diff --git a/app/assets/javascripts/app/controllers/editor.js b/app/assets/javascripts/app/controllers/editor.js index c035c4062..a1d5a2f1c 100644 --- a/app/assets/javascripts/app/controllers/editor.js +++ b/app/assets/javascripts/app/controllers/editor.js @@ -23,7 +23,8 @@ angular.module('app') } }) .controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, actionsManager, - syncManager, modelManager, themeManager, componentManager, storageManager, sessionHistory, privilegesManager) { + syncManager, modelManager, themeManager, componentManager, storageManager, sessionHistory, + privilegesManager, keyboardManager) { this.spellcheck = true; this.componentManager = componentManager; @@ -121,6 +122,9 @@ angular.module('app') this.showExtensions = false; this.showMenu = false; this.noteStatus = null; + // When setting alt key down and deleting note, an alert will come up and block the key up event when alt is released. + // We reset it on set note so that the alt menu restores to default. + this.altKeyDown = false; this.loadTagsString(); let onReady = () => { @@ -796,10 +800,24 @@ angular.module('app') syncManager.sync(); } + this.altKeyObserver = keyboardManager.addKeyObserver({ + modifiers: [KeyboardManager.KeyModifierAlt], + onKeyDown: () => { + this.altKeyDown = true; + }, + onKeyUp: () => { + this.altKeyDown = false; + } + }) - - - + this.deleteKeyObserver = keyboardManager.addKeyObserver({ + key: KeyboardManager.KeyBackspace, + notElementIds: ["note-text-editor", "note-title-editor"], + modifiers: [KeyboardManager.KeyModifierMeta], + onKeyDown: () => { + this.deleteNote(); + }, + }) /* Editor Customization @@ -810,49 +828,57 @@ angular.module('app') return; } this.loadedTabListener = true; + /** * Insert 4 spaces when a tab key is pressed, * only used when inside of the text editor. * If the shift key is pressed first, this event is * not fired. */ - var parent = this; - var handleTab = function (event) { - if(!event.shiftKey && event.which == 9) { - if(parent.note.locked) { + + const editor = document.getElementById("note-text-editor"); + this.tabObserver = keyboardManager.addKeyObserver({ + element: editor, + key: KeyboardManager.KeyTab, + onKeyDown: (event) => { + if(event.shiftKey) { return; } + + if(this.note.locked) { + return; + } + event.preventDefault(); // Using document.execCommand gives us undo support - if(!document.execCommand("insertText", false, "\t")) { + let insertSuccessful = document.execCommand("insertText", false, "\t"); + if(!insertSuccessful) { // document.execCommand works great on Chrome/Safari but not Firefox - var start = this.selectionStart; - var end = this.selectionEnd; + var start = editor.selectionStart; + var end = editor.selectionEnd; var spaces = " "; // Insert 4 spaces - this.value = this.value.substring(0, start) - + spaces + this.value.substring(end); + editor.value = editor.value.substring(0, start) + + spaces + editor.value.substring(end); // Place cursor 4 spaces away from where // the tab key was pressed - this.selectionStart = this.selectionEnd = start + 4; + editor.selectionStart = editor.selectionEnd = start + 4; } - parent.note.text = this.value; - parent.changesMade({bypassDebouncer: true}); + this.note.text = editor.value; + this.changesMade({bypassDebouncer: true}); + }, + }) + + // This handles when the editor itself is destroyed, and not when our controller is destroyed. + angular.element(editor).on('$destroy', function(){ + if(this.tabObserver) { + keyboardManager.removeKeyObserver(this.tabObserver); + this.loadedTabListener = false; } - } - - var element = document.getElementById("note-text-editor"); - element.addEventListener('keydown', handleTab); - - angular.element(element).on('$destroy', function(){ - window.removeEventListener('keydown', handleTab); - this.loadedTabListener = false; - }.bind(this)) + }.bind(this)); } - - - }); +}); diff --git a/app/assets/javascripts/app/controllers/notes.js b/app/assets/javascripts/app/controllers/notes.js index 67123c9e5..00d5989bf 100644 --- a/app/assets/javascripts/app/controllers/notes.js +++ b/app/assets/javascripts/app/controllers/notes.js @@ -23,7 +23,7 @@ angular.module('app') } }) .controller('NotesCtrl', function (authManager, $timeout, $rootScope, modelManager, - syncManager, storageManager, desktopManager, privilegesManager) { + syncManager, storageManager, desktopManager, privilegesManager, keyboardManager) { this.panelController = {}; this.searchSubmitted = false; @@ -566,4 +566,15 @@ angular.module('app') return result; }; + // In the browser we're not allowed to override cmd/ctrl + n, so we have to use Control modifier as well. + // These rules don't apply to desktop, but probably better to be consistent. + this.newNoteKeyObserver = keyboardManager.addKeyObserver({ + key: "n", + modifiers: [KeyboardManager.KeyModifierMeta, KeyboardManager.KeyModifierCtrl], + onKeyDown: (event) => { + event.preventDefault(); + this.createNewNote(); + } + }) + }); diff --git a/app/assets/javascripts/app/services/keyboardManager.js b/app/assets/javascripts/app/services/keyboardManager.js new file mode 100644 index 000000000..01a9549e0 --- /dev/null +++ b/app/assets/javascripts/app/services/keyboardManager.js @@ -0,0 +1,94 @@ +class KeyboardManager { + + constructor($timeout) { + this.observers = []; + + this.$timeout = $timeout; + + KeyboardManager.KeyTab = "Tab"; + KeyboardManager.KeyBackspace = "Backspace"; + + KeyboardManager.KeyModifierShift = "Shift"; + KeyboardManager.KeyModifierCtrl = "Control"; + // ⌘ key on Mac, ⊞ key on Windows + KeyboardManager.KeyModifierMeta = "Meta"; + KeyboardManager.KeyModifierAlt = "Alt"; + + KeyboardManager.KeyEventDown = "KeyEventDown"; + KeyboardManager.KeyEventUp = "KeyEventUp"; + + window.addEventListener('keydown', this.handleKeyDown.bind(this)); + window.addEventListener('keyup', this.handleKeyUp.bind(this)); + } + + eventMatchesKeyAndModifiers(event, key, modifiers = []) { + + for(let modifier of modifiers) { + // For a modifier like ctrlKey, must check both event.ctrlKey and event.key. + // That's because on keyup, event.ctrlKey would be false, but event.key == Control would be true. + let matches = ( + ((event.ctrlKey || event.key == KeyboardManager.KeyModifierCtrl) && modifier === KeyboardManager.KeyModifierCtrl) || + ((event.metaKey || event.key == KeyboardManager.KeyModifierMeta) && modifier === KeyboardManager.KeyModifierMeta) || + ((event.altKey || event.key == KeyboardManager.KeyModifierAlt) && modifier === KeyboardManager.KeyModifierAlt) || + ((event.shiftKey || event.key == KeyboardManager.KeyModifierShift) && modifier === KeyboardManager.KeyModifierShift) + ) + + if(!matches) { + return false; + } + } + + // Modifers match, check key + if(!key) { + return true; + } + + + return key == event.key; + } + + notifyObserver(event, keyEventType) { + for(let observer of this.observers) { + if(observer.element && event.target != observer.element) { + continue; + } + + if(observer.notElement && observer.notElement == event.target) { + continue; + } + + if(observer.notElementIds && observer.notElementIds.includes(event.target.id)) { + continue; + } + + if(this.eventMatchesKeyAndModifiers(event, observer.key, observer.modifiers)) { + let callback = keyEventType == KeyboardManager.KeyEventDown ? observer.onKeyDown : observer.onKeyUp; + if(callback) { + this.$timeout(() => { + callback(event); + }) + } + } + } + } + + handleKeyDown(event) { + this.notifyObserver(event, KeyboardManager.KeyEventDown); + } + + handleKeyUp(event) { + this.notifyObserver(event, KeyboardManager.KeyEventUp); + } + + addKeyObserver({key, modifiers, onKeyDown, onKeyUp, element, notElement, notElementIds}) { + let observer = {key, modifiers, onKeyDown, onKeyUp, element, notElement, notElementIds}; + this.observers.push(observer); + return observer; + } + + removeKeyObserver(observer) { + this.observers.splice(this.observers.indexOf(observer), 1); + } +} + +angular.module('app').service('keyboardManager', KeyboardManager); diff --git a/app/assets/templates/editor.html.haml b/app/assets/templates/editor.html.haml index bba107f19..609e11d3a 100644 --- a/app/assets/templates/editor.html.haml +++ b/app/assets/templates/editor.html.haml @@ -38,13 +38,13 @@ %menu-row{"label" => "ctrl.note.locked ? 'Unlock' : 'Lock'", "action" => "ctrl.selectedMenuItem(true); ctrl.toggleLockNote()", "desc" => "'Locking notes prevents unintentional editing'"} %menu-row{"label" => "ctrl.note.content.protected ? 'Unprotect' : 'Protect'", "action" => "ctrl.selectedMenuItem(true); ctrl.toggleProtectNote()", "desc" => "'Protecting a note will require credentials to view it (Manage Privileges via Account menu)'"} %menu-row{"label" => "'Preview'", "circle" => "ctrl.note.content.hidePreview ? 'danger' : 'success'", "circle-align" => "'right'", "action" => "ctrl.selectedMenuItem(true); ctrl.toggleNotePreview()", "desc" => "'Hide or unhide the note preview from the list of notes'"} - %menu-row{"ng-show" => "!ctrl.note.content.trashed && !ctrl.note.errorDecrypting", "label" => "'Move to Trash'", "action" => "ctrl.selectedMenuItem(); ctrl.deleteNote()", "stylekit-class" => "'warning'", "desc" => "'Send this note to the trash'"} + %menu-row{"ng-show" => "!ctrl.altKeyDown && !ctrl.note.content.trashed && !ctrl.note.errorDecrypting", "label" => "'Move to Trash'", "action" => "ctrl.selectedMenuItem(); ctrl.deleteNote()", "stylekit-class" => "'warning'", "desc" => "'Send this note to the trash'"} %menu-row{"ng-show" => "!ctrl.note.content.trashed && ctrl.note.errorDecrypting", "label" => "'Delete Permanently'", "action" => "ctrl.selectedMenuItem(); ctrl.deleteNotePermanantely()", "stylekit-class" => "'danger'", "desc" => "'Delete this note permanently from all your devices'"} - %div{"ng-if" => "ctrl.note.content.trashed"} - %menu-row{"label" => "'Restore'", "action" => "ctrl.selectedMenuItem(true); ctrl.restoreTrashedNote()", "stylekit-class" => "'info'", "desc" => "'Undelete this note and restore it back into your notes'"} + %div{"ng-if" => "ctrl.note.content.trashed || ctrl.altKeyDown"} + %menu-row{"label" => "'Restore'", "ng-show" => "ctrl.note.content.trashed", "action" => "ctrl.selectedMenuItem(true); ctrl.restoreTrashedNote()", "stylekit-class" => "'info'", "desc" => "'Undelete this note and restore it back into your notes'"} %menu-row{"label" => "'Delete Permanently'", "action" => "ctrl.selectedMenuItem(true); ctrl.deleteNotePermanantely()", "stylekit-class" => "'danger'", "desc" => "'Delete this note permanently from all your devices'"} - %menu-row{"label" => "'Empty Trash'", "subtitle" => "ctrl.getTrashCount() + ' notes in trash'", "action" => "ctrl.selectedMenuItem(true); ctrl.emptyTrash()", "stylekit-class" => "'danger'", "desc" => "'Permanently delete all notes in the trash'"} + %menu-row{"label" => "'Empty Trash'", "ng-show" => "ctrl.note.content.trashed || !ctrl.altKeyDown", "subtitle" => "ctrl.getTrashCount() + ' notes in trash'", "action" => "ctrl.selectedMenuItem(true); ctrl.emptyTrash()", "stylekit-class" => "'danger'", "desc" => "'Permanently delete all notes in the trash'"} .sk-menu-panel-section .sk-menu-panel-header