From 616a083f98490a79761a466346b80c740607be59 Mon Sep 17 00:00:00 2001 From: RaymondLim Date: Mon, 3 Dec 2012 22:21:46 -0800 Subject: [PATCH 01/17] Implement registerHintProvider api with extra parameters suggested by Glenn. This change also makes the problematic shouldShowHintsOnKey api no longer needed. It also resolves the conflicts between two or more providers by allowing each provider to register its specificity. --- src/editor/CodeHintManager.js | 118 ++++++++++++++----- src/extensions/default/HTMLCodeHints/main.js | 22 +--- 2 files changed, 93 insertions(+), 47 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index d672193b19c..edd0413d704 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -38,11 +38,23 @@ define(function (require, exports, module) { KeyEvent = require("utils/KeyEvent"); - var hintProviders = [], + var hintProviders = {}, hintList, - shouldShowHintsOnChange = false, + triggeredKey = null, keyDownEditor; + /** Comparator to sort providers based on their specificity */ + function _providerSort(a, b) { + if (a.specificity === b.specificity) { + return 0; + } + if (a.specificity > b.specificity) { + return -1; + } + if (a.specificity < b.specificity) { + return 1; + } + } /** * @constructor @@ -257,20 +269,47 @@ define(function (require, exports, module) { * @param {Editor} editor */ CodeHintList.prototype.open = function (editor) { - var self = this; - this.editor = editor; + var self = this, + mode = editor.getModeForSelection(), + enabledProviders = []; + mode = (typeof mode === "string") ? mode : mode.name; + enabledProviders = hintProviders[mode]; + this.editor = editor; Menus.closeAll(); + // If we have any providers for "all" mode, then append it to enabled + // porviders list and sort them based on their specificity again. + if (enabledProviders && hintProviders.all) { + enabledProviders = enabledProviders.concat(hintProviders.all); + enabledProviders.sort(_providerSort); + } else if (hintProviders.all) { + enabledProviders = hintProviders.all; + } + + if (!enabledProviders) { + return; + } + this.currentProvider = null; - $.each(hintProviders, function (index, item) { - var query = item.getQueryInfo(self.editor, self.editor.getCursorPos()); + $.each(enabledProviders, function (index, item) { + // If we have a triggered key, then skip all the providers that + // do not want to be invoked by the triggered key. + if (triggeredKey && item.provider.triggeredKeys && + item.provider.triggeredKeys.indexOf(triggeredKey) === -1) { + return true; + } + + var query = item.provider.getQueryInfo(self.editor, self.editor.getCursorPos()); if (query.queryStr !== null) { self.query = query; - self.currentProvider = item; + self.currentProvider = item.provider; return false; } }); + + triggeredKey = null; + if (!this.currentProvider) { return; } @@ -284,7 +323,7 @@ define(function (require, exports, module) { var hintPos = this.calcHintListLocation(); this.$hintMenu.addClass("open") - .css({"left": hintPos.left, "top": hintPos.top}); + .css({"left": hintPos.left, "top": hintPos.top}); this.opened = true; PopUpManager.addPopUp(this.$hintMenu, @@ -311,7 +350,7 @@ define(function (require, exports, module) { this.$hintMenu.remove(); if (hintList === this) { hintList = null; - shouldShowHintsOnChange = false; + triggeredKey = null; keyDownEditor = null; } }; @@ -386,24 +425,29 @@ define(function (require, exports, module) { * @param {KeyboardEvent} event */ function handleKeyEvent(editor, event) { - var provider = null; + var provider = null, + mode; // Check for Control+Space if (event.type === "keydown" && event.keyCode === 32 && event.ctrlKey) { showHint(editor); event.preventDefault(); } else if (event.type === "keypress") { - // Check if any provider wants to show hints on this key. - $.each(hintProviders, function (index, item) { - if (item.shouldShowHintsOnKey(String.fromCharCode(event.charCode))) { - provider = item; - return false; + mode = editor.getModeForSelection(); + mode = (typeof mode === "string") ? mode : mode.name; + + if (hintProviders[mode]) { + // Check if any provider wants to show hints on this key. + $.each(hintProviders[mode], function (index, item) { + if (item.triggerKeys && item.triggerKeys.indexOf(String.fromCharCode(event.charCode)) !== -1) { + triggeredKey = String.fromCharCode(event.charCode); + return false; + } + }); + + if (triggeredKey) { + keyDownEditor = editor; } - }); - - shouldShowHintsOnChange = !!provider; - if (shouldShowHintsOnChange) { - keyDownEditor = editor; } } @@ -417,8 +461,7 @@ define(function (require, exports, module) { * */ function handleChange(editor) { - if (shouldShowHintsOnChange && keyDownEditor === editor) { - shouldShowHintsOnChange = false; + if (triggeredKey && keyDownEditor === editor) { keyDownEditor = null; showHint(editor); } @@ -434,8 +477,7 @@ define(function (require, exports, module) { * * @param {Object.< getQueryInfo: function(editor, cursor), * search: function(string), - * handleSelect: function(string, Editor, cursor), - * shouldShowHintsOnKey: function(string)>} + * handleSelect: function(string, Editor, cursor)>} * * Parameter Details: * - getQueryInfo - examines cursor location of editor and returns an object representing @@ -446,10 +488,32 @@ define(function (require, exports, module) { * - handleSelect - takes a completion string and inserts it into the editor near the cursor * position. It should return true by default to close the hint list, but if the code hint provider * can return false if it wants to keep the hint list open and continue with a updated list. - * - shouldShowHintsOnKey - inspects the char code and returns true if it wants to show code hints on that key. + * + * @param {Array.} modes An array of mode strings in which the provider can show code hints or "all" + * if it can show code hints in any mode. + * @param {!Array.} triggerKeys An array of all the keys that the provider can be triggered to show hints. + * @param {number} specificity A positive number to indicate the priority of the provider. The larger the number, + * the higher priority the provider has. Zero if it has the lowest priority in displaying its code hints. */ - function registerHintProvider(providerInfo) { - hintProviders.push(providerInfo); + function registerHintProvider(providerInfo, modes, triggerKeys, specificity) { + var providerObj = { provider: providerInfo, + triggerKeys: triggerKeys || [], + specificity: specificity || 0 }; + + if (modes) { + modes.forEach(function (mode) { + if (mode) { + if (!hintProviders[mode]) { + hintProviders[mode] = []; + } + hintProviders[mode].push(providerObj); + + if (hintProviders[mode].length > 1) { + hintProviders[mode].sort(_providerSort); + } + } + }); + } } /** diff --git a/src/extensions/default/HTMLCodeHints/main.js b/src/extensions/default/HTMLCodeHints/main.js index 346eebac7a6..611ff11b446 100644 --- a/src/extensions/default/HTMLCodeHints/main.js +++ b/src/extensions/default/HTMLCodeHints/main.js @@ -120,15 +120,6 @@ define(function (require, exports, module) { return true; }; - /** - * Check whether to show hints on a specific key. - * @param {string} key -- the character for the key user just presses. - * @return {boolean} return true/false to indicate whether hinting should be triggered by this key. - */ - TagHints.prototype.shouldShowHintsOnKey = function (key) { - return key === "<"; - }; - /** * @constructor */ @@ -471,19 +462,10 @@ define(function (require, exports, module) { return result; }; - /** - * Check whether to show hints on a specific key. - * @param {string} key -- the character for the key user just presses. - * @return {boolean} return true/false to indicate whether hinting should be triggered by this key. - */ - AttrHints.prototype.shouldShowHintsOnKey = function (key) { - return (key === " " || key === "'" || key === "\"" || key === "="); - }; - var tagHints = new TagHints(); var attrHints = new AttrHints(); - CodeHintManager.registerHintProvider(tagHints); - CodeHintManager.registerHintProvider(attrHints); + CodeHintManager.registerHintProvider(tagHints, ["html"], ["<"], 0); + CodeHintManager.registerHintProvider(attrHints, ["html"], [" ", "'", "\"", "="], 0); // For unit testing exports.tagHintProvider = tagHints; From 2509c7fe372a35e2e61e21074db2449a134b559d Mon Sep 17 00:00:00 2001 From: RaymondLim Date: Wed, 5 Dec 2012 12:52:59 -0800 Subject: [PATCH 02/17] Putting back shouldShowHintsOnKey api since JS code hinting Ian is working on requires to be called for every keystokes. --- src/editor/CodeHintManager.js | 122 +++++++++++-------- src/extensions/default/HTMLCodeHints/main.js | 22 +++- 2 files changed, 91 insertions(+), 53 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index edd0413d704..f1b5e30c3dc 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -39,23 +39,55 @@ define(function (require, exports, module) { var hintProviders = {}, + triggeredHintProviders = [], hintList, - triggeredKey = null, keyDownEditor; /** Comparator to sort providers based on their specificity */ function _providerSort(a, b) { - if (a.specificity === b.specificity) { - return 0; - } - if (a.specificity > b.specificity) { - return -1; + return b.specificity - a.specificity; + } + + /** + * If there is any providers for all modes, then add them to each individual + * mode providers list and re-sort them based on their specificity. + */ + function _mergeAllModeToIndividualMode() { + var allModeProviders = []; + if (hintProviders.all) { + allModeProviders = hintProviders.all; + + // Remove "all" mode list since we don't need it any more after + // merging them to each individual mode provider lists. + delete hintProviders.all; + + $.each(hintProviders, function (key, value) { + if (hintProviders[key]) { + hintProviders[key] = hintProviders[key].concat(allModeProviders); + hintProviders[key].sort(_providerSort); + } + }); } - if (a.specificity < b.specificity) { - return 1; + } + + /** + * Return the array of hint providers for the given mode. + * If this is called for the first time, then we check if any provider wants to show + * hints on all modes. If there is any, then we merge them into each individual + * mode provider list. + * + * @param {string | Object} mode + * @return {!Array.<{provider:Object, modes:Array, specificity: number}>} + */ + function _getEnabledHintProviders(mode) { + if (hintProviders.all) { + _mergeAllModeToIndividualMode(); } + + mode = (typeof mode === "string") ? mode : mode.name; + return hintProviders[mode] || []; } - + /** * @constructor * @@ -161,7 +193,7 @@ define(function (require, exports, module) { /** * Selects the item in the hint list specified by index - * @param {Number} index + * @param {number} index */ CodeHintList.prototype.setSelectedIndex = function (index) { var items = this.$hintMenu.find("li"); @@ -211,7 +243,6 @@ define(function (require, exports, module) { // Up arrow, down arrow and enter key are always handled here if (event.type !== "keypress") { - if (keyCode === KeyEvent.DOM_VK_RETURN || keyCode === KeyEvent.DOM_VK_TAB || keyCode === KeyEvent.DOM_VK_UP || keyCode === KeyEvent.DOM_VK_DOWN || keyCode === KeyEvent.DOM_VK_PAGE_UP || keyCode === KeyEvent.DOM_VK_PAGE_DOWN) { @@ -251,7 +282,7 @@ define(function (require, exports, module) { /** * Return true if the CodeHintList is open. - * @return {Boolean} + * @return {boolean} */ CodeHintList.prototype.isOpen = function () { // We don't get a notification when the dropdown closes. The best @@ -273,33 +304,21 @@ define(function (require, exports, module) { mode = editor.getModeForSelection(), enabledProviders = []; - mode = (typeof mode === "string") ? mode : mode.name; - enabledProviders = hintProviders[mode]; - this.editor = editor; - Menus.closeAll(); - - // If we have any providers for "all" mode, then append it to enabled - // porviders list and sort them based on their specificity again. - if (enabledProviders && hintProviders.all) { - enabledProviders = enabledProviders.concat(hintProviders.all); - enabledProviders.sort(_providerSort); - } else if (hintProviders.all) { - enabledProviders = hintProviders.all; + if (triggeredHintProviders.length > 0) { + enabledProviders = triggeredHintProviders; + } else { + enabledProviders = _getEnabledHintProviders(mode); } - if (!enabledProviders) { + if (enabledProviders.length === 0) { return; } + this.editor = editor; + Menus.closeAll(); + this.currentProvider = null; $.each(enabledProviders, function (index, item) { - // If we have a triggered key, then skip all the providers that - // do not want to be invoked by the triggered key. - if (triggeredKey && item.provider.triggeredKeys && - item.provider.triggeredKeys.indexOf(triggeredKey) === -1) { - return true; - } - var query = item.provider.getQueryInfo(self.editor, self.editor.getCursorPos()); if (query.queryStr !== null) { self.query = query; @@ -308,8 +327,6 @@ define(function (require, exports, module) { } }); - triggeredKey = null; - if (!this.currentProvider) { return; } @@ -350,14 +367,13 @@ define(function (require, exports, module) { this.$hintMenu.remove(); if (hintList === this) { hintList = null; - triggeredKey = null; keyDownEditor = null; } }; /** * Computes top left location for hint list so that the list is not clipped by the window - * @return {Object. } + * @return {Object. } */ CodeHintList.prototype.calcHintListLocation = function () { var cursor = this.editor._codeMirror.cursorCoords(), @@ -425,27 +441,30 @@ define(function (require, exports, module) { * @param {KeyboardEvent} event */ function handleKeyEvent(editor, event) { - var provider = null, - mode; + var mode = editor.getModeForSelection(), + enabledProviders = [], + key; // Check for Control+Space if (event.type === "keydown" && event.keyCode === 32 && event.ctrlKey) { + triggeredHintProviders = []; showHint(editor); event.preventDefault(); } else if (event.type === "keypress") { mode = editor.getModeForSelection(); - mode = (typeof mode === "string") ? mode : mode.name; - - if (hintProviders[mode]) { - // Check if any provider wants to show hints on this key. - $.each(hintProviders[mode], function (index, item) { - if (item.triggerKeys && item.triggerKeys.indexOf(String.fromCharCode(event.charCode)) !== -1) { - triggeredKey = String.fromCharCode(event.charCode); - return false; + enabledProviders = _getEnabledHintProviders(mode); + + triggeredHintProviders = []; + if (enabledProviders.length > 0) { + key = String.fromCharCode(event.charCode); + // Check if any provider wants to start showing hints on this key. + $.each(enabledProviders, function (index, item) { + if (item.provider.shouldShowHintsOnKey(key)) { + triggeredHintProviders.push(item); } }); - if (triggeredKey) { + if (triggeredHintProviders.length > 0) { keyDownEditor = editor; } } @@ -461,7 +480,7 @@ define(function (require, exports, module) { * */ function handleChange(editor) { - if (triggeredKey && keyDownEditor === editor) { + if (triggeredHintProviders.length > 0 && keyDownEditor === editor) { keyDownEditor = null; showHint(editor); } @@ -477,7 +496,8 @@ define(function (require, exports, module) { * * @param {Object.< getQueryInfo: function(editor, cursor), * search: function(string), - * handleSelect: function(string, Editor, cursor)>} + * handleSelect: function(string, Editor, cursor), + * shouldShowHintsOnKey: function(string)>} * * Parameter Details: * - getQueryInfo - examines cursor location of editor and returns an object representing @@ -488,6 +508,7 @@ define(function (require, exports, module) { * - handleSelect - takes a completion string and inserts it into the editor near the cursor * position. It should return true by default to close the hint list, but if the code hint provider * can return false if it wants to keep the hint list open and continue with a updated list. + * - shouldShowHintsOnKey - inspects the char code and returns true if it wants to show code hints on that key. * * @param {Array.} modes An array of mode strings in which the provider can show code hints or "all" * if it can show code hints in any mode. @@ -495,9 +516,8 @@ define(function (require, exports, module) { * @param {number} specificity A positive number to indicate the priority of the provider. The larger the number, * the higher priority the provider has. Zero if it has the lowest priority in displaying its code hints. */ - function registerHintProvider(providerInfo, modes, triggerKeys, specificity) { + function registerHintProvider(providerInfo, modes, specificity) { var providerObj = { provider: providerInfo, - triggerKeys: triggerKeys || [], specificity: specificity || 0 }; if (modes) { diff --git a/src/extensions/default/HTMLCodeHints/main.js b/src/extensions/default/HTMLCodeHints/main.js index 611ff11b446..0e09c031be8 100644 --- a/src/extensions/default/HTMLCodeHints/main.js +++ b/src/extensions/default/HTMLCodeHints/main.js @@ -120,6 +120,15 @@ define(function (require, exports, module) { return true; }; + /** + * Check whether to show hints on a specific key. + * @param {string} key -- the character for the key user just presses. + * @return {boolean} return true/false to indicate whether hinting should be triggered by this key. + */ + TagHints.prototype.shouldShowHintsOnKey = function (key) { + return key === "<"; + }; + /** * @constructor */ @@ -462,10 +471,19 @@ define(function (require, exports, module) { return result; }; + /** + * Check whether to show hints on a specific key. + * @param {string} key -- the character for the key user just presses. + * @return {boolean} return true/false to indicate whether hinting should be triggered by this key. + */ + AttrHints.prototype.shouldShowHintsOnKey = function (key) { + return (key === " " || key === "'" || key === "\"" || key === "="); + }; + var tagHints = new TagHints(); var attrHints = new AttrHints(); - CodeHintManager.registerHintProvider(tagHints, ["html"], ["<"], 0); - CodeHintManager.registerHintProvider(attrHints, ["html"], [" ", "'", "\"", "="], 0); + CodeHintManager.registerHintProvider(tagHints, ["html"], 0); + CodeHintManager.registerHintProvider(attrHints, ["html"], 0); // For unit testing exports.tagHintProvider = tagHints; From b92c7c56bec97ec65cff6aba0e30a76b7b10760a Mon Sep 17 00:00:00 2001 From: RaymondLim Date: Wed, 5 Dec 2012 18:19:37 -0800 Subject: [PATCH 03/17] Correct some mistakes in jsDoc. --- src/editor/CodeHintManager.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index f1b5e30c3dc..2ee325c4155 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -49,7 +49,7 @@ define(function (require, exports, module) { } /** - * If there is any providers for all modes, then add them to each individual + * If there is any provider for all modes, then add it to each individual * mode providers list and re-sort them based on their specificity. */ function _mergeAllModeToIndividualMode() { @@ -73,11 +73,11 @@ define(function (require, exports, module) { /** * Return the array of hint providers for the given mode. * If this is called for the first time, then we check if any provider wants to show - * hints on all modes. If there is any, then we merge them into each individual + * hints on all modes. If there is any, then we merge it into each individual * mode provider list. * - * @param {string | Object} mode - * @return {!Array.<{provider:Object, modes:Array, specificity: number}>} + * @param {(string|Object)} mode + * @return {Array.<{provider: Object, modes: Array., specificity: number}>} */ function _getEnabledHintProviders(mode) { if (hintProviders.all) { @@ -512,7 +512,6 @@ define(function (require, exports, module) { * * @param {Array.} modes An array of mode strings in which the provider can show code hints or "all" * if it can show code hints in any mode. - * @param {!Array.} triggerKeys An array of all the keys that the provider can be triggered to show hints. * @param {number} specificity A positive number to indicate the priority of the provider. The larger the number, * the higher priority the provider has. Zero if it has the lowest priority in displaying its code hints. */ From e5aa07930cf979fd9611d3d38e9475b0c45cfff1 Mon Sep 17 00:00:00 2001 From: RaymondLim Date: Wed, 5 Dec 2012 22:13:19 -0800 Subject: [PATCH 04/17] Add one more code hints extension api to let the provider decide on the default initial selection. This fixes issue #2286 --- src/editor/CodeHintManager.js | 13 +++++++++++-- src/extensions/default/HTMLCodeHints/main.js | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index 2ee325c4155..499715a1bb0 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -184,7 +184,7 @@ define(function (require, exports, module) { if (count === 0) { this.close(); - } else { + } else if (this.currentProvider.wantInitialSelection()) { // Select the first item in the list this.setSelectedIndex(0); } @@ -243,6 +243,13 @@ define(function (require, exports, module) { // Up arrow, down arrow and enter key are always handled here if (event.type !== "keypress") { + // If we don't have a selection in the list, then just update the list and + // show it at the new location for Return and Tab keys. + if (this.selectedIndex === -1 && (keyCode === KeyEvent.DOM_VK_RETURN || keyCode === KeyEvent.DOM_VK_TAB)) { + this.updateQueryAndList(); + return; + } + if (keyCode === KeyEvent.DOM_VK_RETURN || keyCode === KeyEvent.DOM_VK_TAB || keyCode === KeyEvent.DOM_VK_UP || keyCode === KeyEvent.DOM_VK_DOWN || keyCode === KeyEvent.DOM_VK_PAGE_UP || keyCode === KeyEvent.DOM_VK_PAGE_DOWN) { @@ -497,7 +504,8 @@ define(function (require, exports, module) { * @param {Object.< getQueryInfo: function(editor, cursor), * search: function(string), * handleSelect: function(string, Editor, cursor), - * shouldShowHintsOnKey: function(string)>} + * shouldShowHintsOnKey: function(string), + * wantInitialSelection: function()>} * * Parameter Details: * - getQueryInfo - examines cursor location of editor and returns an object representing @@ -509,6 +517,7 @@ define(function (require, exports, module) { * position. It should return true by default to close the hint list, but if the code hint provider * can return false if it wants to keep the hint list open and continue with a updated list. * - shouldShowHintsOnKey - inspects the char code and returns true if it wants to show code hints on that key. + * - wantInitialSelection - return true if the provider wants to select the first hint item by default. * * @param {Array.} modes An array of mode strings in which the provider can show code hints or "all" * if it can show code hints in any mode. diff --git a/src/extensions/default/HTMLCodeHints/main.js b/src/extensions/default/HTMLCodeHints/main.js index 0e09c031be8..8b7109d5b9f 100644 --- a/src/extensions/default/HTMLCodeHints/main.js +++ b/src/extensions/default/HTMLCodeHints/main.js @@ -129,6 +129,14 @@ define(function (require, exports, module) { return key === "<"; }; + /** + * Check whether to select the first item in the list by default + * @return {boolean} return true to highlight the first item. + */ + TagHints.prototype.wantInitialSelection = function () { + return true; + }; + /** * @constructor */ @@ -480,6 +488,14 @@ define(function (require, exports, module) { return (key === " " || key === "'" || key === "\"" || key === "="); }; + /** + * Check whether to select the first item in the list by default + * @return {boolean} return true to highlight the first item. + */ + AttrHints.prototype.wantInitialSelection = function () { + return true; + }; + var tagHints = new TagHints(); var attrHints = new AttrHints(); CodeHintManager.registerHintProvider(tagHints, ["html"], 0); From 46785f743612292ebd1a4baed7b794f3ff43b4f8 Mon Sep 17 00:00:00 2001 From: RaymondLim Date: Thu, 6 Dec 2012 13:22:14 -0800 Subject: [PATCH 05/17] Use a local variable to avoid modifying the parameter. --- src/editor/CodeHintManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index 499715a1bb0..30404bef8ea 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -84,8 +84,8 @@ define(function (require, exports, module) { _mergeAllModeToIndividualMode(); } - mode = (typeof mode === "string") ? mode : mode.name; - return hintProviders[mode] || []; + var modeStr = (typeof mode === "string") ? mode : mode.name; + return hintProviders[modeStr] || []; } /** From 4d2d4997ca93f214e13f889f2cd80d6731ff8091 Mon Sep 17 00:00:00 2001 From: RaymondLim Date: Thu, 6 Dec 2012 13:45:42 -0800 Subject: [PATCH 06/17] modeName sounds better. --- src/editor/CodeHintManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index 30404bef8ea..7605dbbb0af 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -84,8 +84,8 @@ define(function (require, exports, module) { _mergeAllModeToIndividualMode(); } - var modeStr = (typeof mode === "string") ? mode : mode.name; - return hintProviders[modeStr] || []; + var modeName = (typeof mode === "string") ? mode : mode.name; + return hintProviders[modeName] || []; } /** From 2530141a6a81f17ca8ad6483cd0f275c3a4565ee Mon Sep 17 00:00:00 2001 From: Ian Wehrman Date: Fri, 14 Dec 2012 13:24:21 -0800 Subject: [PATCH 07/17] Rework Code Hint Manager to use new API, and fix up HTML code hint providers --- src/editor/CodeHintList.js | 340 ++++++++ src/editor/CodeHintManager.js | 807 ++++++++---------- src/extensions/default/HTMLCodeHints/main.js | 537 +++++++----- .../default/HTMLCodeHints/unittests.js | 37 +- 4 files changed, 1019 insertions(+), 702 deletions(-) create mode 100644 src/editor/CodeHintList.js diff --git a/src/editor/CodeHintList.js b/src/editor/CodeHintList.js new file mode 100644 index 00000000000..b9df916f861 --- /dev/null +++ b/src/editor/CodeHintList.js @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2012 Adobe Systems Incorporated. All rights reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + */ + +/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ +/*global define, $, window, brackets */ + +define(function (require, exports, module) { + "use strict"; + + // Load dependent modules + var Menus = require("command/Menus"), + StringUtils = require("utils/StringUtils"), + PopUpManager = require("widgets/PopUpManager"), + ViewUtils = require("utils/ViewUtils"), + KeyEvent = require("utils/KeyEvent"); + + /** + * @constructor + * + * Displays a popup list of code completions. + * Currently only HTML tags are supported, but this will greatly be extended in coming sprint + * to include: extensibility API, HTML attributes hints, JavaScript hints, CSS hints + */ + function CodeHintList(editor) { + this.displayList = []; + this.options = { + maxResults: 999 + }; + + this.opened = false; + this.selectedIndex = -1; + this.editor = editor; + this.handleSelect = null; + this.handleClose = null; + + this.$hintMenu = $(""); + var $toggle = $("") + .hide(); + + this.$hintMenu.append($toggle) + .append(""); + } + + /** + * @private + * Adds a single item to the hint list + * @param {string} name + */ + CodeHintList.prototype._addItem = function (name) { + var self = this; + var displayName = name.replace( + new RegExp(StringUtils.regexEscape(this.query.queryStr), "i"), + "$&" + ); + + var $item = $("
  • " + displayName + "
  • ") + .on("click", function (e) { + // Don't let the click propagate upward (otherwise it will hit the close handler in + // bootstrap-dropdown). + e.stopPropagation(); + self.handleSelect(name); + }); + + this.$hintMenu.find("ul.dropdown-menu") + .append($item); + }; + + /** + * @private + * Selects the item in the hint list specified by index + * @param {number} index + */ + CodeHintList.prototype._setSelectedIndex = function (index) { + var items = this.$hintMenu.find("li"); + + // Range check + index = Math.max(-1, Math.min(index, items.length - 1)); + + // Clear old highlight + if (this.selectedIndex !== -1) { + $(items[this.selectedIndex]).find("a").removeClass("highlight"); + } + + // Highlight the new selected item + this.selectedIndex = index; + + if (this.selectedIndex !== -1) { + var $item = $(items[this.selectedIndex]); + var $view = this.$hintMenu.find("ul.dropdown-menu"); + + ViewUtils.scrollElementIntoView($view, $item, false); + $item.find("a").addClass("highlight"); + } + }; + + /** + * @private + * Rebuilds the list items for the hint list based on this.displayList + */ + CodeHintList.prototype._buildListView = function () { + var self = this; + + // clear the list + this.$hintMenu.find("li").remove(); + + $.each(this.displayList, function (index, item) { + if (index > self.options.maxResults) { + return false; + } + self._addItem(item); + }); + + if (this.displayList.length === 0) { + this.handleClose(); + } else { + this._setSelectedIndex(this.initialSelect ? 0 : -1); + } + }; + + /** + * @private + * Calculate the number of items per scroll page. Used for PageUp and PageDown. + * @return {number} + */ + CodeHintList.prototype._getItemsPerPage = function () { + var itemsPerPage = 1, + $items = this.$hintMenu.find("li"), + $view = this.$hintMenu.find("ul.dropdown-menu"), + itemHeight; + + if ($items.length !== 0) { + itemHeight = $($items[0]).height(); + if (itemHeight) { + // round down to integer value + itemsPerPage = Math.floor($view.height() / itemHeight); + itemsPerPage = Math.max(1, Math.min(itemsPerPage, $items.length)); + } + } + + return itemsPerPage; + }; + + /** + * @private + * Computes top left location for hint list so that the list is not clipped by the window + * @return {Object. } + */ + CodeHintList.prototype._calcHintListLocation = function () { + var cursor = this.editor._codeMirror.cursorCoords(), + posTop = cursor.y, + posLeft = cursor.x, + $window = $(window), + $menuWindow = this.$hintMenu.children("ul"); + + // TODO Ty: factor out menu repositioning logic so code hints and Context menus share code + // adjust positioning so menu is not clipped off bottom or right + var bottomOverhang = posTop + 25 + $menuWindow.height() - $window.height(); + if (bottomOverhang > 0) { + posTop -= (27 + $menuWindow.height()); + } + // todo: should be shifted by line height + posTop -= 15; // shift top for hidden parent element + //posLeft += 5; + + var rightOverhang = posLeft + $menuWindow.width() - $window.width(); + if (rightOverhang > 0) { + posLeft = Math.max(0, posLeft - rightOverhang); + } + + return {left: posLeft, top: posTop}; + }; + + /** + * Handles key presses when the hint list is being displayed + * @param {Editor} editor + * @param {KeyBoardEvent} keyEvent + */ + CodeHintList.prototype.handleKeyEvent = function (event) { + var keyCode, + self = this; + + function _upSelection() { + if (self.selectedIndex > 0) { + self._setSelectedIndex(self.selectedIndex - 1); + } + } + + function _downSelection() { + if (self.selectedIndex < (self.displayList.length - 1)) { + self._setSelectedIndex(self.selectedIndex + 1); + } + } + + function _pageUpSelection() { + var index; + if (self.selectedIndex > 0) { + index = self.selectedIndex - self._getItemsPerPage(); + self._setSelectedIndex(Math.max(index, 0)); + } + } + + function _pageDownSelection() { + var index; + if (self.selectedIndex < (self.displayList.length - 1)) { + index = self.selectedIndex + self._getItemsPerPage(); + self._setSelectedIndex(Math.min(index, (self.displayList.length - 1))); + } + } + + // (page) up, (page) down, enter and tab key are handled by the list + if (event.type === "keydown") { + keyCode = event.keyCode; + + if (keyCode === KeyEvent.DOM_VK_UP) { + _upSelection.call(this); + } else if (keyCode === KeyEvent.DOM_VK_DOWN) { + _downSelection.call(this); + } else if (keyCode === KeyEvent.DOM_VK_PAGE_UP) { + _pageUpSelection.call(this); + } else if (keyCode === KeyEvent.DOM_VK_PAGE_DOWN) { + _pageDownSelection.call(this); + } else if (this.selectedIndex !== -1 && + (keyCode === KeyEvent.DOM_VK_RETURN || keyCode === KeyEvent.DOM_VK_TAB)) { + // Trigger a click handler to commmit the selected item + $(this.$hintMenu.find("li")[this.selectedIndex]).triggerHandler("click"); + } else { + // only prevent default handler wshen the list handles the event + return; + } + + event.preventDefault(); + } + }; + + /** + * Return true if the CodeHintList is open. + * @return {boolean} + */ + CodeHintList.prototype.isOpen = function () { + // We don't get a notification when the dropdown closes. The best + // we can do is keep an "opened" flag and check to see if we + // still have the "open" class applied. + if (this.opened && !this.$hintMenu.hasClass("open")) { + this.opened = false; + } + + return this.opened; + }; + + /** + * Displays the hint list at the current cursor position + * @param {Editor} editor + */ + CodeHintList.prototype.open = function (response) { + this.query = {queryStr: response.match}; + this.displayList = response.hints; + this.initialSelect = response.selectInitial; + + Menus.closeAll(); + this._buildListView(); + + if (this.displayList.length) { + // Need to add the menu to the DOM before trying to calculate its ideal location. + $("#codehint-menu-bar > ul").append(this.$hintMenu); + + var hintPos = this._calcHintListLocation(); + + this.$hintMenu.addClass("open") + .css({"left": hintPos.left, "top": hintPos.top}); + this.opened = true; + + PopUpManager.addPopUp(this.$hintMenu, this.handleClose, true); + } + }; + + /** + * Gets the new query from the current provider and rebuilds the hint list based on the new one. + */ + CodeHintList.prototype.update = function (response) { + this.query = {queryStr: response.match}; + this.displayList = response.hints; + this.initialSelect = response.selectInitial; + + this._buildListView(); + + // Update the CodeHintList location + if (this.displayList.length) { + var hintPos = this._calcHintListLocation(); + this.$hintMenu.css({"left": hintPos.left, "top": hintPos.top}); + } + }; + + /** + * Closes the hint list + */ + CodeHintList.prototype.close = function () { + this.$hintMenu.removeClass("open"); + this.opened = false; + + PopUpManager.removePopUp(this.$hintMenu); + this.$hintMenu.remove(); + }; + + CodeHintList.prototype.onSelect = function (callback) { + this.handleSelect = callback; + }; + + CodeHintList.prototype.onClose = function (callback) { + // TODO: Due to #1381, this won't get called if the user clicks out of + // the code hint menu. That's (sort of) okay right now since it doesn't + // really matter if a single old invisible code hint list is lying + // around (it'll get closed the next time the user pops up a code + // hint). Once #1381 is fixed this issue should go away. + this.handleClose = callback; + }; + + + // Define public API + exports.CodeHintList = CodeHintList; +}); diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index 7605dbbb0af..d5dc403c2f7 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -21,528 +21,424 @@ * */ - /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ -/*global define, $, window, brackets */ +/*global define, $, brackets */ define(function (require, exports, module) { "use strict"; // Load dependent modules - var HTMLUtils = require("language/HTMLUtils"), - Menus = require("command/Menus"), - StringUtils = require("utils/StringUtils"), - EditorManager = require("editor/EditorManager"), - PopUpManager = require("widgets/PopUpManager"), - ViewUtils = require("utils/ViewUtils"), - KeyEvent = require("utils/KeyEvent"); - + var KeyEvent = require("utils/KeyEvent"), + CodeHintList = require("editor/CodeHintList").CodeHintList; - var hintProviders = {}, - triggeredHintProviders = [], - hintList, - keyDownEditor; + var hintProviders = { "all" : [] }, + lastChar = null, + sessionProvider = null, + sessionEditor = null, + hintList = null; - /** Comparator to sort providers based on their specificity */ + /** + * Comparator to sort providers based on their priority + */ function _providerSort(a, b) { - return b.specificity - a.specificity; + return b.priority - a.priority; } - - /** - * If there is any provider for all modes, then add it to each individual - * mode providers list and re-sort them based on their specificity. + + /** + * The method by which a CodeHintProvider registers its willingness to + * providing hints for editors in a given mode. + * + * @param {CodeHintProvider} provider + * The hint provider to be registered, described below. + * + * @param {Array[(string|Object)]} modes + * The set of mode names for which the provider is capable of + * providing hints. If the special mode name "all" is included then + * the provider may be called upon to provide hints for any mode. + * + * @param {Integer} priority + * A non-negative number used to break ties among hint providers for a + * particular mode. Providers that register with a higher priority + * will have the opportunity to provide hints at a given mode before + * those with a lower priority. Brackets default providers have + * priority zero. + * + * + * + * A code hint provider should implement the following three functions: + * + * CodeHintProvider.hasHints(editor, implicitChar) + * CodeHintProvider.getHints(implicitChar) + * CodeHintProvider.insertHint(hint) + * + * The behavior of these three functions is described in detail below. + * + * + * # CodeHintProvider.hasHints(editor, implicitChar) + * + * The method by which a provider indicates intent to provide hints for a + * given editor. The manager calls this method both when hints are + * explicitly requested (via, e.g., Ctrl-Space) and when they may be + * implicitly requested as a result of character insertion in the editor. + * If the provider responds negatively then the manager may query other + * providers for hints. Otherwise, a new hinting session begins with this + * provider, during which the manager may repeatedly query the provider + * for hints via the getHints method. Note that no other providers will be + * queried until the hinting session ends. + * + * The implicitChar parameter is used to determine whether the hinting + * request is explicit or implicit. If the string is null then hints were + * explicitly requested and the provider should reply based on whether it + * is possible to return hints for the given editor context. Otherwise, + * the string contains just the last character inserted into the editor's + * document and the request for hints is implicit. In this case, the + * provider should determine whether it is both possible and appropriate + * to show hints. Because implicit hints can be triggered by every + * character insertion, hasHints may be called frequently; consequently, + * the provider should endeavor to return a value as quickly as possible. + * + * Because calls to hasHints imply that a hinting session is about to + * begin, a provider may wish to clean up cached data from previous + * sessions in this method. Similarly, if the provider returns true, it + * may wish to prepare to cache data suitable for the current session. In + * particular, it should keep a reference to the editor object so that it + * can access the editor in future calls to getHints and insertHints. + * + * param {Editor} editor + * A non-null editor object for the active window. + * + * param {String} implicitChar + * Either null, if the hinting request was explicit, or a single character + * that represents the last insertion and that indicates an implicit + * hinting request. + * + * return {Boolean} + * Determines whether the current provider is able to provide hints for + * the given editor context and, in case implicitChar is non- null, + * whether it is appropriate to do so. + * + * + * # CodeHintProvider.getHints() + * + * The method by which a provider provides hints for the editor context + * associated with the current session. The getHints method is called only + * if the provider asserted its willingness to provide hints in an earlier + * call to hasHints. The provider may return null, which indicates that + * the manager should end the current hinting session and close the hint + * list window. Otherwise, the provider should return a response object + * that contains three properties: 1. hints, a sorted array hints that the + * provider could later insert into the editor; 2. match, a string that + * the manager may use to emphasize substrings of hints in the hint list; + * and 3. selectInitial, a boolean that indicates whether or not the the + * first hint in the list should be selected by default. If the array of + * hints is empty, then the manager will render an empty list, but the + * hinting session will remain open and the value of the selectInitial + * property is irrelevant. + * + * TODO: Alternatively, the provider may return a jQuery.Deferred object + * that resolves with an object with the structure described above. In + * this case, the manager will initially render the hint list window with + * a throbber and will render the actual list once the deferred object + * resolves to a response object. If a hint list has already been rendered + * (from an earlier call to getHints), then the old list will continue + * to be displayed until the new deferred has resolved. + * + * Both the manager and the provider can reject the deferred object. The + * manager will reject the deferred if the editor changes state (e.g., the + * user types a character) or if the hinting session ends (e.g., the user + * explicitly closes the hints by pressing escape). The provider can use + * this event to, e.g., abort an expensive computation. Consequently, the + * provider may assume that getHints will not be called again until the + * deferred object from the current call has resolved or been rejected. If + * the provider rejects the deferred, the manager will end the hinting + * session. + * + * The getHints method may be called by the manager repeatedly during a + * hinting session. Providers may wish to cache information for efficiency + * that may be useful throughout these sessions. The same editor context + * will be used throughout a session, and will only change during the + * session as a result of single-character insertions, deletions and + * cursor navigations. The provider may assume that, throughout the + * lifetime of the session, the getHints method will be called exactly + * once for each such editor change. Consequently, the provider may also + * assume that the document will not be changed outside of the editor + * during a session. + * + * return {(Object + jQuery.Deferred), + * match: String, selectInitial: Boolean>} + * Null if the provider wishes to end the hinting session. Otherwise, a + * response object, possibly deferred, that provides 1. a sorted array + * hints that consists either of strings or jQuery objects; 2. a string + * match, possibly null, that is used by the manager to emphasize + * matching substrings when rendering the hint list; and 3. a boolean that + * indicates whether the first result, if one exists, should be selected + * by default in the hint list window. If match is non-null, then the + * hints should be strings. + * + * TODO: If the match is null, the manager will not attempt to emphasize + * any parts of the hints when rendering the hint list; instead the + * provider may return strings or jQuery objects for which emphasis is + * self-contained. For example, the strings may contain substrings that + * wrapped in bold tags. In this way, the provider can choose to let the + * manager handle emphasis for the simple and common case of prefix + * matching, or can provide its own emphasis if it wishes to use a more + * sophisticated matching algorithm. + * + * + * # CodeHintProvider.insertHint(hint) + * + * The method by which a provider inserts a hint into the editor context + * associated with the current session. The provider may assume that the + * given hint was returned by the provider in some previous call in the + * current session to getHints, but not necessarily the most recent call. + * After the insertion has been performed, the current hinting session is + * closed. The provider should return a boolean value to indicate whether + * or not the end of the session should be immediately followed by a new + * explicit hinting request, which may result in a new hinting session + * being opened with some provider, but not necessarily the current one. + * + * param {String} hint + * The hint to be inserted into the editor context for the current session. + * + * return {Boolean} + * Indicates whether the manager should follow hint insertion with an + * explicit hint request. */ - function _mergeAllModeToIndividualMode() { - var allModeProviders = []; - if (hintProviders.all) { - allModeProviders = hintProviders.all; - - // Remove "all" mode list since we don't need it any more after - // merging them to each individual mode provider lists. - delete hintProviders.all; - - $.each(hintProviders, function (key, value) { - if (hintProviders[key]) { - hintProviders[key] = hintProviders[key].concat(allModeProviders); - hintProviders[key].sort(_providerSort); + function registerHintProvider(providerInfo, modes, priority) { + var providerObj = { provider: providerInfo, + priority: priority || 0 }; + + if (modes) { + var modeNames = [], registerInAllModes = false; + var i, currentModeName; + for (i = 0; i < modes.length; i++) { + currentModeName = (typeof modes[i] === "string") ? modes[i] : modes[i].name; + if (currentModeName) { + if (currentModeName === "all") { + registerInAllModes = true; + break; + } else { + modeNames.push(currentModeName); + } } - }); + } + + if (registerInAllModes) { + // if we're registering in all, then we ignore the modeNames array + // so that we avoid registering a provider twice + var providerName; + for (providerName in hintProviders) { + if (hintProviders.hasOwnProperty(providerName)) { + hintProviders[providerName].push(providerObj); + hintProviders[providerName].sort(_providerSort); + } + } + } else { + modeNames.forEach(function (modeName) { + if (modeName) { + if (!hintProviders[modeName]) { + // initialize a new mode with all providers + hintProviders[modeName] = Array.prototype.concat(hintProviders.all); + } + hintProviders[modeName].push(providerObj); + hintProviders[modeName].sort(_providerSort); + } + }); + } } } - + /** * Return the array of hint providers for the given mode. - * If this is called for the first time, then we check if any provider wants to show - * hints on all modes. If there is any, then we merge it into each individual - * mode provider list. + * This gets called (potentially) on every keypress. So, it should be fast. * * @param {(string|Object)} mode - * @return {Array.<{provider: Object, modes: Array., specificity: number}>} + * @return {Array.<{provider: Object, modes: Array., priority: number}>} */ - function _getEnabledHintProviders(mode) { - if (hintProviders.all) { - _mergeAllModeToIndividualMode(); - } - + function _getProvidersForMode(mode) { var modeName = (typeof mode === "string") ? mode : mode.name; - return hintProviders[modeName] || []; + return hintProviders[modeName] || hintProviders.all; } - - /** - * @constructor - * - * Displays a popup list of code completions. - * Currently only HTML tags are supported, but this will greatly be extended in coming sprint - * to include: extensibility API, HTML attributes hints, JavaScript hints, CSS hints - */ - function CodeHintList() { - this.currentProvider = null; - this.query = {queryStr: null}; - this.displayList = []; - this.options = { - maxResults: 999 - }; - this.opened = false; - this.selectedIndex = -1; - this.editor = null; - - this.$hintMenu = $(""); - var $toggle = $("") - .hide(); - - this.$hintMenu.append($toggle) - .append(""); - } - - /** - * @private - * Enters the code completion text into the editor and closes list if the provider - * returns true. Otherwise, get a new query and update the list based on the new query. - * @string {string} completion - text to insert into current code editor - */ - CodeHintList.prototype._handleItemClick = function (completion) { - if (this.currentProvider.handleSelect(completion, this.editor, this.editor.getCursorPos())) { - this.close(); - } else { - this.updateQueryAndList(); - } - }; - - /** - * Adds a single item to the hint list - * @param {string} name - */ - CodeHintList.prototype.addItem = function (name) { - var self = this; - var displayName = name.replace( - new RegExp(StringUtils.regexEscape(this.query.queryStr), "i"), - "$&" - ); - - var $item = $("
  • " + displayName + "
  • ") - .on("click", function (e) { - // Don't let the click propagate upward (otherwise it will hit the close handler in - // bootstrap-dropdown). - e.stopPropagation(); - self._handleItemClick(name); - }); - - this.$hintMenu.find("ul.dropdown-menu") - .append($item); - }; - - /** - * Rebuilds the hint list based on this.query - */ - CodeHintList.prototype.updateList = function () { - this.displayList = this.currentProvider.search(this.query); - this.buildListView(); - }; - - /** - * Removes all list items from hint list - */ - CodeHintList.prototype.clearList = function () { - this.$hintMenu.find("li").remove(); - }; - - /** - * Rebuilds the list items for the hint list based on this.displayList - */ - CodeHintList.prototype.buildListView = function () { - this.clearList(); - var self = this; - var count = 0; - $.each(this.displayList, function (index, item) { - if (count > self.options.maxResults) { - return false; - } - self.addItem(item); - count++; - }); - - if (count === 0) { - this.close(); - } else if (this.currentProvider.wantInitialSelection()) { - // Select the first item in the list - this.setSelectedIndex(0); - } - }; - - - /** - * Selects the item in the hint list specified by index - * @param {number} index - */ - CodeHintList.prototype.setSelectedIndex = function (index) { - var items = this.$hintMenu.find("li"); - - // Range check - index = Math.max(0, Math.min(index, items.length - 1)); - - // Clear old highlight - if (this.selectedIndex !== -1) { - $(items[this.selectedIndex]).find("a").removeClass("highlight"); - } - - // Highlight the new selected item - this.selectedIndex = index; - - if (this.selectedIndex !== -1) { - var $item = $(items[this.selectedIndex]); - var $view = this.$hintMenu.find("ul.dropdown-menu"); - - ViewUtils.scrollElementIntoView($view, $item, false); - $item.find("a").addClass("highlight"); - } - }; - - /** - * Gets the new query from the current provider and rebuilds the hint list based on the new one. + * End the current hinting session */ - CodeHintList.prototype.updateQueryAndList = function () { - this.query = this.currentProvider.getQueryInfo(this.editor, this.editor.getCursorPos()); - this.updateList(); - - // Update the CodeHintList location - if (this.displayList.length) { - var hintPos = this.calcHintListLocation(); - this.$hintMenu.css({"left": hintPos.left, "top": hintPos.top}); - } - }; - - /** - * Handles key presses when the hint list is being displayed + function _endSession() { + hintList.close(); + hintList = null; + sessionProvider = null; + sessionEditor = null; + } + + /** + * Is there a hinting session active for a given editor? * @param {Editor} editor - * @param {KeyBoardEvent} keyEvent + * @return boolean */ - CodeHintList.prototype.handleKeyEvent = function (editor, event) { - var keyCode = event.keyCode; - - // Up arrow, down arrow and enter key are always handled here - if (event.type !== "keypress") { - // If we don't have a selection in the list, then just update the list and - // show it at the new location for Return and Tab keys. - if (this.selectedIndex === -1 && (keyCode === KeyEvent.DOM_VK_RETURN || keyCode === KeyEvent.DOM_VK_TAB)) { - this.updateQueryAndList(); - return; - } - - if (keyCode === KeyEvent.DOM_VK_RETURN || keyCode === KeyEvent.DOM_VK_TAB || - keyCode === KeyEvent.DOM_VK_UP || keyCode === KeyEvent.DOM_VK_DOWN || - keyCode === KeyEvent.DOM_VK_PAGE_UP || keyCode === KeyEvent.DOM_VK_PAGE_DOWN) { - - var isNavigationKey = (keyCode !== KeyEvent.DOM_VK_RETURN && keyCode !== KeyEvent.DOM_VK_TAB); - if (event.type === "keydown") { - if (keyCode === KeyEvent.DOM_VK_UP) { - // Up arrow - this.setSelectedIndex(this.selectedIndex - 1); - } else if (keyCode === KeyEvent.DOM_VK_DOWN) { - // Down arrow - this.setSelectedIndex(this.selectedIndex + 1); - } else if (keyCode === KeyEvent.DOM_VK_PAGE_UP) { - // Page Up - this.setSelectedIndex(this.selectedIndex - this.getItemsPerPage()); - } else if (keyCode === KeyEvent.DOM_VK_PAGE_DOWN) { - // Page Down - this.setSelectedIndex(this.selectedIndex + this.getItemsPerPage()); - } else { - // Enter/return key or Tab key - // Trigger a click handler to commmit the selected item - $(this.$hintMenu.find("li")[this.selectedIndex]).triggerHandler("click"); - } - } - - event.preventDefault(); - return; + function _inSession(editor) { + if (sessionEditor !== null) { + if (sessionEditor === editor && hintList.isOpen()) { + return true; + } else { + // the editor has changed + _endSession(); } } - - // All other key events trigger a rebuild of the list, but only - // on keyup events - if (event.type === "keyup") { - this.updateQueryAndList(); - } - }; + return false; + } /** - * Return true if the CodeHintList is open. - * @return {boolean} + * From an active hinting session, get hints from the current provider and + * render the hint list window. + * + * Assumes that it is called when a session is active (i.e. sessionProvider is not null). */ - CodeHintList.prototype.isOpen = function () { - // We don't get a notification when the dropdown closes. The best - // we can do is keep an "opened" flag and check to see if we - // still have the "open" class applied. - if (this.opened && !this.$hintMenu.hasClass("open")) { - this.opened = false; - } + function _updateHintList() { + var response = sessionProvider.getHints(lastChar); + lastChar = null; - return this.opened; - }; + if (!response) { + // the provider wishes to close the session + _endSession(); + } else if (hintList.isOpen()) { + // the session is open + hintList.update(response); + } else { + hintList.open(response); + } + } /** - * Displays the hint list at the current cursor position + * Try to begin a new hinting session. * @param {Editor} editor */ - CodeHintList.prototype.open = function (editor) { - var self = this, - mode = editor.getModeForSelection(), - enabledProviders = []; - - if (triggeredHintProviders.length > 0) { - enabledProviders = triggeredHintProviders; - } else { - enabledProviders = _getEnabledHintProviders(mode); - } - - if (enabledProviders.length === 0) { - return; - } + function _beginSession(editor) { + // Find a suitable provider, if any + var mode = editor.getModeForSelection(), + enabledProviders = _getProvidersForMode(mode); - this.editor = editor; - Menus.closeAll(); - - this.currentProvider = null; $.each(enabledProviders, function (index, item) { - var query = item.provider.getQueryInfo(self.editor, self.editor.getCursorPos()); - if (query.queryStr !== null) { - self.query = query; - self.currentProvider = item.provider; + if (item.provider.hasHints(editor, lastChar)) { + sessionProvider = item.provider; return false; } + return true; }); - - if (!this.currentProvider) { - return; - } - - this.updateList(); - - if (this.displayList.length) { - // Need to add the menu to the DOM before trying to calculate its ideal location. - $("#codehint-menu-bar > ul").append(this.$hintMenu); - - var hintPos = this.calcHintListLocation(); - - this.$hintMenu.addClass("open") - .css({"left": hintPos.left, "top": hintPos.top}); - this.opened = true; - - PopUpManager.addPopUp(this.$hintMenu, - function () { - self.close(); - }, - true); - } - }; - - /** - * Closes the hint list - */ - CodeHintList.prototype.close = function () { - // TODO: Due to #1381, this won't get called if the user clicks out of the code hint menu. - // That's (sort of) okay right now since it doesn't really matter if a single old invisible - // code hint list is lying around (it'll get closed the next time the user pops up a code - // hint). Once #1381 is fixed this issue should go away. - this.$hintMenu.removeClass("open"); - this.opened = false; - this.currentProvider = null; - - PopUpManager.removePopUp(this.$hintMenu); - this.$hintMenu.remove(); - if (hintList === this) { - hintList = null; - keyDownEditor = null; - } - }; - - /** - * Computes top left location for hint list so that the list is not clipped by the window - * @return {Object. } - */ - CodeHintList.prototype.calcHintListLocation = function () { - var cursor = this.editor._codeMirror.cursorCoords(), - posTop = cursor.y, - posLeft = cursor.x, - $window = $(window), - $menuWindow = this.$hintMenu.children("ul"); - - // TODO Ty: factor out menu repositioning logic so code hints and Context menus share code - // adjust positioning so menu is not clipped off bottom or right - var bottomOverhang = posTop + 25 + $menuWindow.height() - $window.height(); - if (bottomOverhang > 0) { - posTop -= (27 + $menuWindow.height()); - } - // todo: should be shifted by line height - posTop -= 15; // shift top for hidden parent element - //posLeft += 5; - - var rightOverhang = posLeft + $menuWindow.width() - $window.width(); - if (rightOverhang > 0) { - posLeft = Math.max(0, posLeft - rightOverhang); - } - - return {left: posLeft, top: posTop}; - }; - - /** - * @private - * Calculate the number of items per scroll page. Used for PageUp and PageDown. - * @return {number} - */ - CodeHintList.prototype.getItemsPerPage = function () { - var itemsPerPage = 1, - $items = this.$hintMenu.find("li"), - $view = this.$hintMenu.find("ul.dropdown-menu"), - itemHeight; - if ($items.length !== 0) { - itemHeight = $($items[0]).height(); - if (itemHeight) { - // round down to integer value - itemsPerPage = Math.floor($view.height() / itemHeight); - itemsPerPage = Math.max(1, Math.min(itemsPerPage, $items.length)); - } - } + // If a provider is found, initialize the hint list and update it + if (sessionProvider) { + sessionEditor = editor; + + hintList = new CodeHintList(sessionEditor); + hintList.onSelect(function (hint) { + var restart = sessionProvider.insertHint(hint), + previousEditor = sessionEditor; + _endSession(); + if (restart) { + _beginSession(previousEditor); + } + }); + hintList.onClose(_endSession); - return itemsPerPage; - }; - - /** - * Show the code hint list near the current cursor position for the specified editor - * @param {Editor} - */ - function showHint(editor) { - if (hintList) { - hintList.close(); + _updateHintList(); } - hintList = new CodeHintList(); - hintList.open(editor); } /** - * Handles keys related to displaying, searching, and navigating the hint list + * Handles keys related to displaying, searching, and navigating the hint list. + * This gets called before handleChange. + * + * TODO: Ideally, we'd get a more semantic event from the editor that told us + * what changed so that we could do all of this logic without looking at + * key events. Then, the purposes of handleKeyEvent and handleChange could be + * combined. Doing this well requires changing CodeMirror. + * * @param {Editor} editor * @param {KeyboardEvent} event */ function handleKeyEvent(editor, event) { - var mode = editor.getModeForSelection(), - enabledProviders = [], - key; - - // Check for Control+Space - if (event.type === "keydown" && event.keyCode === 32 && event.ctrlKey) { - triggeredHintProviders = []; - showHint(editor); - event.preventDefault(); + if (event.type === "keydown") { + if (event.keyCode === 32 && event.ctrlKey) { // User pressed Ctrl+Space + event.preventDefault(); + lastChar = null; + if (_inSession(editor)) { + _endSession(); + } + // Begin a new explicit session + _beginSession(editor); + } else if (_inSession(editor)) { + // Pass event to the hint list, if it's open + hintList.handleKeyEvent(event); + } } else if (event.type === "keypress") { - mode = editor.getModeForSelection(); - enabledProviders = _getEnabledHintProviders(mode); - - triggeredHintProviders = []; - if (enabledProviders.length > 0) { - key = String.fromCharCode(event.charCode); - // Check if any provider wants to start showing hints on this key. - $.each(enabledProviders, function (index, item) { - if (item.provider.shouldShowHintsOnKey(key)) { - triggeredHintProviders.push(item); - } - }); - - if (triggeredHintProviders.length > 0) { - keyDownEditor = editor; + // Last inserted character, used later by handleChange + lastChar = String.fromCharCode(event.charCode); + } else if (event.type === "keyup") { + if (_inSession(editor)) { + if ((event.keyCode !== 32 && event.ctrlKey) || event.altKey || event.metaKey) { + // End the session if the user presses any key with a modifier (other than Ctrl+Space). + _endSession(); + } else if (event.keyCode === KeyEvent.DOM_VK_LEFT || + event.keyCode === KeyEvent.DOM_VK_RIGHT) { + // Update the list after a simple navigation. + // We do this in "keyup" because we want the cursor position to be updated before + // we redraw the list. + _updateHintList(); } } } - - // Pass to the hint list, if it's open - if (hintList && hintList.isOpen()) { - hintList.handleKeyEvent(editor, event); - } } /** - * + * Start a new implicit hinting session, or update the existing hint list. + * Called by the editor after handleKeyEvent, which is responsible for setting + * the lastChar. */ function handleChange(editor) { - if (triggeredHintProviders.length > 0 && keyDownEditor === editor) { - keyDownEditor = null; - showHint(editor); + if (!_inSession(editor) && lastChar) { + _beginSession(editor); + } else if (_inSession(editor)) { + _updateHintList(); } } - /** - * Registers an object that is able to provide code hints. When the user requests a code - * hint getQueryInfo() will be called on every hint provider. Providers should examine - * the state of the editor and return a search query object with a filter string if hints - * can be provided. search() will then be called allowing the hint provider to create a - * list of hints for the search query. When the user chooses a hint handleSelect() is called - * so that the hint provider can insert the hint into the editor. + /* + * CodeHintManager Overview: + * + * The CodeHintManager mediates the interaction between the editor and a + * collection of hint providers. If hints are requested explicitly by the + * user, then the providers registered for the current mode are queried + * for their ability to provide hints in order of descending priority by + * way their hasHints methods. Character insertions may also constitute an + * implicit request for hints; consequently, providers for the current + * mode are also queried on character insertion for both their ability to + * provide hints and also for the suitability of providing implicit hints + * in the given editor context. * - * @param {Object.< getQueryInfo: function(editor, cursor), - * search: function(string), - * handleSelect: function(string, Editor, cursor), - * shouldShowHintsOnKey: function(string), - * wantInitialSelection: function()>} + * Once a provider responds affirmatively to a request for hints, the + * manager begins a hinting session with that provider, begins to query + * that provider for hints by way of its getHints method, and opens the + * hint list window. The hint list is kept open for the duration of the + * current session. The manager maintains the session until either: * - * Parameter Details: - * - getQueryInfo - examines cursor location of editor and returns an object representing - * the search query to be used for hinting. queryStr is a required property of the search object - * and a client may provide other properties on the object to carry more context about the query. - * - search - takes a query object and returns an array of hint strings based on the queryStr property - * of the query object. - * - handleSelect - takes a completion string and inserts it into the editor near the cursor - * position. It should return true by default to close the hint list, but if the code hint provider - * can return false if it wants to keep the hint list open and continue with a updated list. - * - shouldShowHintsOnKey - inspects the char code and returns true if it wants to show code hints on that key. - * - wantInitialSelection - return true if the provider wants to select the first hint item by default. + * 1. the provider gives a null response to a request for hints; + * 2. a deferred response to getHints fails to resolve; + * 3. the user explicitly dismisses the hint list window; + * 4. the editor is closed or becomes inactive; or + * 5. the editor undergoes a "complex" change, e.g., a multi-character + * insertion, deletion or navigation. * - * @param {Array.} modes An array of mode strings in which the provider can show code hints or "all" - * if it can show code hints in any mode. - * @param {number} specificity A positive number to indicate the priority of the provider. The larger the number, - * the higher priority the provider has. Zero if it has the lowest priority in displaying its code hints. + * Single-character insertions, deletions or navigations may not + * invalidate the current session; in which case, each such change + * precipitates a successive call to getHints. + * + * If the user selects a hint from the rendered hint list then the + * provider is responsible for inserting the hint into the editor context + * for the current session by way of its insertHint method. The provider + * may use the return value of insertHint to request that an additional + * explicit hint request be triggered, potentially beginning a new + * session. */ - function registerHintProvider(providerInfo, modes, specificity) { - var providerObj = { provider: providerInfo, - specificity: specificity || 0 }; - - if (modes) { - modes.forEach(function (mode) { - if (mode) { - if (!hintProviders[mode]) { - hintProviders[mode] = []; - } - hintProviders[mode].push(providerObj); - - if (hintProviders[mode].length > 1) { - hintProviders[mode].sort(_providerSort); - } - } - }); - } - } /** * Expose CodeHintList for unit testing @@ -554,7 +450,6 @@ define(function (require, exports, module) { // Define public API exports.handleKeyEvent = handleKeyEvent; exports.handleChange = handleChange; - exports.showHint = showHint; exports._getCodeHintList = _getCodeHintList; exports.registerHintProvider = registerHintProvider; }); diff --git a/src/extensions/default/HTMLCodeHints/main.js b/src/extensions/default/HTMLCodeHints/main.js index 8b7109d5b9f..2cd82ddea68 100644 --- a/src/extensions/default/HTMLCodeHints/main.js +++ b/src/extensions/default/HTMLCodeHints/main.js @@ -45,60 +45,96 @@ define(function (require, exports, module) { * @constructor */ function TagHints() {} - + /** - * Filters the source list by query and returns the result - * @param {Object.} + * Determines whether HTML tag hints are available in the current editor + * context. + * + * @param {Editor} editor + * A non-null editor object for the active window. + * + * @param {String} implicitChar + * Either null, if the hinting request was explicit, or a single character + * that represents the last insertion and that indicates an implicit + * hinting request. + * + * @return {Boolean} + * Determines whether the current provider is able to provide hints for + * the given editor context and, in case implicitChar is non- null, + * whether it is appropriate to do so. */ - TagHints.prototype.search = function (query) { - var result = $.map(tags, function (value, key) { - if (key.indexOf(query.queryStr) === 0) { - return key; + TagHints.prototype.hasHints = function (editor, implicitChar) { + var tagInfo, + query; + + this.editor = editor; + if (implicitChar === null) { + tagInfo = HTMLUtils.getTagInfo(this.editor, this.editor.getCursorPos()); + if (tagInfo.position.tokenType === HTMLUtils.TAG_NAME) { + if (tagInfo.position.offset >= 0) { + return true; + } } - }).sort(); - - return result; - // TODO: better sorting. Should rank tags based on portion of query that is present in tag + return false; + } else { + return implicitChar === "<"; + } }; - - + /** - * Figures out the text to use for the hint list query based on the text - * around the cursor - * Query is the text from the start of a tag to the current cursor position - * @param {Editor} editor - * @param {Cursor} current cursor location - * @return {Object., match: String, + * selectInitial: Boolean>} + * Null if the provider wishes to end the hinting session. Otherwise, a + * response object that provides 1. a sorted array hints that consists + * of strings; 2. a string match that is used by the manager to emphasize + * matching substrings when rendering the hint list; and 3. a boolean that + * indicates whether the first result, if one exists, should be selected + * by default in the hint list window. */ - TagHints.prototype.getQueryInfo = function (editor, cursor) { - var tagInfo = HTMLUtils.getTagInfo(editor, cursor), - query = {queryStr: null}; + TagHints.prototype.getHints = function (implicitChar) { + var tagInfo = HTMLUtils.getTagInfo(this.editor, this.editor.getCursorPos()), + query, + result; if (tagInfo.position.tokenType === HTMLUtils.TAG_NAME) { if (tagInfo.position.offset >= 0) { - query.queryStr = tagInfo.tagName.slice(0, tagInfo.position.offset); + query = tagInfo.tagName.slice(0, tagInfo.position.offset); + result = $.map(tags, function (value, key) { + if (key.indexOf(query) === 0) { + return key; + } + }).sort(); + // TODO: better sorting. Should rank tags based on portion of query that is present in tag + + return { + hints: result, + match: query, + selectInitial: true + }; } - } - - return query; + + return null; }; - + /** - * Enters the code completion text into the editor - * @param {string} completion - text to insert into current code editor - * @param {Editor} editor - * @param {Cursor} current cursor location - * @return {boolean} true to close the hint list, false to keep the hint list open. + * Inserts a given HTML tag hint into the current editor context. + * + * @param {String} hint + * The hint to be inserted into the editor context. + * + * @return {Boolean} + * Indicates whether the manager should follow hint insertion with an + * additional explicit hint request. */ - TagHints.prototype.handleSelect = function (completion, editor, cursor) { + TagHints.prototype.insertHint = function (completion) { var start = {line: -1, ch: -1}, end = {line: -1, ch: -1}, - tagInfo = HTMLUtils.getTagInfo(editor, cursor), + cursor = this.editor.getCursorPos(), + tagInfo = HTMLUtils.getTagInfo(this.editor, cursor), charCount = 0; if (tagInfo.position.tokenType === HTMLUtils.TAG_NAME) { @@ -111,30 +147,13 @@ define(function (require, exports, module) { if (completion !== tagInfo.tagName) { if (start.ch !== end.ch) { - editor.document.replaceRange(completion, start, end); + this.editor.document.replaceRange(completion, start, end); } else { - editor.document.replaceRange(completion, start); + this.editor.document.replaceRange(completion, start); } } - return true; - }; - - /** - * Check whether to show hints on a specific key. - * @param {string} key -- the character for the key user just presses. - * @return {boolean} return true/false to indicate whether hinting should be triggered by this key. - */ - TagHints.prototype.shouldShowHintsOnKey = function (key) { - return key === "<"; - }; - - /** - * Check whether to select the first item in the list by default - * @return {boolean} return true to highlight the first item. - */ - TagHints.prototype.wantInitialSelection = function () { - return true; + return false; }; /** @@ -162,125 +181,6 @@ define(function (require, exports, module) { }); }; - /** - * Enters the code completion text into the editor - * @param {string} completion - text to insert into current code editor - * @param {Editor} editor - * @param {Cursor} current cursor location - * @return {boolean} true to close the hint list, false to keep the hint list open. - */ - AttrHints.prototype.handleSelect = function (completion, editor, cursor) { - var start = {line: -1, ch: -1}, - end = {line: -1, ch: -1}, - tagInfo = HTMLUtils.getTagInfo(editor, cursor), - tokenType = tagInfo.position.tokenType, - charCount = 0, - insertedName = false, - replaceExistingOne = tagInfo.attr.valueAssigned, - endQuote = "", - shouldReplace = true; - - if (tokenType === HTMLUtils.ATTR_NAME) { - charCount = tagInfo.attr.name.length; - // Append an equal sign and two double quotes if the current attr is not an empty attr - // and then adjust cursor location before the last quote that we just inserted. - if (!replaceExistingOne && attributes && attributes[completion] && - attributes[completion].type !== "flag") { - completion += "=\"\""; - insertedName = true; - } else if (completion === tagInfo.attr.name) { - shouldReplace = false; - } - } else if (tokenType === HTMLUtils.ATTR_VALUE) { - charCount = tagInfo.attr.value.length; - - // Special handling for URL hinting -- if the completion is a file name - // and not a folder, then close the code hint list. - if (!this.closeOnSelect && completion.match(/\/$/) === null) { - this.closeOnSelect = true; - } - - if (!tagInfo.attr.hasEndQuote) { - endQuote = tagInfo.attr.quoteChar; - if (endQuote) { - completion += endQuote; - } else if (tagInfo.position.offset === 0) { - completion = "\"" + completion + "\""; - } - } else if (completion === tagInfo.attr.value) { - shouldReplace = false; - } - } - - end.line = start.line = cursor.line; - start.ch = cursor.ch - tagInfo.position.offset; - end.ch = start.ch + charCount; - - if (shouldReplace) { - if (start.ch !== end.ch) { - editor.document.replaceRange(completion, start, end); - } else { - editor.document.replaceRange(completion, start); - } - } - - if (!this.closeOnSelect) { - return false; - } - - if (insertedName) { - editor.setCursorPos(start.line, start.ch + completion.length - 1); - - // Since we're now inside the double-quotes we just inserted, - // immediately pop up the attribute value hint. - CodeHintManager.showHint(editor); - } else if (tokenType === HTMLUtils.ATTR_VALUE && tagInfo.attr.hasEndQuote) { - // Move the cursor to the right of the existing end quote after value insertion. - editor.setCursorPos(start.line, start.ch + completion.length + 1); - } - - return true; - }; - - /** - * Figures out the text to use for the hint list query based on the text - * around the cursor - * Query is the text from the start of an attribute to the current cursor position - * @param {Editor} editor - * @param {Cursor} current cursor location - * @return {Object.= 0) { - if (tokenType === HTMLUtils.ATTR_NAME) { - query.queryStr = tagInfo.attr.name.slice(0, tagInfo.position.offset); - } else { - query.queryStr = tagInfo.attr.value.slice(0, tagInfo.position.offset); - query.attrName = tagInfo.attr.name; - } - } else if (tokenType === HTMLUtils.ATTR_VALUE) { - // We get negative offset for a quoted attribute value with some leading whitespaces - // as in } + * Helper function that determins the possible value hints for a given html tag/attribute name pair + * + * @param {String} query + * The current query + * + * @param {String} tagName + * HTML tag name + * + * @param {String} attrName + * HTML attribute name + * + * @return {Object} + * The possible hints and the sort function to use on thise hints. + */ + AttrHints.prototype._getValueHintsForAttr = function (query, tagName, attrName) { + // We look up attribute values with tagName plus a slash and attrName first. + // If the lookup fails, then we fall back to look up with attrName only. Most + // of the attributes in JSON are using attribute name only as their properties, + // but in some cases like "type" attribute, we have different properties like + // "script/type", "link/type" and "button/type". + var hints = [], + sortFunc = null; + + var tagPlusAttr = tagName + "/" + attrName, + attrInfo = attributes[tagPlusAttr] || attributes[attrName]; + + if (attrInfo) { + if (attrInfo.type === "boolean") { + hints = ["false", "true"]; + } else if (attrInfo.type === "url") { + // Default behavior for url hints is do not close on select. + this.closeOnSelect = false; + hints = this._getUrlList(query); + sortFunc = StringUtils.urlSort; + } else if (attrInfo.attribOption) { + hints = attrInfo.attribOption; + } + } + + return { hints: hints, sortFunc: sortFunc }; + }; + + /** + * Determines whether HTML attribute hints are available in the current + * editor context. + * + * @param {Editor} editor + * A non-null editor object for the active window. + * + * @param {String} implicitChar + * Either null, if the hinting request was explicit, or a single character + * that represents the last insertion and that indicates an implicit + * hinting request. + * + * @return {Boolean} + * Determines whether the current provider is able to provide hints for + * the given editor context and, in case implicitChar is non-null, + * whether it is appropriate to do so. */ - AttrHints.prototype.search = function (query) { - var result = []; + AttrHints.prototype.hasHints = function (editor, implicitChar) { + var tagInfo, + query, + tokenType; + + this.editor = editor; + if (implicitChar === null) { + tagInfo = HTMLUtils.getTagInfo(editor, editor.getCursorPos()); + query = null; + tokenType = tagInfo.position.tokenType; + + if (tokenType === HTMLUtils.ATTR_NAME || tokenType === HTMLUtils.ATTR_VALUE) { + if (tagInfo.position.offset >= 0) { + if (tokenType === HTMLUtils.ATTR_NAME) { + query = tagInfo.attr.name.slice(0, tagInfo.position.offset); + } else { + query = tagInfo.attr.value.slice(0, tagInfo.position.offset); + } + } else if (tokenType === HTMLUtils.ATTR_VALUE) { + // We get negative offset for a quoted attribute value with some leading whitespaces + // as in '); + testEditor.setCursorPos({ line: 0, ch: 13 }); // cursor between = and space expectHints(HTMLCodeHints.attrHintProvider); - testEditor.setCursorPos({ line: 6, ch: 12 }); // cursor between space and ' + testEditor.setCursorPos({ line: 0, ch: 14 }); // cursor between space and " expectHints(HTMLCodeHints.attrHintProvider); }); it("should NOT list hints to right of attribute value with no separating space", function () { @@ -412,15 +407,13 @@ define(function (require, exports, module) { // Replace div on line 9 with embed type=' ("
    " - selectHint(HTMLCodeHints.attrHintProvider, "dir"); + expect(selectHint(HTMLCodeHints.attrHintProvider, "dir")).toBe(true); // returning 'true' from insertHint (which is called by selectHint helper) initiates a new explicit hint request expect(testDocument.getLine(6)).toBe("

    Subheading

    "); - expect(CodeHintManager._getCodeHintList()).toBeTruthy(); - expect(CodeHintManager._getCodeHintList().isOpen()).toBe(true); }); it("should NOT insert =\"\" after valueless attribute", function () { From 9c775d46b6f3811c9a2c75362ce72d1c32b5a80d Mon Sep 17 00:00:00 2001 From: Joel Brandt Date: Tue, 18 Dec 2012 07:40:20 -0800 Subject: [PATCH 08/17] Implement async interface for getHints, and update URL hinting to use this interface --- src/editor/CodeHintManager.js | 56 +++++++++++++----- src/extensions/default/HTMLCodeHints/main.js | 61 +++++++++++++++----- 2 files changed, 88 insertions(+), 29 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index d5dc403c2f7..553688934d8 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -35,7 +35,8 @@ define(function (require, exports, module) { lastChar = null, sessionProvider = null, sessionEditor = null, - hintList = null; + hintList = null, + deferredHints = null; /** * Comparator to sort providers based on their priority @@ -135,7 +136,7 @@ define(function (require, exports, module) { * hinting session will remain open and the value of the selectInitial * property is irrelevant. * - * TODO: Alternatively, the provider may return a jQuery.Deferred object + * Alternatively, the provider may return a jQuery.Deferred object * that resolves with an object with the structure described above. In * this case, the manager will initially render the hint list window with * a throbber and will render the actual list once the deferred object @@ -175,14 +176,14 @@ define(function (require, exports, module) { * by default in the hint list window. If match is non-null, then the * hints should be strings. * - * TODO: If the match is null, the manager will not attempt to emphasize - * any parts of the hints when rendering the hint list; instead the - * provider may return strings or jQuery objects for which emphasis is - * self-contained. For example, the strings may contain substrings that - * wrapped in bold tags. In this way, the provider can choose to let the - * manager handle emphasis for the simple and common case of prefix - * matching, or can provide its own emphasis if it wishes to use a more - * sophisticated matching algorithm. + * TODO - NOT YET IMPLEMENTED: If the match is null, the manager will not + * attempt to emphasize any parts of the hints when rendering the hint + * list; instead the provider may return strings or jQuery objects for + * which emphasis is self-contained. For example, the strings may contain + * substrings that wrapped in bold tags. In this way, the provider can + * choose to let the manager handle emphasis for the simple and common case + * of prefix matching, or can provide its own emphasis if it wishes to use + * a more sophisticated matching algorithm. * * * # CodeHintProvider.insertHint(hint) @@ -268,6 +269,10 @@ define(function (require, exports, module) { hintList = null; sessionProvider = null; sessionEditor = null; + if (deferredHints) { + deferredHints.reject(); + deferredHints = null; + } } /** @@ -277,7 +282,9 @@ define(function (require, exports, module) { */ function _inSession(editor) { if (sessionEditor !== null) { - if (sessionEditor === editor && hintList.isOpen()) { + if (sessionEditor === editor && + (hintList.isOpen() || + (deferredHints && !deferredHints.isResolved() && !deferredHints.isRejected()))) { return true; } else { // the editor has changed @@ -294,17 +301,34 @@ define(function (require, exports, module) { * Assumes that it is called when a session is active (i.e. sessionProvider is not null). */ function _updateHintList() { + if (deferredHints) { + deferredHints.reject(); + deferredHints = null; + } + var response = sessionProvider.getHints(lastChar); lastChar = null; if (!response) { // the provider wishes to close the session _endSession(); - } else if (hintList.isOpen()) { - // the session is open - hintList.update(response); - } else { - hintList.open(response); + } else if (response.hasOwnProperty("hints")) { // a synchronous response + if (hintList.isOpen()) { + // the session is open + hintList.update(response); + } else { + hintList.open(response); + } + } else { // response is a deferred + deferredHints = response; + response.done(function (hints) { + if (hintList.isOpen()) { + // the session is open + hintList.update(hints); + } else { + hintList.open(hints); + } + }); } } diff --git a/src/extensions/default/HTMLCodeHints/main.js b/src/extensions/default/HTMLCodeHints/main.js index 2cd82ddea68..dc84ba393d5 100644 --- a/src/extensions/default/HTMLCodeHints/main.js +++ b/src/extensions/default/HTMLCodeHints/main.js @@ -190,7 +190,7 @@ define(function (require, exports, module) { AttrHints.prototype._getUrlList = function (query) { var doc, result = []; - + // site-root relative links are not yet supported, so filter them out if (query.queryStr.length > 0 && query.queryStr[0] === "/") { return result; @@ -261,8 +261,12 @@ define(function (require, exports, module) { var self = this, origEditor = EditorManager.getFocusedEditor(); + if (self.cachedHints && self.cachedHints.deferred) { + self.cachedHints.deferred.reject(); + } // create empty object so we can detect "waiting" state self.cachedHints = {}; + self.cachedHints.deferred = $.Deferred(); self.cachedHints.unfiltered = []; NativeFileSystem.requestNativeFileSystem(targetDir, function (fs) { @@ -283,10 +287,32 @@ define(function (require, exports, module) { self.cachedHints.query = query; self.cachedHints.queryDir = queryDir; self.cachedHints.docDir = docDir; + + if (!self.cachedHints.deferred.isRejected()) { + var currentDeferred = self.cachedHints.deferred; + // Since we've cached the results, the next call to _getUrlList should be synchronous. + // If it isn't, we've got a problem and should reject both the current deferred + // and any new deferred that got created on the call. + var syncResults = self._getUrlList(query); + if (syncResults instanceof Array) { + currentDeferred.resolveWith(self, [syncResults]); + } else { + if (currentDeferred && !currentDeferred.isResolved() && !currentDeferred.isRejected()) { + currentDeferred.reject(); + } + + if (self.cachedHints.deferred && + !self.cachedHints.deferred.isResolved() && + !self.cachedHints.deferred.isRejected()) { + self.cachedHints.deferred.reject(); + self.cachedHints.deferred = null; + } + } + } }); }); - return result; + return self.cachedHints.deferred; } // build list @@ -324,8 +350,8 @@ define(function (require, exports, module) { * @param {String} attrName * HTML attribute name * - * @return {Object} - * The possible hints and the sort function to use on thise hints. + * @return {Object), sortFunc: ?Function>} + * The (possibly deferred) hints and the sort function to use on thise hints. */ AttrHints.prototype._getValueHintsForAttr = function (query, tagName, attrName) { // We look up attribute values with tagName plus a slash and attrName first. @@ -402,15 +428,18 @@ define(function (require, exports, module) { if (tokenType === HTMLUtils.ATTR_VALUE && tagInfo.attr.name) { var hintsAndSortFunc = this._getValueHintsForAttr({queryStr: query}, tagInfo.tagName, tagInfo.attr.name); var hints = hintsAndSortFunc.hints; - var i, foundPrefix = false; - for (i = 0; i < hints.length; i++) { - if (hints[i].indexOf(query) === 0) { - foundPrefix = true; - break; + if (hints instanceof Array) { + // If we got synchronous hints, check if we have something we'll actually use + var i, foundPrefix = false; + for (i = 0; i < hints.length; i++) { + if (hints[i].indexOf(query) === 0) { + foundPrefix = true; + break; + } + } + if (!foundPrefix) { + query = null; } - } - if (!foundPrefix) { - query = null; } } return query !== null; @@ -483,7 +512,7 @@ define(function (require, exports, module) { }); } - if (hints.length) { + if (hints instanceof Array && hints.length) { console.assert(!result.length); result = $.map(hints, function (item) { if (item.indexOf(filter) === 0) { @@ -495,6 +524,12 @@ define(function (require, exports, module) { match: query.queryStr, selectInitial: true }; + } else if (hints instanceof Object && hints.hasOwnProperty("done")) { // Deferred hints + var deferred = $.Deferred(); + hints.done(function (asyncHints) { + deferred.resolveWith(this, [{ hints : asyncHints, match: query.queryStr, selectInitial: true }]); + }); + return deferred; } else { return null; } From b0f893a12e94158f90f4b1f0010a0a1eac384bb9 Mon Sep 17 00:00:00 2001 From: Ian Wehrman Date: Tue, 18 Dec 2012 15:59:01 -0800 Subject: [PATCH 09/17] Update getHints documentation to reflect the change online here: https://github.com/adobe/brackets/wiki/New-Code-Hinting-API-Proposal --- src/editor/CodeHintManager.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index 553688934d8..94fe79b3f4a 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -119,7 +119,7 @@ define(function (require, exports, module) { * whether it is appropriate to do so. * * - * # CodeHintProvider.getHints() + * # CodeHintProvider.getHints(implicitChar) * * The method by which a provider provides hints for the editor context * associated with the current session. The getHints method is called only @@ -165,6 +165,10 @@ define(function (require, exports, module) { * assume that the document will not be changed outside of the editor * during a session. * + * param {String} implicitChar + * Either null, if the request to update the hint list was a result of + * navigation, or a single character that represents the last insertion. + * * return {(Object + jQuery.Deferred), * match: String, selectInitial: Boolean>} * Null if the provider wishes to end the hinting session. Otherwise, a From 24e9a3547b3da0d1aa04f196629291eab1be7d38 Mon Sep 17 00:00:00 2001 From: Ian Wehrman Date: Tue, 18 Dec 2012 15:59:56 -0800 Subject: [PATCH 10/17] comment typo --- src/editor/CodeHintList.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/CodeHintList.js b/src/editor/CodeHintList.js index b9df916f861..bbdf0fe6438 100644 --- a/src/editor/CodeHintList.js +++ b/src/editor/CodeHintList.js @@ -244,7 +244,7 @@ define(function (require, exports, module) { // Trigger a click handler to commmit the selected item $(this.$hintMenu.find("li")[this.selectedIndex]).triggerHandler("click"); } else { - // only prevent default handler wshen the list handles the event + // only prevent default handler when the list handles the event return; } From c7e6bc798e4cc1fc60592d8c382848288dfa0e83 Mon Sep 17 00:00:00 2001 From: Ian Wehrman Date: Tue, 18 Dec 2012 17:29:50 -0800 Subject: [PATCH 11/17] change providerName to modeName and shift the documentation around for readability --- src/editor/CodeHintManager.js | 382 +++++++++++++++++----------------- 1 file changed, 194 insertions(+), 188 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index 94fe79b3f4a..95f3f09e2aa 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -21,6 +21,195 @@ * */ +/* + * CodeHintManager Overview: + * + * The CodeHintManager mediates the interaction between the editor and a + * collection of hint providers. If hints are requested explicitly by the + * user, then the providers registered for the current mode are queried + * for their ability to provide hints in order of descending priority by + * way their hasHints methods. Character insertions may also constitute an + * implicit request for hints; consequently, providers for the current + * mode are also queried on character insertion for both their ability to + * provide hints and also for the suitability of providing implicit hints + * in the given editor context. + * + * Once a provider responds affirmatively to a request for hints, the + * manager begins a hinting session with that provider, begins to query + * that provider for hints by way of its getHints method, and opens the + * hint list window. The hint list is kept open for the duration of the + * current session. The manager maintains the session until either: + * + * 1. the provider gives a null response to a request for hints; + * 2. a deferred response to getHints fails to resolve; + * 3. the user explicitly dismisses the hint list window; + * 4. the editor is closed or becomes inactive; or + * 5. the editor undergoes a "complex" change, e.g., a multi-character + * insertion, deletion or navigation. + * + * Single-character insertions, deletions or navigations may not + * invalidate the current session; in which case, each such change + * precipitates a successive call to getHints. + * + * If the user selects a hint from the rendered hint list then the + * provider is responsible for inserting the hint into the editor context + * for the current session by way of its insertHint method. The provider + * may use the return value of insertHint to request that an additional + * explicit hint request be triggered, potentially beginning a new + * session. + * + * + * CodeHintProvider Overview: + * + * A code hint provider should implement the following three functions: + * + * CodeHintProvider.hasHints(editor, implicitChar) + * CodeHintProvider.getHints(implicitChar) + * CodeHintProvider.insertHint(hint) + * + * The behavior of these three functions is described in detail below. + * + * # CodeHintProvider.hasHints(editor, implicitChar) + * + * The method by which a provider indicates intent to provide hints for a + * given editor. The manager calls this method both when hints are + * explicitly requested (via, e.g., Ctrl-Space) and when they may be + * implicitly requested as a result of character insertion in the editor. + * If the provider responds negatively then the manager may query other + * providers for hints. Otherwise, a new hinting session begins with this + * provider, during which the manager may repeatedly query the provider + * for hints via the getHints method. Note that no other providers will be + * queried until the hinting session ends. + * + * The implicitChar parameter is used to determine whether the hinting + * request is explicit or implicit. If the string is null then hints were + * explicitly requested and the provider should reply based on whether it + * is possible to return hints for the given editor context. Otherwise, + * the string contains just the last character inserted into the editor's + * document and the request for hints is implicit. In this case, the + * provider should determine whether it is both possible and appropriate + * to show hints. Because implicit hints can be triggered by every + * character insertion, hasHints may be called frequently; consequently, + * the provider should endeavor to return a value as quickly as possible. + * + * Because calls to hasHints imply that a hinting session is about to + * begin, a provider may wish to clean up cached data from previous + * sessions in this method. Similarly, if the provider returns true, it + * may wish to prepare to cache data suitable for the current session. In + * particular, it should keep a reference to the editor object so that it + * can access the editor in future calls to getHints and insertHints. + * + * param {Editor} editor + * A non-null editor object for the active window. + * + * param {String} implicitChar + * Either null, if the hinting request was explicit, or a single character + * that represents the last insertion and that indicates an implicit + * hinting request. + * + * return {Boolean} + * Determines whether the current provider is able to provide hints for + * the given editor context and, in case implicitChar is non- null, + * whether it is appropriate to do so. + * + * + * # CodeHintProvider.getHints(implicitChar) + * + * The method by which a provider provides hints for the editor context + * associated with the current session. The getHints method is called only + * if the provider asserted its willingness to provide hints in an earlier + * call to hasHints. The provider may return null, which indicates that + * the manager should end the current hinting session and close the hint + * list window. Otherwise, the provider should return a response object + * that contains three properties: + * + * 1. hints, a sorted array hints that the provider could later insert + * into the editor; + * 2. match, a string that the manager may use to emphasize substrings of + * hints in the hint list; and + * 3. selectInitial, a boolean that indicates whether or not the the + * first hint in the list should be selected by default. + * + * If the array of + * hints is empty, then the manager will render an empty list, but the + * hinting session will remain open and the value of the selectInitial + * property is irrelevant. + * + * Alternatively, the provider may return a jQuery.Deferred object + * that resolves with an object with the structure described above. In + * this case, the manager will initially render the hint list window with + * a throbber and will render the actual list once the deferred object + * resolves to a response object. If a hint list has already been rendered + * (from an earlier call to getHints), then the old list will continue + * to be displayed until the new deferred has resolved. + * + * Both the manager and the provider can reject the deferred object. The + * manager will reject the deferred if the editor changes state (e.g., the + * user types a character) or if the hinting session ends (e.g., the user + * explicitly closes the hints by pressing escape). The provider can use + * this event to, e.g., abort an expensive computation. Consequently, the + * provider may assume that getHints will not be called again until the + * deferred object from the current call has resolved or been rejected. If + * the provider rejects the deferred, the manager will end the hinting + * session. + * + * The getHints method may be called by the manager repeatedly during a + * hinting session. Providers may wish to cache information for efficiency + * that may be useful throughout these sessions. The same editor context + * will be used throughout a session, and will only change during the + * session as a result of single-character insertions, deletions and + * cursor navigations. The provider may assume that, throughout the + * lifetime of the session, the getHints method will be called exactly + * once for each such editor change. Consequently, the provider may also + * assume that the document will not be changed outside of the editor + * during a session. + * + * param {String} implicitChar + * Either null, if the request to update the hint list was a result of + * navigation, or a single character that represents the last insertion. + * + * return {(Object + jQuery.Deferred), + * match: String, selectInitial: Boolean>} + * Null if the provider wishes to end the hinting session. Otherwise, a + * response object, possibly deferred, that provides 1. a sorted array + * hints that consists either of strings or jQuery objects; 2. a string + * match, possibly null, that is used by the manager to emphasize + * matching substrings when rendering the hint list; and 3. a boolean that + * indicates whether the first result, if one exists, should be selected + * by default in the hint list window. If match is non-null, then the + * hints should be strings. + * + * TODO - NOT YET IMPLEMENTED: If the match is null, the manager will not + * attempt to emphasize any parts of the hints when rendering the hint + * list; instead the provider may return strings or jQuery objects for + * which emphasis is self-contained. For example, the strings may contain + * substrings that wrapped in bold tags. In this way, the provider can + * choose to let the manager handle emphasis for the simple and common case + * of prefix matching, or can provide its own emphasis if it wishes to use + * a more sophisticated matching algorithm. + * + * + * # CodeHintProvider.insertHint(hint) + * + * The method by which a provider inserts a hint into the editor context + * associated with the current session. The provider may assume that the + * given hint was returned by the provider in some previous call in the + * current session to getHints, but not necessarily the most recent call. + * After the insertion has been performed, the current hinting session is + * closed. The provider should return a boolean value to indicate whether + * or not the end of the session should be immediately followed by a new + * explicit hinting request, which may result in a new hinting session + * being opened with some provider, but not necessarily the current one. + * + * param {String} hint + * The hint to be inserted into the editor context for the current session. + * + * return {Boolean} + * Indicates whether the manager should follow hint insertion with an + * explicit hint request. + */ + + /*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */ /*global define, $, brackets */ @@ -63,151 +252,6 @@ define(function (require, exports, module) { * will have the opportunity to provide hints at a given mode before * those with a lower priority. Brackets default providers have * priority zero. - * - * - * - * A code hint provider should implement the following three functions: - * - * CodeHintProvider.hasHints(editor, implicitChar) - * CodeHintProvider.getHints(implicitChar) - * CodeHintProvider.insertHint(hint) - * - * The behavior of these three functions is described in detail below. - * - * - * # CodeHintProvider.hasHints(editor, implicitChar) - * - * The method by which a provider indicates intent to provide hints for a - * given editor. The manager calls this method both when hints are - * explicitly requested (via, e.g., Ctrl-Space) and when they may be - * implicitly requested as a result of character insertion in the editor. - * If the provider responds negatively then the manager may query other - * providers for hints. Otherwise, a new hinting session begins with this - * provider, during which the manager may repeatedly query the provider - * for hints via the getHints method. Note that no other providers will be - * queried until the hinting session ends. - * - * The implicitChar parameter is used to determine whether the hinting - * request is explicit or implicit. If the string is null then hints were - * explicitly requested and the provider should reply based on whether it - * is possible to return hints for the given editor context. Otherwise, - * the string contains just the last character inserted into the editor's - * document and the request for hints is implicit. In this case, the - * provider should determine whether it is both possible and appropriate - * to show hints. Because implicit hints can be triggered by every - * character insertion, hasHints may be called frequently; consequently, - * the provider should endeavor to return a value as quickly as possible. - * - * Because calls to hasHints imply that a hinting session is about to - * begin, a provider may wish to clean up cached data from previous - * sessions in this method. Similarly, if the provider returns true, it - * may wish to prepare to cache data suitable for the current session. In - * particular, it should keep a reference to the editor object so that it - * can access the editor in future calls to getHints and insertHints. - * - * param {Editor} editor - * A non-null editor object for the active window. - * - * param {String} implicitChar - * Either null, if the hinting request was explicit, or a single character - * that represents the last insertion and that indicates an implicit - * hinting request. - * - * return {Boolean} - * Determines whether the current provider is able to provide hints for - * the given editor context and, in case implicitChar is non- null, - * whether it is appropriate to do so. - * - * - * # CodeHintProvider.getHints(implicitChar) - * - * The method by which a provider provides hints for the editor context - * associated with the current session. The getHints method is called only - * if the provider asserted its willingness to provide hints in an earlier - * call to hasHints. The provider may return null, which indicates that - * the manager should end the current hinting session and close the hint - * list window. Otherwise, the provider should return a response object - * that contains three properties: 1. hints, a sorted array hints that the - * provider could later insert into the editor; 2. match, a string that - * the manager may use to emphasize substrings of hints in the hint list; - * and 3. selectInitial, a boolean that indicates whether or not the the - * first hint in the list should be selected by default. If the array of - * hints is empty, then the manager will render an empty list, but the - * hinting session will remain open and the value of the selectInitial - * property is irrelevant. - * - * Alternatively, the provider may return a jQuery.Deferred object - * that resolves with an object with the structure described above. In - * this case, the manager will initially render the hint list window with - * a throbber and will render the actual list once the deferred object - * resolves to a response object. If a hint list has already been rendered - * (from an earlier call to getHints), then the old list will continue - * to be displayed until the new deferred has resolved. - * - * Both the manager and the provider can reject the deferred object. The - * manager will reject the deferred if the editor changes state (e.g., the - * user types a character) or if the hinting session ends (e.g., the user - * explicitly closes the hints by pressing escape). The provider can use - * this event to, e.g., abort an expensive computation. Consequently, the - * provider may assume that getHints will not be called again until the - * deferred object from the current call has resolved or been rejected. If - * the provider rejects the deferred, the manager will end the hinting - * session. - * - * The getHints method may be called by the manager repeatedly during a - * hinting session. Providers may wish to cache information for efficiency - * that may be useful throughout these sessions. The same editor context - * will be used throughout a session, and will only change during the - * session as a result of single-character insertions, deletions and - * cursor navigations. The provider may assume that, throughout the - * lifetime of the session, the getHints method will be called exactly - * once for each such editor change. Consequently, the provider may also - * assume that the document will not be changed outside of the editor - * during a session. - * - * param {String} implicitChar - * Either null, if the request to update the hint list was a result of - * navigation, or a single character that represents the last insertion. - * - * return {(Object + jQuery.Deferred), - * match: String, selectInitial: Boolean>} - * Null if the provider wishes to end the hinting session. Otherwise, a - * response object, possibly deferred, that provides 1. a sorted array - * hints that consists either of strings or jQuery objects; 2. a string - * match, possibly null, that is used by the manager to emphasize - * matching substrings when rendering the hint list; and 3. a boolean that - * indicates whether the first result, if one exists, should be selected - * by default in the hint list window. If match is non-null, then the - * hints should be strings. - * - * TODO - NOT YET IMPLEMENTED: If the match is null, the manager will not - * attempt to emphasize any parts of the hints when rendering the hint - * list; instead the provider may return strings or jQuery objects for - * which emphasis is self-contained. For example, the strings may contain - * substrings that wrapped in bold tags. In this way, the provider can - * choose to let the manager handle emphasis for the simple and common case - * of prefix matching, or can provide its own emphasis if it wishes to use - * a more sophisticated matching algorithm. - * - * - * # CodeHintProvider.insertHint(hint) - * - * The method by which a provider inserts a hint into the editor context - * associated with the current session. The provider may assume that the - * given hint was returned by the provider in some previous call in the - * current session to getHints, but not necessarily the most recent call. - * After the insertion has been performed, the current hinting session is - * closed. The provider should return a boolean value to indicate whether - * or not the end of the session should be immediately followed by a new - * explicit hinting request, which may result in a new hinting session - * being opened with some provider, but not necessarily the current one. - * - * param {String} hint - * The hint to be inserted into the editor context for the current session. - * - * return {Boolean} - * Indicates whether the manager should follow hint insertion with an - * explicit hint request. */ function registerHintProvider(providerInfo, modes, priority) { var providerObj = { provider: providerInfo, @@ -231,11 +275,11 @@ define(function (require, exports, module) { if (registerInAllModes) { // if we're registering in all, then we ignore the modeNames array // so that we avoid registering a provider twice - var providerName; - for (providerName in hintProviders) { - if (hintProviders.hasOwnProperty(providerName)) { - hintProviders[providerName].push(providerObj); - hintProviders[providerName].sort(_providerSort); + var modeName; + for (modeName in hintProviders) { + if (hintProviders.hasOwnProperty(modeName)) { + hintProviders[modeName].push(providerObj); + hintProviders[modeName].sort(_providerSort); } } } else { @@ -430,44 +474,6 @@ define(function (require, exports, module) { } } - /* - * CodeHintManager Overview: - * - * The CodeHintManager mediates the interaction between the editor and a - * collection of hint providers. If hints are requested explicitly by the - * user, then the providers registered for the current mode are queried - * for their ability to provide hints in order of descending priority by - * way their hasHints methods. Character insertions may also constitute an - * implicit request for hints; consequently, providers for the current - * mode are also queried on character insertion for both their ability to - * provide hints and also for the suitability of providing implicit hints - * in the given editor context. - * - * Once a provider responds affirmatively to a request for hints, the - * manager begins a hinting session with that provider, begins to query - * that provider for hints by way of its getHints method, and opens the - * hint list window. The hint list is kept open for the duration of the - * current session. The manager maintains the session until either: - * - * 1. the provider gives a null response to a request for hints; - * 2. a deferred response to getHints fails to resolve; - * 3. the user explicitly dismisses the hint list window; - * 4. the editor is closed or becomes inactive; or - * 5. the editor undergoes a "complex" change, e.g., a multi-character - * insertion, deletion or navigation. - * - * Single-character insertions, deletions or navigations may not - * invalidate the current session; in which case, each such change - * precipitates a successive call to getHints. - * - * If the user selects a hint from the rendered hint list then the - * provider is responsible for inserting the hint into the editor context - * for the current session by way of its insertHint method. The provider - * may use the return value of insertHint to request that an additional - * explicit hint request be triggered, potentially beginning a new - * session. - */ - /** * Expose CodeHintList for unit testing */ From 90f7e8fc1417dc930980c2896fc262ee0b31b22b Mon Sep 17 00:00:00 2001 From: Ian Wehrman Date: Wed, 19 Dec 2012 11:13:57 -0800 Subject: [PATCH 12/17] rotate the list selection on (page) up / (page) down --- src/editor/CodeHintList.js | 51 ++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/src/editor/CodeHintList.js b/src/editor/CodeHintList.js index bbdf0fe6438..37b6db48c1d 100644 --- a/src/editor/CodeHintList.js +++ b/src/editor/CodeHintList.js @@ -198,33 +198,26 @@ define(function (require, exports, module) { CodeHintList.prototype.handleKeyEvent = function (event) { var keyCode, self = this; - - function _upSelection() { - if (self.selectedIndex > 0) { - self._setSelectedIndex(self.selectedIndex - 1); - } - } - - function _downSelection() { - if (self.selectedIndex < (self.displayList.length - 1)) { - self._setSelectedIndex(self.selectedIndex + 1); - } - } - - function _pageUpSelection() { - var index; - if (self.selectedIndex > 0) { - index = self.selectedIndex - self._getItemsPerPage(); - self._setSelectedIndex(Math.max(index, 0)); + + // positive distance rotates down; negative distance rotates up + function _rotateSelection(distance) { + var len = Math.min(self.displayList.length, self.options.maxResults), + pos; + + // set the initial selection position if necessary + if (self.selectedIndex < 0) { + pos = (distance > 0) ? len - 1 : 0; + self._setSelectedIndex(pos); + } else { + pos = self.selectedIndex; } - } - - function _pageDownSelection() { - var index; - if (self.selectedIndex < (self.displayList.length - 1)) { - index = self.selectedIndex + self._getItemsPerPage(); - self._setSelectedIndex(Math.min(index, (self.displayList.length - 1))); + + // rotate the selection + if (distance < 0) { + distance %= len; + distance += len; } + self._setSelectedIndex((pos + distance) % len); } // (page) up, (page) down, enter and tab key are handled by the list @@ -232,13 +225,13 @@ define(function (require, exports, module) { keyCode = event.keyCode; if (keyCode === KeyEvent.DOM_VK_UP) { - _upSelection.call(this); + _rotateSelection.call(this, -1); } else if (keyCode === KeyEvent.DOM_VK_DOWN) { - _downSelection.call(this); + _rotateSelection.call(this, 1); } else if (keyCode === KeyEvent.DOM_VK_PAGE_UP) { - _pageUpSelection.call(this); + _rotateSelection.call(this, -this._getItemsPerPage()); } else if (keyCode === KeyEvent.DOM_VK_PAGE_DOWN) { - _pageDownSelection.call(this); + _rotateSelection.call(this, this._getItemsPerPage()); } else if (this.selectedIndex !== -1 && (keyCode === KeyEvent.DOM_VK_RETURN || keyCode === KeyEvent.DOM_VK_TAB)) { // Trigger a click handler to commmit the selected item From 349e7fe99be9d4b28874b9e610ba3743e5a7231a Mon Sep 17 00:00:00 2001 From: Ian Wehrman Date: Wed, 19 Dec 2012 11:39:41 -0800 Subject: [PATCH 13/17] documentation improvements and a more general test for sessionEditor undefinedness --- src/editor/CodeHintManager.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index 95f3f09e2aa..1dc07079480 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -168,8 +168,11 @@ * Either null, if the request to update the hint list was a result of * navigation, or a single character that represents the last insertion. * - * return {(Object + jQuery.Deferred), - * match: String, selectInitial: Boolean>} + * return {(Object + jQuery.Deferred)< + * hints: Array<(String + jQuery.Obj)>, + * match: String, + * selectInitial: Boolean>} + * * Null if the provider wishes to end the hinting session. Otherwise, a * response object, possibly deferred, that provides 1. a sorted array * hints that consists either of strings or jQuery objects; 2. a string @@ -325,11 +328,15 @@ define(function (require, exports, module) { /** * Is there a hinting session active for a given editor? + * + * NOTE: the sessionEditor, sessionProvider and hintList objects are + * only guaranteed to be initialized during an active session. + * * @param {Editor} editor * @return boolean */ function _inSession(editor) { - if (sessionEditor !== null) { + if (sessionEditor) { if (sessionEditor === editor && (hintList.isOpen() || (deferredHints && !deferredHints.isResolved() && !deferredHints.isRejected()))) { @@ -480,10 +487,10 @@ define(function (require, exports, module) { function _getCodeHintList() { return hintList; } + exports._getCodeHintList = _getCodeHintList; // Define public API exports.handleKeyEvent = handleKeyEvent; exports.handleChange = handleChange; - exports._getCodeHintList = _getCodeHintList; exports.registerHintProvider = registerHintProvider; }); From b9d81580518c850f0a09d6a2321f72bef9a5c450 Mon Sep 17 00:00:00 2001 From: Ian Wehrman Date: Wed, 19 Dec 2012 11:59:44 -0800 Subject: [PATCH 14/17] remove a stale comment; refactor AttrHints.hasHints for clarity and efficiency --- src/extensions/default/HTMLCodeHints/main.js | 48 ++++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/extensions/default/HTMLCodeHints/main.js b/src/extensions/default/HTMLCodeHints/main.js index dc84ba393d5..eaeb81ca5d5 100644 --- a/src/extensions/default/HTMLCodeHints/main.js +++ b/src/extensions/default/HTMLCodeHints/main.js @@ -107,7 +107,6 @@ define(function (require, exports, module) { return key; } }).sort(); - // TODO: better sorting. Should rank tags based on portion of query that is present in tag return { hints: result, @@ -409,39 +408,40 @@ define(function (require, exports, module) { query = null; tokenType = tagInfo.position.tokenType; - if (tokenType === HTMLUtils.ATTR_NAME || tokenType === HTMLUtils.ATTR_VALUE) { + if (tokenType === HTMLUtils.ATTR_NAME) { if (tagInfo.position.offset >= 0) { - if (tokenType === HTMLUtils.ATTR_NAME) { - query = tagInfo.attr.name.slice(0, tagInfo.position.offset); - } else { - query = tagInfo.attr.value.slice(0, tagInfo.position.offset); - } - } else if (tokenType === HTMLUtils.ATTR_VALUE) { + query = tagInfo.attr.name.slice(0, tagInfo.position.offset); + } + } else if (tokenType === HTMLUtils.ATTR_VALUE) { + if (tagInfo.position.offset >= 0) { + query = tagInfo.attr.value.slice(0, tagInfo.position.offset); + } else { // We get negative offset for a quoted attribute value with some leading whitespaces // as in
    Date: Wed, 19 Dec 2012 12:27:55 -0800 Subject: [PATCH 15/17] instead of removing a broken unit test, add a new and better one --- src/extensions/default/HTMLCodeHints/unittests.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/extensions/default/HTMLCodeHints/unittests.js b/src/extensions/default/HTMLCodeHints/unittests.js index dcfff20db21..7cd85c1b495 100644 --- a/src/extensions/default/HTMLCodeHints/unittests.js +++ b/src/extensions/default/HTMLCodeHints/unittests.js @@ -183,6 +183,10 @@ define(function (require, exports, module) { verifyAttrHints(hintList); // expect no filtering }); + it("should NOT list hints to right of '=' sign on id attr", function () { + testEditor.setCursorPos({ line: 5, ch: 9 }); + expectNoHints(HTMLCodeHints.attrHintProvider); + }); it("should list hints to right of '=' sign", function () { testEditor.setCursorPos({ line: 2, ch: 12 }); expectHints(HTMLCodeHints.attrHintProvider); From c1069de98ecd03c6539d12b5259c2457144ce996 Mon Sep 17 00:00:00 2001 From: Ian Wehrman Date: Wed, 19 Dec 2012 14:16:04 -0800 Subject: [PATCH 16/17] simplify logic in handleChange --- src/editor/CodeHintManager.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index 1dc07079480..10a8bb7edab 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -474,10 +474,12 @@ define(function (require, exports, module) { * the lastChar. */ function handleChange(editor) { - if (!_inSession(editor) && lastChar) { - _beginSession(editor); - } else if (_inSession(editor)) { + if (_inSession(editor)) { _updateHintList(); + } else { + if (lastChar) { + _beginSession(editor); + } } } From d62715ca98d3e79d8fe16961849d35ca5f6a9429 Mon Sep 17 00:00:00 2001 From: Ian Wehrman Date: Wed, 19 Dec 2012 16:03:46 -0800 Subject: [PATCH 17/17] add more unit tests for the id attribute case --- src/extensions/default/HTMLCodeHints/unittests.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/extensions/default/HTMLCodeHints/unittests.js b/src/extensions/default/HTMLCodeHints/unittests.js index 7cd85c1b495..bf68e54581b 100644 --- a/src/extensions/default/HTMLCodeHints/unittests.js +++ b/src/extensions/default/HTMLCodeHints/unittests.js @@ -199,6 +199,12 @@ define(function (require, exports, module) { testEditor.setCursorPos({ line: 6, ch: 10 }); // cursor between space and = expectNoHints(HTMLCodeHints.attrHintProvider); }); + it("should NOT list hints to right of '=' sign with whitespace on id attr", function () { + testEditor.setCursorPos({ line: 6, ch: 11 }); // cursor between = and space + expectNoHints(HTMLCodeHints.attrHintProvider); + testEditor.setCursorPos({ line: 6, ch: 12 }); // cursor between space and ' + expectNoHints(HTMLCodeHints.attrHintProvider); + }); it("should list hints to right of '=' sign with whitespace", function () { testDocument.setText('