Skip to content

Commit 1f5d825

Browse files
peterflynnAJDBenner
authored andcommitted
Replace 'smart autocomplete' with a purpose-built QuickSearchField module.
Simplifies QuickOpen code, removing workarounds and allowing it to use the ModalBar autoClose option. Fixes bugs with arrow key handling, cleans up APIs, and more consistently highlights the item that pressing Enter will select.
1 parent 1d8d9b5 commit 1f5d825

9 files changed

Lines changed: 518 additions & 362 deletions

File tree

src/extensions/default/QuickOpenCSS/main.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,12 @@ define(function (require, exports, module) {
9393
}
9494

9595
/**
96-
* Select the selected item in the current document
96+
* Scroll top the selected item in the current document (unless no query string entered yet,
97+
* in which case the topmost list item is irrelevant)
9798
* @param {?SearchResult} selectedItem
9899
*/
99-
function itemFocus(selectedItem) {
100-
if (!selectedItem) {
100+
function itemFocus(selectedItem, query) {
101+
if (!selectedItem || query.length < 2) {
101102
return;
102103
}
103104
var selectorInfo = selectedItem.selectorInfo;
@@ -107,8 +108,8 @@ define(function (require, exports, module) {
107108
EditorManager.getCurrentFullEditor().setSelection(from, to, true);
108109
}
109110

110-
function itemSelect(selectedItem) {
111-
itemFocus(selectedItem);
111+
function itemSelect(selectedItem, query) {
112+
itemFocus(selectedItem, query);
112113
}
113114

114115

src/extensions/default/QuickOpenHTML/main.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,12 @@ define(function (require, exports, module) {
128128

129129

130130
/**
131-
* Select the selected item in the current document
131+
* Scroll top the selected item in the current document (unless no query string entered yet,
132+
* in which case the topmost list item is irrelevant)
132133
* @param {?SearchResult} selectedItem
133134
*/
134-
function itemFocus(selectedItem) {
135-
if (!selectedItem) {
135+
function itemFocus(selectedItem, query) {
136+
if (!selectedItem || query.length < 2) {
136137
return;
137138
}
138139
var fileLocation = selectedItem.fileLocation;
@@ -142,8 +143,8 @@ define(function (require, exports, module) {
142143
EditorManager.getCurrentFullEditor().setSelection(from, to, true);
143144
}
144145

145-
function itemSelect(selectedItem) {
146-
itemFocus(selectedItem);
146+
function itemSelect(selectedItem, query) {
147+
itemFocus(selectedItem, query);
147148
}
148149

149150

src/extensions/default/QuickOpenJavaScript/main.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,12 @@ define(function (require, exports, module) {
121121
}
122122

123123
/**
124-
* Select the selected item in the current document
124+
* Scroll top the selected item in the current document (unless no query string entered yet,
125+
* in which case the topmost list item is irrelevant)
125126
* @param {?SearchResult} selectedItem
126127
*/
127-
function itemFocus(selectedItem) {
128-
if (!selectedItem) {
128+
function itemFocus(selectedItem, query) {
129+
if (!selectedItem || query.length < 2) {
129130
return;
130131
}
131132
var fileLocation = selectedItem.fileLocation;
@@ -135,8 +136,8 @@ define(function (require, exports, module) {
135136
EditorManager.getCurrentFullEditor().setSelection(from, to, true);
136137
}
137138

138-
function itemSelect(selectedItem) {
139-
itemFocus(selectedItem);
139+
function itemSelect(selectedItem, query) {
140+
itemFocus(selectedItem, query);
140141
}
141142

142143

src/search/QuickOpen.js

Lines changed: 141 additions & 328 deletions
Large diffs are not rendered by default.

src/search/QuickSearchField.js

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
/*
2+
* Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a
5+
* copy of this software and associated documentation files (the "Software"),
6+
* to deal in the Software without restriction, including without limitation
7+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
8+
* and/or sell copies of the Software, and to permit persons to whom the
9+
* Software is furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19+
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20+
* DEALINGS IN THE SOFTWARE.
21+
*
22+
*/
23+
24+
/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */
25+
/*global define, $, window, setTimeout */
26+
27+
28+
/*
29+
* Text field with attached dropdown list that is updated (based on a provider) whenever the text changes.
30+
*
31+
* For styling, the DOM structure of the popup is as follows:
32+
* body
33+
* ol.quick-search-container
34+
* li
35+
* li.highlight
36+
* li
37+
* And the text field is:
38+
* input
39+
* input.no-results
40+
*
41+
* BUGS to verify:
42+
* #1855, #1384
43+
*/
44+
define(function (require, exports, module) {
45+
"use strict";
46+
47+
var KeyEvent = require("utils/KeyEvent");
48+
49+
50+
/**
51+
* Attaches to an existing <input> tag
52+
* @param {!jQueryObject} $input
53+
* @param {!function(string):($.Promise|Array.<*>|{error:?string}} options.resultProvider
54+
* Given the current search text, returns an an array of result objects, an error object, or a
55+
* Promise that yields one of those. If the Promise is still outstanding when the query next
56+
* changes, resultProvider() will be called again (without waiting for the earlier Promise), and
57+
* the Promise's result will be ignored.
58+
* If the provider yields [], or a non-null error string, input is decorated with ".no-results"; if
59+
* the provider yields a null error string, input is not decorated.
60+
*
61+
* @param {!function(*, string):string} options.formatter
62+
* Converts one result object to a string of HTML text. Passed the item and the current query.
63+
* @param {!function(*, string):void} options.onCommit
64+
* Called when an item is selected by clicking or pressing Enter. Passed the item and the current
65+
* query. If the current result list is not up to date with the query text at the time Enter is
66+
* pressed, waits until it is before running this callback. The popup remains open after this event.
67+
* @param {!function(*, string):void} options.onHighlight
68+
* Called when an item is highlighted via the arrow keys. Passed the item and the current query.
69+
* Always called once with the top item in the result list, each time the list is updated (because
70+
* the top item is always initially highlighted).
71+
* TODO: only if query not blank - fix IN THE HANDLER by no-oping if query.length===1 !!!
72+
* @param {?number} options.maxResults
73+
* Maximum number of items from resultProvider() to display in the popup.
74+
* @param {?number} options.verticalAdjust
75+
* Number of pixels to position the popup below where $input is when constructor is called. Useful
76+
* if UI is going to animate position after construction, but QuickSearchField may receive input
77+
* before the animation is done.
78+
*/
79+
function QuickSearchField($input, options) {
80+
this.$input = $input;
81+
this.options = options;
82+
83+
this._handleInput = this._handleInput.bind(this);
84+
this._handleKeyDown = this._handleKeyDown.bind(this);
85+
86+
$input.on("input", this._handleInput);
87+
$input.on("keydown", this._handleKeyDown);
88+
89+
this._dropdownTop = $input.offset().top + $input.height() + (options.verticalAdjust || 0);
90+
}
91+
92+
/** @type {!Object} */
93+
QuickSearchField.prototype.options = null;
94+
95+
/** @type {?$.Promise} */
96+
QuickSearchField.prototype._pending = null;
97+
98+
/** @type {boolean} */
99+
QuickSearchField.prototype._commitPending = false;
100+
101+
/** @type {?string} */
102+
QuickSearchField.prototype._displayedQuery = null;
103+
104+
/** @type {?Array.<*>} */
105+
QuickSearchField.prototype._displayedResults = null;
106+
107+
/** @type {?number} */
108+
QuickSearchField.prototype._highlightIndex = null;
109+
110+
/** @type {?jQueryObject} */
111+
QuickSearchField.prototype._$dropdown = null;
112+
113+
/** @type {!jQueryObject} */
114+
QuickSearchField.prototype.$input = null; // TODO: _ prefix?
115+
116+
/**
117+
*/
118+
QuickSearchField.prototype._handleInput = function () {
119+
this._pending = null; // immediately invalidate any previous Promise
120+
121+
var valueAtEvent = this.$input.val();
122+
var self = this;
123+
setTimeout(function () {
124+
if (self.$input.val() === valueAtEvent) {
125+
self.updateResults();
126+
}
127+
}, 0);
128+
};
129+
130+
/**
131+
*/
132+
QuickSearchField.prototype._handleKeyDown = function (event) {
133+
if (event.keyCode === KeyEvent.DOM_VK_RETURN) {
134+
if (this._displayedQuery === this.$input.val()) {
135+
this._doCommit();
136+
} else {
137+
// Once the current wait resolves, _render() will run the commit
138+
this._commitPending = true;
139+
}
140+
} else if (event.keyCode === KeyEvent.DOM_VK_DOWN) {
141+
// Highlight changes are always done synchronously on the currently shown result list. If the list
142+
// later changes, the highlight is reset
143+
if (this._displayedResults && this._displayedResults.length) {
144+
if (this._highlightIndex === null || this._highlightIndex === this._displayedResults.length - 1) {
145+
this._highlightIndex = 0;
146+
} else {
147+
this._highlightIndex++;
148+
}
149+
this._updateHighlight();
150+
}
151+
event.preventDefault(); // treated as Home key otherwise
152+
153+
} else if (event.keyCode === KeyEvent.DOM_VK_UP) {
154+
if (this._displayedResults && this._displayedResults.length) {
155+
if (this._highlightIndex === null || this._highlightIndex === 0) {
156+
this._highlightIndex = this._displayedResults.length - 1;
157+
} else {
158+
this._highlightIndex--;
159+
}
160+
this._updateHighlight();
161+
}
162+
event.preventDefault(); // treated as End key otherwise
163+
}
164+
};
165+
QuickSearchField.prototype._doCommit = function (clickedIndex) {
166+
if (this._displayedResults && this._displayedResults.length) {
167+
var committedIndex = clickedIndex !== undefined ? clickedIndex : (this._highlightIndex || 0);
168+
this.options.onCommit(this._displayedResults[committedIndex], this._displayedQuery);
169+
}
170+
};
171+
QuickSearchField.prototype._updateHighlight = function () {
172+
var $items = this._$dropdown.find("li");
173+
$items.removeClass("highlight");
174+
if (this._highlightIndex !== null) {
175+
$items.eq(this._highlightIndex).addClass("highlight");
176+
this.options.onHighlight(this._displayedResults[this._highlightIndex], this.$input.val());
177+
}
178+
};
179+
180+
/**
181+
* Refresh the results dropdown, as if the user had changed the search text. Useful for providers that
182+
* want to show cached data initially, then update the results with fresher data once available.
183+
*/
184+
QuickSearchField.prototype.updateResults = function () {
185+
this._pending = null; // immediately invalidate any previous Promise
186+
187+
var query = this.$input.val();
188+
var results = this.options.resultProvider(query);
189+
if (results.done && results.fail) {
190+
this._pending = results;
191+
var self = this;
192+
this._pending.done(function (realResults) {
193+
if (self._pending === results) {
194+
self._render(realResults, query);
195+
this._pending = null;
196+
}
197+
});
198+
this._pending.fail(function () {
199+
if (self._pending === results) {
200+
self._render([], query);
201+
this._pending = null;
202+
}
203+
});
204+
} else {
205+
this._render(results, query);
206+
}
207+
};
208+
209+
QuickSearchField.prototype._$dropdown = null;
210+
QuickSearchField.prototype._closeDropdown = function () {
211+
if (this._$dropdown) {
212+
this._$dropdown.remove();
213+
this._$dropdown = null;
214+
}
215+
};
216+
QuickSearchField.prototype._openDropdown = function (htmlContent) {
217+
if (!this._$dropdown) {
218+
var self = this;
219+
this._$dropdown = $("<ol class='quick-search-container'/>").appendTo("body")
220+
.css({
221+
position: "absolute",
222+
top: this._dropdownTop,
223+
left: this.$input.offset().left,
224+
width: this.$input.width()
225+
})
226+
.click(function (event) {
227+
// Unlike the Enter key, where we wait to catch up with typing, clicking commits immediately
228+
var $item = $(event.target).closest("li");
229+
if ($item.length) {
230+
self._doCommit($item.index());
231+
}
232+
});
233+
}
234+
this._$dropdown.html(htmlContent);
235+
};
236+
// QuickSearchField.prototype._setExtraDropdownCSS = function (cssProps) {
237+
// };
238+
239+
QuickSearchField.prototype._render = function (results, query) {
240+
this._displayedQuery = query;
241+
this._displayedResults = results;
242+
this._highlightIndex = 0;
243+
// TODO: fixup to match prev value's item if possible?
244+
// (Sublime moves arrowed highlight to stay on same item as list is filters, as long as it's
245+
// still visible; then jumps back to top when not. Do we need a equals(*, *):boolean to track this?)
246+
247+
if (results.error || results.length === 0) {
248+
this._closeDropdown();
249+
this.$input.addClass("no-results");
250+
} else if (results.hasOwnProperty("error")) {
251+
this._closeDropdown();
252+
this.$input.removeClass("no-results");
253+
} else {
254+
this.$input.removeClass("no-results");
255+
256+
var count = Math.min(results.length, this.options.maxResults),
257+
html = "",
258+
i;
259+
for (i = 0; i < count; i++) {
260+
html += this.options.formatter(results[i], query);
261+
}
262+
this._openDropdown(html);
263+
264+
// Highlight top item and trigger highlight callback
265+
// TODO: if we had equals(), could avoid running callback when topmost item is same as before
266+
// (though master doesn't do this either)
267+
this._updateHighlight();
268+
}
269+
270+
if (this._commitPending) {
271+
this._commitPending = false;
272+
this._doCommit();
273+
}
274+
};
275+
276+
/**
277+
* Programmatically changes the search text and updates the results.
278+
* FIXME: is this needed if we listen for "input" events?
279+
*/
280+
QuickSearchField.prototype.setText = function (value) {
281+
this.$input.val(value);
282+
this.updateResults();
283+
};
284+
285+
/**
286+
* Returns the currently highlighted item. Returns null if there is no result popup open (i.e. no text has
287+
* been entered yet; the provider returned zero results; or one of those was previously true and we are
288+
* still waiting for the provider to return newer results).
289+
*/
290+
QuickSearchField.prototype.getSelectedItem = function () {
291+
};
292+
293+
/**
294+
* Closes the dropdown, and discards any pending Promises.
295+
*/
296+
QuickSearchField.prototype.destroy = function () {
297+
this._pending = null; // immediately invalidate any pending Promise
298+
this._closeDropdown();
299+
};
300+
301+
302+
exports.QuickSearchField = QuickSearchField;
303+
});

0 commit comments

Comments
 (0)