1845 lines
61 KiB
JavaScript
Executable File
1845 lines
61 KiB
JavaScript
Executable File
// This is the annotated source code for
|
|
// [VisualSearch.js](http://documentcloud.github.com/visualsearch/),
|
|
// a rich search box for real data.
|
|
//
|
|
// The annotated source HTML is generated by
|
|
// [Docco](http://jashkenas.github.com/docco/).
|
|
|
|
/** @license VisualSearch.js 0.2.2
|
|
* (c) 2011 Samuel Clay, @samuelclay, DocumentCloud Inc.
|
|
* VisualSearch.js may be freely distributed under the MIT license.
|
|
* For all details and documentation:
|
|
* http://documentcloud.github.com/visualsearch
|
|
*/
|
|
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// Setting up VisualSearch globals. These will eventually be made instance-based.
|
|
if (!window.VS) window.VS = {};
|
|
if (!VS.app) VS.app = {};
|
|
if (!VS.ui) VS.ui = {};
|
|
if (!VS.model) VS.model = {};
|
|
if (!VS.utils) VS.utils = {};
|
|
|
|
// Sets the version for VisualSearch to be used programatically elsewhere.
|
|
VS.VERSION = '0.2.2';
|
|
|
|
VS.VisualSearch = function(options) {
|
|
var defaults = {
|
|
container : '',
|
|
query : '',
|
|
unquotable : [],
|
|
callbacks : {
|
|
search : $.noop,
|
|
focus : $.noop,
|
|
blur : $.noop,
|
|
facetMatches : $.noop,
|
|
valueMatches : $.noop
|
|
}
|
|
};
|
|
this.options = _.extend({}, defaults, options);
|
|
this.options.callbacks = _.extend({}, defaults.callbacks, options.callbacks);
|
|
|
|
VS.app.hotkeys.initialize();
|
|
this.searchQuery = new VS.model.SearchQuery();
|
|
this.searchBox = new VS.ui.SearchBox({app: this});
|
|
|
|
if (options.container) {
|
|
var searchBox = this.searchBox.render().el;
|
|
$(this.options.container).html(searchBox);
|
|
}
|
|
this.searchBox.value(this.options.query || '');
|
|
|
|
// Disable page caching for browsers that incorrectly cache the visual search inputs.
|
|
// This is forced the browser to re-render the page when it is retrieved in its history.
|
|
$(window).bind('unload', function(e) {});
|
|
|
|
// Gives the user back a reference to the `searchBox` so they
|
|
// can use public methods.
|
|
return this;
|
|
};
|
|
|
|
// Entry-point used to tie all parts of VisualSearch together. It will either attach
|
|
// itself to `options.container`, or pass back the `searchBox` so it can be rendered
|
|
// at will.
|
|
VS.init = function(options) {
|
|
return new VS.VisualSearch(options);
|
|
};
|
|
|
|
})();
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// The search box is responsible for managing the many facet views and input views.
|
|
VS.ui.SearchBox = Backbone.View.extend({
|
|
|
|
id : 'search',
|
|
|
|
events : {
|
|
'click .VS-cancel-search-box' : 'clearSearch',
|
|
'mousedown .VS-search-box' : 'maybeFocusSearch',
|
|
'dblclick .VS-search-box' : 'highlightSearch',
|
|
'click .VS-search-box' : 'maybeTripleClick'
|
|
},
|
|
|
|
// Creating a new SearchBox registers handlers for re-rendering facets when necessary,
|
|
// as well as handling typing when a facet is selected.
|
|
initialize : function() {
|
|
this.app = this.options.app;
|
|
this.flags = {
|
|
allSelected : false
|
|
};
|
|
this.facetViews = [];
|
|
this.inputViews = [];
|
|
_.bindAll(this, 'renderFacets', '_maybeDisableFacets', 'disableFacets',
|
|
'deselectAllFacets', 'addedFacet', 'removedFacet', 'changedFacet');
|
|
this.app.searchQuery
|
|
.bind('reset', this.renderFacets)
|
|
.bind('add', this.addedFacet)
|
|
.bind('remove', this.removedFacet)
|
|
.bind('change', this.changedFacet);
|
|
$(document).bind('keydown', this._maybeDisableFacets);
|
|
},
|
|
|
|
// Renders the search box, but requires placement on the page through `this.el`.
|
|
render : function() {
|
|
$(this.el).append(JST['search_box']({}));
|
|
$(document.body).setMode('no', 'search');
|
|
|
|
return this;
|
|
},
|
|
|
|
// # Querying Facets #
|
|
|
|
// Either gets a serialized query string or sets the faceted query from a query string.
|
|
value : function(query) {
|
|
if (query == null) return this.serialize();
|
|
return this.setQuery(query);
|
|
},
|
|
|
|
// Uses the VS.app.searchQuery collection to serialize the current query from the various
|
|
// facets that are in the search box.
|
|
serialize : function() {
|
|
var query = [];
|
|
var inputViewsCount = this.inputViews.length;
|
|
|
|
this.app.searchQuery.each(_.bind(function(facet, i) {
|
|
query.push(this.inputViews[i].value());
|
|
query.push(facet.serialize());
|
|
}, this));
|
|
|
|
if (inputViewsCount) {
|
|
query.push(this.inputViews[inputViewsCount-1].value());
|
|
}
|
|
|
|
return _.compact(query).join(' ');
|
|
},
|
|
|
|
// Takes a query string and uses the SearchParser to parse and render it. Note that
|
|
// `VS.app.SearchParser` refreshes the `VS.app.searchQuery` collection, which is bound
|
|
// here to call `this.renderFacets`.
|
|
setQuery : function(query) {
|
|
this.currentQuery = query;
|
|
VS.app.SearchParser.parse(this.app, query);
|
|
},
|
|
|
|
// Returns the position of a facet/input view. Useful when moving between facets.
|
|
viewPosition : function(view) {
|
|
var views = view.type == 'facet' ? this.facetViews : this.inputViews;
|
|
var position = _.indexOf(views, view);
|
|
if (position == -1) position = 0;
|
|
return position;
|
|
},
|
|
|
|
// Used to launch a search. Hitting enter or clicking the search button.
|
|
searchEvent : function(e) {
|
|
var query = this.value();
|
|
this.focusSearch(e);
|
|
this.value(query);
|
|
this.app.options.callbacks.search(query, this.app.searchQuery);
|
|
},
|
|
|
|
// # Rendering Facets #
|
|
|
|
// Add a new facet. Facet will be focused and ready to accept a value. Can also
|
|
// specify position, in the case of adding facets from an inbetween input.
|
|
addFacet : function(category, initialQuery, position) {
|
|
category = VS.utils.inflector.trim(category);
|
|
initialQuery = VS.utils.inflector.trim(initialQuery || '');
|
|
if (!category) return;
|
|
|
|
var model = new VS.model.SearchFacet({
|
|
category : category,
|
|
value : initialQuery || '',
|
|
app : this.app
|
|
});
|
|
this.app.searchQuery.add(model, {at: position});
|
|
},
|
|
|
|
// Renders a newly added facet, and selects it.
|
|
addedFacet : function (model) {
|
|
this.renderFacets();
|
|
var facetView = _.detect(this.facetViews, function(view) {
|
|
if (view.model == model) return true;
|
|
});
|
|
|
|
_.defer(function() {
|
|
facetView.enableEdit();
|
|
});
|
|
},
|
|
|
|
// Changing a facet programmatically re-renders it.
|
|
changedFacet: function () {
|
|
this.renderFacets();
|
|
},
|
|
|
|
// When removing a facet, potentially do something. For now, the adjacent
|
|
// remaining facet is selected, but this is handled by the facet's view,
|
|
// since its position is unknown by the time the collection triggers this
|
|
// remove callback.
|
|
removedFacet : function (facet, query, options) {},
|
|
|
|
// Renders each facet as a searchFacet view.
|
|
renderFacets : function() {
|
|
this.facetViews = [];
|
|
this.inputViews = [];
|
|
|
|
this.$('.VS-search-inner').empty();
|
|
|
|
this.app.searchQuery.each(_.bind(function(facet, i) {
|
|
this.renderFacet(facet, i);
|
|
}, this));
|
|
|
|
// Add on an n+1 empty search input on the very end.
|
|
this.renderSearchInput();
|
|
},
|
|
|
|
// Render a single facet, using its category and query value.
|
|
renderFacet : function(facet, position) {
|
|
var view = new VS.ui.SearchFacet({
|
|
app : this.app,
|
|
model : facet,
|
|
order : position
|
|
});
|
|
|
|
// Input first, facet second.
|
|
this.renderSearchInput();
|
|
this.facetViews.push(view);
|
|
this.$('.VS-search-inner').children().eq(position*2).after(view.render().el);
|
|
|
|
view.calculateSize();
|
|
_.defer(_.bind(view.calculateSize, view));
|
|
|
|
return view;
|
|
},
|
|
|
|
// Render a single input, used to create and autocomplete facets
|
|
renderSearchInput : function() {
|
|
var input = new VS.ui.SearchInput({position: this.inputViews.length, app: this.app});
|
|
this.$('.VS-search-inner').append(input.render().el);
|
|
this.inputViews.push(input);
|
|
},
|
|
|
|
// # Modifying Facets #
|
|
|
|
// Clears out the search box. Command+A + delete can trigger this, as can a cancel button.
|
|
//
|
|
// If a `clearSearch` callback was provided, the callback is invoked and
|
|
// provided with a function performs the actual removal of the data. This
|
|
// allows third-party developers to either clear data asynchronously, or
|
|
// prior to performing their custom "clear" logic.
|
|
clearSearch : function(e) {
|
|
var actualClearSearch = _.bind(function() {
|
|
this.disableFacets();
|
|
this.value('');
|
|
this.flags.allSelected = false;
|
|
this.searchEvent(e);
|
|
this.focusSearch(e);
|
|
}, this);
|
|
|
|
if (this.app.options.callbacks.clearSearch) {
|
|
this.app.options.callbacks.clearSearch(actualClearSearch);
|
|
} else {
|
|
actualClearSearch();
|
|
}
|
|
},
|
|
|
|
// Command+A selects all facets.
|
|
selectAllFacets : function() {
|
|
this.flags.allSelected = true;
|
|
|
|
$(document).one('click.selectAllFacets', this.deselectAllFacets);
|
|
|
|
_.each(this.facetViews, function(facetView, i) {
|
|
facetView.selectFacet();
|
|
});
|
|
_.each(this.inputViews, function(inputView, i) {
|
|
inputView.selectText();
|
|
});
|
|
},
|
|
|
|
// Used by facets and input to see if all facets are currently selected.
|
|
allSelected : function(deselect) {
|
|
if (deselect) this.flags.allSelected = false;
|
|
return this.flags.allSelected;
|
|
},
|
|
|
|
// After `selectAllFacets` is engaged, this method is bound to the entire document.
|
|
// This immediate disables and deselects all facets, but it also checks if the user
|
|
// has clicked on either a facet or an input, and properly selects the view.
|
|
deselectAllFacets : function(e) {
|
|
this.disableFacets();
|
|
|
|
if (this.$(e.target).is('.category,input')) {
|
|
var el = $(e.target).closest('.search_facet,.search_input');
|
|
var view = _.detect(this.facetViews.concat(this.inputViews), function(v) {
|
|
return v.el == el[0];
|
|
});
|
|
if (view.type == 'facet') {
|
|
view.selectFacet();
|
|
} else if (view.type == 'input') {
|
|
_.defer(function() {
|
|
view.enableEdit(true);
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
// Disables all facets except for the passed in view. Used when switching between
|
|
// facets, so as not to have to keep state of active facets.
|
|
disableFacets : function(keepView) {
|
|
_.each(this.inputViews, function(view) {
|
|
if (view && view != keepView &&
|
|
(view.modes.editing == 'is' || view.modes.selected == 'is')) {
|
|
view.disableEdit();
|
|
}
|
|
});
|
|
_.each(this.facetViews, function(view) {
|
|
if (view && view != keepView &&
|
|
(view.modes.editing == 'is' || view.modes.selected == 'is')) {
|
|
view.disableEdit();
|
|
view.deselectFacet();
|
|
}
|
|
});
|
|
|
|
this.flags.allSelected = false;
|
|
this.removeFocus();
|
|
$(document).unbind('click.selectAllFacets');
|
|
},
|
|
|
|
// Resize all inputs to account for extra keystrokes which may be changing the facet
|
|
// width incorrectly. This is a safety check to ensure inputs are correctly sized.
|
|
resizeFacets : function(view) {
|
|
_.each(this.facetViews, function(facetView, i) {
|
|
if (!view || facetView == view) {
|
|
facetView.resize();
|
|
}
|
|
});
|
|
},
|
|
|
|
// Handles keydown events on the document. Used to complete the Cmd+A deletion, and
|
|
// blurring focus.
|
|
_maybeDisableFacets : function(e) {
|
|
if (this.flags.allSelected && VS.app.hotkeys.key(e) == 'backspace') {
|
|
e.preventDefault();
|
|
this.clearSearch(e);
|
|
return false;
|
|
} else if (this.flags.allSelected && VS.app.hotkeys.printable(e)) {
|
|
this.clearSearch(e);
|
|
}
|
|
},
|
|
|
|
// # Focusing Facets #
|
|
|
|
// Move focus between facets and inputs. Takes a direction as well as many options
|
|
// for skipping over inputs and only to facets, placement of cursor position in facet
|
|
// (i.e. at the end), and selecting the text in the input/facet.
|
|
focusNextFacet : function(currentView, direction, options) {
|
|
options = options || {};
|
|
var viewCount = this.facetViews.length;
|
|
var viewPosition = options.viewPosition || this.viewPosition(currentView);
|
|
|
|
if (!options.skipToFacet) {
|
|
// Correct for bouncing between matching text and facet arrays.
|
|
if (currentView.type == 'text' && direction > 0) direction -= 1;
|
|
if (currentView.type == 'facet' && direction < 0) direction += 1;
|
|
} else if (options.skipToFacet && currentView.type == 'text' &&
|
|
viewCount == viewPosition && direction >= 0) {
|
|
// Special case of looping around to a facet from the last search input box.
|
|
viewPosition = 0;
|
|
direction = 0;
|
|
}
|
|
var view, next = Math.min(viewCount, viewPosition + direction);
|
|
|
|
if (currentView.type == 'text') {
|
|
if (next >= 0 && next < viewCount) {
|
|
view = this.facetViews[next];
|
|
} else if (next == viewCount) {
|
|
view = this.inputViews[this.inputViews.length-1];
|
|
}
|
|
if (view && options.selectFacet && view.type == 'facet') {
|
|
view.selectFacet();
|
|
} else if (view) {
|
|
view.enableEdit();
|
|
view.setCursorAtEnd(direction || options.startAtEnd);
|
|
}
|
|
} else if (currentView.type == 'facet') {
|
|
if (options.skipToFacet) {
|
|
if (next >= viewCount || next < 0) {
|
|
view = _.last(this.inputViews);
|
|
view.enableEdit();
|
|
} else {
|
|
view = this.facetViews[next];
|
|
view.enableEdit();
|
|
view.setCursorAtEnd(direction || options.startAtEnd);
|
|
}
|
|
} else {
|
|
view = this.inputViews[next];
|
|
view.enableEdit();
|
|
}
|
|
}
|
|
if (options.selectText) view.selectText();
|
|
this.resizeFacets();
|
|
},
|
|
|
|
maybeFocusSearch : function(e) {
|
|
if ($(e.target).is('.VS-search-box') ||
|
|
$(e.target).is('.VS-search-inner') ||
|
|
e.type == 'keydown') {
|
|
this.focusSearch(e);
|
|
}
|
|
},
|
|
|
|
// Bring focus to last input field.
|
|
focusSearch : function(e, selectText) {
|
|
var view = this.inputViews[this.inputViews.length-1];
|
|
view.enableEdit(selectText);
|
|
if (!selectText) view.setCursorAtEnd(-1);
|
|
if (e.type == 'keydown') {
|
|
view.keydown(e);
|
|
view.box.trigger('keydown');
|
|
}
|
|
_.defer(_.bind(function() {
|
|
if (!this.$('input:focus').length) {
|
|
view.enableEdit(selectText);
|
|
}
|
|
}, this));
|
|
},
|
|
|
|
// Double-clicking on the search wrapper should select the existing text in
|
|
// the last search input. Also start the triple-click timer.
|
|
highlightSearch : function(e) {
|
|
if ($(e.target).is('.VS-search-box') ||
|
|
$(e.target).is('.VS-search-inner') ||
|
|
e.type == 'keydown') {
|
|
var lastinput = this.inputViews[this.inputViews.length-1];
|
|
lastinput.startTripleClickTimer();
|
|
this.focusSearch(e, true);
|
|
}
|
|
},
|
|
|
|
maybeTripleClick : function(e) {
|
|
var lastinput = this.inputViews[this.inputViews.length-1];
|
|
return lastinput.maybeTripleClick(e);
|
|
},
|
|
|
|
// Used to show the user is focused on some input inside the search box.
|
|
addFocus : function() {
|
|
this.app.options.callbacks.focus();
|
|
this.$('.VS-search-box').addClass('VS-focus');
|
|
},
|
|
|
|
// User is no longer focused on anything in the search box.
|
|
removeFocus : function() {
|
|
this.app.options.callbacks.blur();
|
|
var focus = _.any(this.facetViews.concat(this.inputViews), function(view) {
|
|
return view.isFocused();
|
|
});
|
|
if (!focus) this.$('.VS-search-box').removeClass('VS-focus');
|
|
},
|
|
|
|
// Show a menu which adds pre-defined facets to the search box. This is unused for now.
|
|
showFacetCategoryMenu : function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (this.facetCategoryMenu && this.facetCategoryMenu.modes.open == 'is') {
|
|
return this.facetCategoryMenu.close();
|
|
}
|
|
|
|
var items = [
|
|
{title: 'Account', onClick: _.bind(this.addFacet, this, 'account', '')},
|
|
{title: 'Project', onClick: _.bind(this.addFacet, this, 'project', '')},
|
|
{title: 'Filter', onClick: _.bind(this.addFacet, this, 'filter', '')},
|
|
{title: 'Access', onClick: _.bind(this.addFacet, this, 'access', '')}
|
|
];
|
|
|
|
var menu = this.facetCategoryMenu || (this.facetCategoryMenu = new dc.ui.Menu({
|
|
items : items,
|
|
standalone : true
|
|
}));
|
|
|
|
this.$('.VS-icon-search').after(menu.render().open().content);
|
|
return false;
|
|
}
|
|
|
|
});
|
|
|
|
})();
|
|
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// This is the visual search facet that holds the category and its autocompleted
|
|
// input field.
|
|
VS.ui.SearchFacet = Backbone.View.extend({
|
|
|
|
type : 'facet',
|
|
|
|
className : 'search_facet',
|
|
|
|
events : {
|
|
'click .category' : 'selectFacet',
|
|
'keydown input' : 'keydown',
|
|
'mousedown input' : 'enableEdit',
|
|
'mouseover .VS-icon-cancel' : 'showDelete',
|
|
'mouseout .VS-icon-cancel' : 'hideDelete',
|
|
'click .VS-icon-cancel' : 'remove'
|
|
},
|
|
|
|
initialize : function(options) {
|
|
this.flags = {
|
|
canClose : false
|
|
};
|
|
_.bindAll(this, 'set', 'keydown', 'deselectFacet', 'deferDisableEdit');
|
|
},
|
|
|
|
// Rendering the facet sets up autocompletion, events on blur, and populates
|
|
// the facet's input with its starting value.
|
|
render : function() {
|
|
$(this.el).html(JST['search_facet']({
|
|
model : this.model
|
|
}));
|
|
|
|
this.setMode('not', 'editing');
|
|
this.setMode('not', 'selected');
|
|
this.box = this.$('input');
|
|
this.box.val(this.model.get('value'));
|
|
this.box.bind('blur', this.deferDisableEdit);
|
|
// Handle paste events with `propertychange`
|
|
this.box.bind('input propertychange', this.keydown);
|
|
this.setupAutocomplete();
|
|
|
|
return this;
|
|
},
|
|
|
|
// This method is used to setup the facet's input to auto-grow.
|
|
// This is defered in the searchBox so it can be attached to the
|
|
// DOM to get the correct font-size.
|
|
calculateSize : function() {
|
|
this.box.autoGrowInput();
|
|
this.box.unbind('updated.autogrow');
|
|
this.box.bind('updated.autogrow', _.bind(this.moveAutocomplete, this));
|
|
},
|
|
|
|
// Forces a recalculation of this facet's input field's value. Called when
|
|
// the facet is focused, removed, or otherwise modified.
|
|
resize : function(e) {
|
|
this.box.trigger('resize.autogrow', e);
|
|
},
|
|
|
|
// Watches the facet's input field to see if it matches the beginnings of
|
|
// words in `autocompleteValues`, which is different for every category.
|
|
// If the value, when selected from the autocompletion menu, is different
|
|
// than what it was, commit the facet and search for it.
|
|
setupAutocomplete : function() {
|
|
this.box.autocomplete({
|
|
source : _.bind(this.autocompleteValues, this),
|
|
minLength : 0,
|
|
delay : 0,
|
|
autoFocus : true,
|
|
position : {offset : "0 5"},
|
|
create : _.bind(function(e, ui) {
|
|
$(this.el).find('.ui-autocomplete-input').css('z-index','auto');
|
|
}, this),
|
|
select : _.bind(function(e, ui) {
|
|
e.preventDefault();
|
|
var originalValue = this.model.get('value');
|
|
this.set(ui.item.value);
|
|
if (originalValue != ui.item.value || this.box.val() != ui.item.value) {
|
|
this.search(e);
|
|
}
|
|
return false;
|
|
}, this),
|
|
open : _.bind(function(e, ui) {
|
|
var box = this.box;
|
|
this.box.autocomplete('widget').find('.ui-menu-item').each(function() {
|
|
var $value = $(this);
|
|
if ($value.data('item.autocomplete')['value'] == box.val()) {
|
|
box.data('autocomplete').menu.activate(new $.Event("mouseover"), $value);
|
|
}
|
|
});
|
|
}, this)
|
|
});
|
|
|
|
this.box.autocomplete('widget').addClass('VS-interface');
|
|
},
|
|
|
|
// As the facet's input field grows, it may move to the next line in the
|
|
// search box. `autoGrowInput` triggers an `updated` event on the input
|
|
// field, which is bound to this method to move the autocomplete menu.
|
|
moveAutocomplete : function() {
|
|
var autocomplete = this.box.data('autocomplete');
|
|
if (autocomplete) {
|
|
autocomplete.menu.element.position({
|
|
my : "left top",
|
|
at : "left bottom",
|
|
of : this.box.data('autocomplete').element,
|
|
collision : "flip",
|
|
offset : "0 5"
|
|
});
|
|
}
|
|
},
|
|
|
|
// When a user enters a facet and it is being edited, immediately show
|
|
// the autocomplete menu and size it to match the contents.
|
|
searchAutocomplete : function(e) {
|
|
var autocomplete = this.box.data('autocomplete');
|
|
if (autocomplete) {
|
|
var menu = autocomplete.menu.element;
|
|
autocomplete.search();
|
|
|
|
// Resize the menu based on the correctly measured width of what's bigger:
|
|
// the menu's original size or the menu items' new size.
|
|
menu.outerWidth(Math.max(
|
|
menu.width('').outerWidth(),
|
|
autocomplete.element.outerWidth()
|
|
));
|
|
}
|
|
},
|
|
|
|
// Closes the autocomplete menu. Called on disabling, selecting, deselecting,
|
|
// and anything else that takes focus out of the facet's input field.
|
|
closeAutocomplete : function() {
|
|
var autocomplete = this.box.data('autocomplete');
|
|
if (autocomplete) autocomplete.close();
|
|
},
|
|
|
|
// Search terms used in the autocomplete menu. These are specific to the facet,
|
|
// and only match for the facet's category. The values are then matched on the
|
|
// first letter of any word in matches, and finally sorted according to the
|
|
// value's own category. You can pass `preserveOrder` as an option in the
|
|
// `facetMatches` callback to skip any further ordering done client-side.
|
|
autocompleteValues : function(req, resp) {
|
|
var category = this.model.get('category');
|
|
var value = this.model.get('value');
|
|
var searchTerm = req.term;
|
|
|
|
this.options.app.options.callbacks.valueMatches(category, searchTerm, function(matches, options) {
|
|
options = options || {};
|
|
matches = matches || [];
|
|
|
|
if (searchTerm && value != searchTerm) {
|
|
if (options.preserveMatches) {
|
|
return matches;
|
|
} else {
|
|
var re = VS.utils.inflector.escapeRegExp(searchTerm || '');
|
|
var matcher = new RegExp('\\b' + re, 'i');
|
|
matches = $.grep(matches, function(item) {
|
|
return matcher.test(item) ||
|
|
matcher.test(item.value) ||
|
|
matcher.test(item.label);
|
|
});
|
|
}
|
|
}
|
|
|
|
if (options.preserveOrder) {
|
|
resp(matches);
|
|
} else {
|
|
resp(_.sortBy(matches, function(match) {
|
|
if (match == value || match.value == value) return '';
|
|
else return match;
|
|
}));
|
|
}
|
|
});
|
|
|
|
},
|
|
|
|
// Sets the facet's model's value.
|
|
set : function(value) {
|
|
if (!value) return;
|
|
this.model.set({'value': value});
|
|
},
|
|
|
|
// Before the searchBox performs a search, we need to close the
|
|
// autocomplete menu.
|
|
search : function(e, direction) {
|
|
if (!direction) direction = 1;
|
|
this.closeAutocomplete();
|
|
this.options.app.searchBox.searchEvent(e);
|
|
_.defer(_.bind(function() {
|
|
this.options.app.searchBox.focusNextFacet(this, direction, {viewPosition: this.options.order});
|
|
}, this));
|
|
},
|
|
|
|
// Begin editing the facet's input. This is called when the user enters
|
|
// the input either from another facet or directly clicking on it.
|
|
//
|
|
// This method tells all other facets and inputs to disable so it can have
|
|
// the sole focus. It also prepares the autocompletion menu.
|
|
enableEdit : function() {
|
|
if (this.modes.editing != 'is') {
|
|
this.setMode('is', 'editing');
|
|
this.deselectFacet();
|
|
if (this.box.val() == '') {
|
|
this.box.val(this.model.get('value'));
|
|
}
|
|
}
|
|
|
|
this.flags.canClose = false;
|
|
this.options.app.searchBox.disableFacets(this);
|
|
this.options.app.searchBox.addFocus();
|
|
_.defer(_.bind(function() {
|
|
this.options.app.searchBox.addFocus();
|
|
}, this));
|
|
this.resize();
|
|
this.searchAutocomplete();
|
|
this.box.focus();
|
|
},
|
|
|
|
// When the user blurs the input, they may either be going to another input
|
|
// or off the search box entirely. If they go to another input, this facet
|
|
// will be instantly disabled, and the canClose flag will be turned back off.
|
|
//
|
|
// However, if the user clicks elsewhere on the page, this method starts a timer
|
|
// that checks if any of the other inputs are selected or are being edited. If
|
|
// not, then it can finally close itself and its autocomplete menu.
|
|
deferDisableEdit : function() {
|
|
this.flags.canClose = true;
|
|
_.delay(_.bind(function() {
|
|
if (this.flags.canClose && !this.box.is(':focus') &&
|
|
this.modes.editing == 'is' && this.modes.selected != 'is') {
|
|
this.disableEdit();
|
|
}
|
|
}, this), 250);
|
|
},
|
|
|
|
// Called either by other facets receiving focus or by the timer in `deferDisableEdit`,
|
|
// this method will turn off the facet, remove any text selection, and close
|
|
// the autocomplete menu.
|
|
disableEdit : function() {
|
|
var newFacetQuery = VS.utils.inflector.trim(this.box.val());
|
|
if (newFacetQuery != this.model.get('value')) {
|
|
this.set(newFacetQuery);
|
|
}
|
|
this.flags.canClose = false;
|
|
this.box.selectRange(0, 0);
|
|
this.box.blur();
|
|
this.setMode('not', 'editing');
|
|
this.closeAutocomplete();
|
|
this.options.app.searchBox.removeFocus();
|
|
},
|
|
|
|
// Selects the facet, which blurs the facet's input and highlights the facet.
|
|
// If this is the only facet being selected (and not part of a select all event),
|
|
// we attach a mouse/keyboard watcher to check if the next action by the user
|
|
// should delete this facet or just deselect it.
|
|
selectFacet : function(e) {
|
|
if (e) e.preventDefault();
|
|
var allSelected = this.options.app.searchBox.allSelected();
|
|
if (this.modes.selected == 'is') return;
|
|
|
|
if (this.box.is(':focus')) {
|
|
this.box.setCursorPosition(0);
|
|
this.box.blur();
|
|
}
|
|
|
|
this.flags.canClose = false;
|
|
this.closeAutocomplete();
|
|
this.setMode('is', 'selected');
|
|
this.setMode('not', 'editing');
|
|
if (!allSelected || e) {
|
|
$(document).unbind('keydown.facet', this.keydown);
|
|
$(document).unbind('click.facet', this.deselectFacet);
|
|
_.defer(_.bind(function() {
|
|
$(document).unbind('keydown.facet').bind('keydown.facet', this.keydown);
|
|
$(document).unbind('click.facet').one('click.facet', this.deselectFacet);
|
|
}, this));
|
|
this.options.app.searchBox.disableFacets(this);
|
|
this.options.app.searchBox.addFocus();
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// Turns off highlighting on the facet. Called in a variety of ways, this
|
|
// only deselects the facet if it is selected, and then cleans up the
|
|
// keyboard/mouse watchers that were created when the facet was first
|
|
// selected.
|
|
deselectFacet : function(e) {
|
|
if (e) e.preventDefault();
|
|
if (this.modes.selected == 'is') {
|
|
this.setMode('not', 'selected');
|
|
this.closeAutocomplete();
|
|
this.options.app.searchBox.removeFocus();
|
|
}
|
|
$(document).unbind('keydown.facet', this.keydown);
|
|
$(document).unbind('click.facet', this.deselectFacet);
|
|
return false;
|
|
},
|
|
|
|
// Is the user currently focused in this facet's input field?
|
|
isFocused : function() {
|
|
return this.box.is(':focus');
|
|
},
|
|
|
|
// Hovering over the delete button styles the facet so the user knows that
|
|
// the delete button will kill the entire facet.
|
|
showDelete : function() {
|
|
$(this.el).addClass('search_facet_maybe_delete');
|
|
},
|
|
|
|
// On `mouseout`, the user is no longer hovering on the delete button.
|
|
hideDelete : function() {
|
|
$(this.el).removeClass('search_facet_maybe_delete');
|
|
},
|
|
|
|
// When switching between facets, depending on the direction the cursor is
|
|
// coming from, the cursor in this facet's input field should match the original
|
|
// direction.
|
|
setCursorAtEnd : function(direction) {
|
|
if (direction == -1) {
|
|
this.box.setCursorPosition(this.box.val().length);
|
|
} else {
|
|
this.box.setCursorPosition(0);
|
|
}
|
|
},
|
|
|
|
// Deletes the facet and sends the cursor over to the nearest input field.
|
|
remove : function(e) {
|
|
var committed = this.model.get('value');
|
|
this.deselectFacet();
|
|
this.disableEdit();
|
|
this.options.app.searchQuery.remove(this.model);
|
|
if (committed) {
|
|
this.search(e, -1);
|
|
} else {
|
|
this.options.app.searchBox.renderFacets();
|
|
this.options.app.searchBox.focusNextFacet(this, -1, {viewPosition: this.options.order});
|
|
}
|
|
},
|
|
|
|
// Selects the text in the facet's input field. When the user tabs between
|
|
// facets, convention is to highlight the entire field.
|
|
selectText: function() {
|
|
this.box.selectRange(0, this.box.val().length);
|
|
},
|
|
|
|
// Handles all keyboard inputs when in the facet's input field. This checks
|
|
// for movement between facets and inputs, entering a new value that needs
|
|
// to be autocompleted, as well as the removal of this facet.
|
|
keydown : function(e) {
|
|
var key = VS.app.hotkeys.key(e);
|
|
|
|
if (key == 'enter' && this.box.val()) {
|
|
this.disableEdit();
|
|
this.search(e);
|
|
} else if (key == 'left') {
|
|
if (this.modes.selected == 'is') {
|
|
this.deselectFacet();
|
|
this.options.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1});
|
|
} else if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) {
|
|
this.selectFacet();
|
|
}
|
|
} else if (key == 'right') {
|
|
if (this.modes.selected == 'is') {
|
|
e.preventDefault();
|
|
this.deselectFacet();
|
|
this.setCursorAtEnd(0);
|
|
this.enableEdit();
|
|
} else if (this.box.getCursorPosition() == this.box.val().length) {
|
|
e.preventDefault();
|
|
this.disableEdit();
|
|
this.options.app.searchBox.focusNextFacet(this, 1);
|
|
}
|
|
} else if (VS.app.hotkeys.shift && key == 'tab') {
|
|
e.preventDefault();
|
|
this.options.app.searchBox.focusNextFacet(this, -1, {
|
|
startAtEnd : -1,
|
|
skipToFacet : true,
|
|
selectText : true
|
|
});
|
|
} else if (key == 'tab') {
|
|
e.preventDefault();
|
|
this.options.app.searchBox.focusNextFacet(this, 1, {
|
|
skipToFacet : true,
|
|
selectText : true
|
|
});
|
|
} else if (VS.app.hotkeys.command && (e.which == 97 || e.which == 65)) {
|
|
e.preventDefault();
|
|
this.options.app.searchBox.selectAllFacets();
|
|
return false;
|
|
} else if (VS.app.hotkeys.printable(e) && this.modes.selected == 'is') {
|
|
this.options.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1});
|
|
this.remove(e);
|
|
} else if (key == 'backspace') {
|
|
if (this.modes.selected == 'is') {
|
|
e.preventDefault();
|
|
this.remove(e);
|
|
} else if (this.box.getCursorPosition() == 0 &&
|
|
!this.box.getSelection().length) {
|
|
e.preventDefault();
|
|
this.selectFacet();
|
|
}
|
|
}
|
|
|
|
this.resize(e);
|
|
|
|
// Handle paste events
|
|
if (e.which == null) {
|
|
this.searchAutocomplete(e);
|
|
_.defer(_.bind(this.resize, this, e));
|
|
}
|
|
}
|
|
|
|
});
|
|
|
|
})();
|
|
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// This is the visual search input that is responsible for creating new facets.
|
|
// There is one input placed in between all facets.
|
|
VS.ui.SearchInput = Backbone.View.extend({
|
|
|
|
type : 'text',
|
|
|
|
className : 'search_input',
|
|
|
|
events : {
|
|
'keypress input' : 'keypress',
|
|
'keydown input' : 'keydown',
|
|
'click input' : 'maybeTripleClick',
|
|
'dblclick input' : 'startTripleClickTimer'
|
|
},
|
|
|
|
initialize : function() {
|
|
this.app = this.options.app;
|
|
this.flags = {
|
|
canClose : false
|
|
};
|
|
_.bindAll(this, 'removeFocus', 'addFocus', 'moveAutocomplete', 'deferDisableEdit');
|
|
},
|
|
|
|
// Rendering the input sets up autocomplete, events on focusing and blurring
|
|
// the input, and the auto-grow of the input.
|
|
render : function() {
|
|
$(this.el).html(JST['search_input']({}));
|
|
|
|
this.setMode('not', 'editing');
|
|
this.setMode('not', 'selected');
|
|
this.box = this.$('input');
|
|
this.box.autoGrowInput();
|
|
this.box.bind('updated.autogrow', this.moveAutocomplete);
|
|
this.box.bind('blur', this.deferDisableEdit);
|
|
this.box.bind('focus', this.addFocus);
|
|
this.setupAutocomplete();
|
|
|
|
return this;
|
|
},
|
|
|
|
// Watches the input and presents an autocompleted menu, taking the
|
|
// remainder of the input field and adding a separate facet for it.
|
|
//
|
|
// See `addTextFacetRemainder` for explanation on how the remainder works.
|
|
setupAutocomplete : function() {
|
|
this.box.autocomplete({
|
|
minLength : 1,
|
|
delay : 50,
|
|
autoFocus : true,
|
|
position : {offset : "0 -1"},
|
|
source : _.bind(this.autocompleteValues, this),
|
|
create : _.bind(function(e, ui) {
|
|
$(this.el).find('.ui-autocomplete-input').css('z-index','auto');
|
|
}, this),
|
|
select : _.bind(function(e, ui) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var remainder = this.addTextFacetRemainder(ui.item.value);
|
|
var position = this.options.position + (remainder ? 1 : 0);
|
|
this.app.searchBox.addFacet(ui.item.value, '', position);
|
|
return false;
|
|
}, this)
|
|
});
|
|
|
|
// Renders the results grouped by the categories they belong to.
|
|
this.box.data('autocomplete')._renderMenu = function(ul, items) {
|
|
var category = '';
|
|
_.each(items, _.bind(function(item, i) {
|
|
if (item.category && item.category != category) {
|
|
ul.append('<li class="ui-autocomplete-category">'+item.category+'</li>');
|
|
category = item.category;
|
|
}
|
|
this._renderItem(ul, item);
|
|
}, this));
|
|
};
|
|
|
|
this.box.autocomplete('widget').addClass('VS-interface');
|
|
},
|
|
|
|
// Search terms used in the autocomplete menu. The values are matched on the
|
|
// first letter of any word in matches, and finally sorted according to the
|
|
// value's own category. You can pass `preserveOrder` as an option in the
|
|
// `facetMatches` callback to skip any further ordering done client-side.
|
|
autocompleteValues : function(req, resp) {
|
|
var searchTerm = req.term;
|
|
var lastWord = searchTerm.match(/\w+$/); // Autocomplete only last word.
|
|
var re = VS.utils.inflector.escapeRegExp(lastWord && lastWord[0] || ' ');
|
|
this.app.options.callbacks.facetMatches(function(prefixes, options) {
|
|
options = options || {};
|
|
prefixes = prefixes || [];
|
|
|
|
// Only match from the beginning of the word.
|
|
var matcher = new RegExp('^' + re, 'i');
|
|
var matches = $.grep(prefixes, function(item) {
|
|
return item && matcher.test(item.label || item);
|
|
});
|
|
|
|
if (options.preserveOrder) {
|
|
resp(matches);
|
|
} else {
|
|
resp(_.sortBy(matches, function(match) {
|
|
if (match.label) return match.category + '-' + match.label;
|
|
else return match;
|
|
}));
|
|
}
|
|
});
|
|
|
|
},
|
|
|
|
// Closes the autocomplete menu. Called on disabling, selecting, deselecting,
|
|
// and anything else that takes focus out of the facet's input field.
|
|
closeAutocomplete : function() {
|
|
var autocomplete = this.box.data('autocomplete');
|
|
if (autocomplete) autocomplete.close();
|
|
},
|
|
|
|
// As the input field grows, it may move to the next line in the
|
|
// search box. `autoGrowInput` triggers an `updated` event on the input
|
|
// field, which is bound to this method to move the autocomplete menu.
|
|
moveAutocomplete : function() {
|
|
var autocomplete = this.box.data('autocomplete');
|
|
if (autocomplete) {
|
|
autocomplete.menu.element.position({
|
|
my : "left top",
|
|
at : "left bottom",
|
|
of : this.box.data('autocomplete').element,
|
|
collision : "none",
|
|
offset : '0 -1'
|
|
});
|
|
}
|
|
},
|
|
|
|
// When a user enters a facet and it is being edited, immediately show
|
|
// the autocomplete menu and size it to match the contents.
|
|
searchAutocomplete : function(e) {
|
|
var autocomplete = this.box.data('autocomplete');
|
|
if (autocomplete) {
|
|
var menu = autocomplete.menu.element;
|
|
autocomplete.search();
|
|
|
|
// Resize the menu based on the correctly measured width of what's bigger:
|
|
// the menu's original size or the menu items' new size.
|
|
menu.outerWidth(Math.max(
|
|
menu.width('').outerWidth(),
|
|
autocomplete.element.outerWidth()
|
|
));
|
|
}
|
|
},
|
|
|
|
// If a user searches for "word word category", the category would be
|
|
// matched and autocompleted, and when selected, the "word word" would
|
|
// also be caught as the remainder and then added in its own facet.
|
|
addTextFacetRemainder : function(facetValue) {
|
|
var boxValue = this.box.val();
|
|
var lastWord = boxValue.match(/\b(\w+)$/);
|
|
var matcher = new RegExp(lastWord[0], "i");
|
|
if (lastWord && facetValue.search(matcher) == 0) {
|
|
boxValue = boxValue.replace(/\b(\w+)$/, '');
|
|
}
|
|
boxValue = boxValue.replace('^\s+|\s+$', '');
|
|
if (boxValue) {
|
|
this.app.searchBox.addFacet('text', boxValue, this.options.position);
|
|
}
|
|
return boxValue;
|
|
},
|
|
|
|
// Directly called to focus the input. This is different from `addFocus`
|
|
// because this is not called by a focus event. This instead calls a
|
|
// focus event causing the input to become focused.
|
|
enableEdit : function(selectText) {
|
|
this.addFocus();
|
|
if (selectText) {
|
|
this.selectText();
|
|
}
|
|
this.box.focus();
|
|
},
|
|
|
|
// Event called on user focus on the input. Tells all other input and facets
|
|
// to give up focus, and starts revving the autocomplete.
|
|
addFocus : function() {
|
|
this.flags.canClose = false;
|
|
if (!this.app.searchBox.allSelected()) {
|
|
this.app.searchBox.disableFacets(this);
|
|
}
|
|
this.app.searchBox.addFocus();
|
|
this.setMode('is', 'editing');
|
|
this.setMode('not', 'selected');
|
|
this.searchAutocomplete();
|
|
},
|
|
|
|
// Directly called to blur the input. This is different from `removeFocus`
|
|
// because this is not called by a blur event.
|
|
disableEdit : function() {
|
|
this.box.blur();
|
|
this.removeFocus();
|
|
},
|
|
|
|
// Event called when user blur's the input, either through the keyboard tabbing
|
|
// away or the mouse clicking off. Cleans up
|
|
removeFocus : function() {
|
|
this.flags.canClose = false;
|
|
this.app.searchBox.removeFocus();
|
|
this.setMode('not', 'editing');
|
|
this.setMode('not', 'selected');
|
|
this.closeAutocomplete();
|
|
},
|
|
|
|
// When the user blurs the input, they may either be going to another input
|
|
// or off the search box entirely. If they go to another input, this facet
|
|
// will be instantly disabled, and the canClose flag will be turned back off.
|
|
//
|
|
// However, if the user clicks elsewhere on the page, this method starts a timer
|
|
// that checks if any of the other inputs are selected or are being edited. If
|
|
// not, then it can finally close itself and its autocomplete menu.
|
|
deferDisableEdit : function() {
|
|
this.flags.canClose = true;
|
|
_.delay(_.bind(function() {
|
|
if (this.flags.canClose &&
|
|
!this.box.is(':focus') &&
|
|
this.modes.editing == 'is') {
|
|
this.disableEdit();
|
|
}
|
|
}, this), 250);
|
|
},
|
|
|
|
// Starts a timer that will cause a triple-click, which highlights all facets.
|
|
startTripleClickTimer : function() {
|
|
this.tripleClickTimer = setTimeout(_.bind(function() {
|
|
this.tripleClickTimer = null;
|
|
}, this), 500);
|
|
},
|
|
|
|
// Event on click that checks if a triple click is in play. The
|
|
// `tripleClickTimer` is counting down, ready to be engaged and intercept
|
|
// the click event to force a select all instead.
|
|
maybeTripleClick : function(e) {
|
|
if (!!this.tripleClickTimer) {
|
|
e.preventDefault();
|
|
this.app.searchBox.selectAllFacets();
|
|
return false;
|
|
}
|
|
},
|
|
|
|
// Is the user currently focused in the input field?
|
|
isFocused : function() {
|
|
return this.box.is(':focus');
|
|
},
|
|
|
|
// When serializing the facets, the inputs need to also have their values represented,
|
|
// in case they contain text that is not yet faceted (but will be once the search is
|
|
// completed).
|
|
value : function() {
|
|
return this.box.val();
|
|
},
|
|
|
|
// When switching between facets and inputs, depending on the direction the cursor
|
|
// is coming from, the cursor in this facet's input field should match the original
|
|
// direction.
|
|
setCursorAtEnd : function(direction) {
|
|
if (direction == -1) {
|
|
this.box.setCursorPosition(this.box.val().length);
|
|
} else {
|
|
this.box.setCursorPosition(0);
|
|
}
|
|
},
|
|
|
|
// Selects the entire range of text in the input. Useful when tabbing between inputs
|
|
// and facets.
|
|
selectText : function() {
|
|
this.box.selectRange(0, this.box.val().length);
|
|
if (!this.app.searchBox.allSelected()) {
|
|
this.box.focus();
|
|
} else {
|
|
this.setMode('is', 'selected');
|
|
}
|
|
},
|
|
|
|
// Before the searchBox performs a search, we need to close the
|
|
// autocomplete menu.
|
|
search : function(e, direction) {
|
|
if (!direction) direction = 0;
|
|
this.closeAutocomplete();
|
|
this.app.searchBox.searchEvent(e);
|
|
_.defer(_.bind(function() {
|
|
this.app.searchBox.focusNextFacet(this, direction);
|
|
}, this));
|
|
},
|
|
|
|
// Callback fired on key press in the search box. We search when they hit return.
|
|
keypress : function(e) {
|
|
var key = VS.app.hotkeys.key(e);
|
|
|
|
if (key == 'enter') {
|
|
return this.search(e, 100);
|
|
} else if (VS.app.hotkeys.colon(e)) {
|
|
this.box.trigger('resize.autogrow', e);
|
|
var query = this.box.val();
|
|
var prefixes = [];
|
|
if (this.app.options.callbacks.facetMatches) {
|
|
this.app.options.callbacks.facetMatches(function(p) {
|
|
prefixes = p;
|
|
});
|
|
}
|
|
var labels = _.map(prefixes, function(prefix) {
|
|
if (prefix.label) return prefix.label;
|
|
else return prefix;
|
|
});
|
|
if (_.contains(labels, query)) {
|
|
e.preventDefault();
|
|
var remainder = this.addTextFacetRemainder(query);
|
|
var position = this.options.position + (remainder?1:0);
|
|
this.app.searchBox.addFacet(query, '', position);
|
|
return false;
|
|
}
|
|
} else if (key == 'backspace') {
|
|
if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
this.app.searchBox.resizeFacets();
|
|
return false;
|
|
}
|
|
}
|
|
},
|
|
|
|
// Handles all keyboard inputs when in the input field. This checks
|
|
// for movement between facets and inputs, entering a new value that needs
|
|
// to be autocompleted, as well as stepping between facets with backspace.
|
|
keydown : function(e) {
|
|
var key = VS.app.hotkeys.key(e);
|
|
|
|
if (key == 'left') {
|
|
if (this.box.getCursorPosition() == 0) {
|
|
e.preventDefault();
|
|
this.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1});
|
|
}
|
|
} else if (key == 'right') {
|
|
if (this.box.getCursorPosition() == this.box.val().length) {
|
|
e.preventDefault();
|
|
this.app.searchBox.focusNextFacet(this, 1, {selectFacet: true});
|
|
}
|
|
} else if (VS.app.hotkeys.shift && key == 'tab') {
|
|
e.preventDefault();
|
|
this.app.searchBox.focusNextFacet(this, -1, {selectText: true});
|
|
} else if (key == 'tab') {
|
|
e.preventDefault();
|
|
var value = this.box.val();
|
|
if (value.length) {
|
|
var remainder = this.addTextFacetRemainder(value);
|
|
var position = this.options.position + (remainder?1:0);
|
|
this.app.searchBox.addFacet(value, '', position);
|
|
} else {
|
|
this.app.searchBox.focusNextFacet(this, 0, {
|
|
skipToFacet: true,
|
|
selectText: true
|
|
});
|
|
}
|
|
} else if (VS.app.hotkeys.command &&
|
|
String.fromCharCode(e.which).toLowerCase() == 'a') {
|
|
e.preventDefault();
|
|
this.app.searchBox.selectAllFacets();
|
|
return false;
|
|
} else if (key == 'backspace' && !this.app.searchBox.allSelected()) {
|
|
if (this.box.getCursorPosition() == 0 && !this.box.getSelection().length) {
|
|
e.preventDefault();
|
|
this.app.searchBox.focusNextFacet(this, -1, {backspace: true});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
this.box.trigger('resize.autogrow', e);
|
|
}
|
|
|
|
});
|
|
|
|
})();
|
|
|
|
(function(){
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// Makes the view enter a mode. Modes have both a 'mode' and a 'group',
|
|
// and are mutually exclusive with any other modes in the same group.
|
|
// Setting will update the view's modes hash, as well as set an HTML class
|
|
// of *[mode]_[group]* on the view's element. Convenient way to swap styles
|
|
// and behavior.
|
|
Backbone.View.prototype.setMode = function(mode, group) {
|
|
this.modes || (this.modes = {});
|
|
if (this.modes[group] === mode) return;
|
|
$(this.el).setMode(mode, group);
|
|
this.modes[group] = mode;
|
|
};
|
|
|
|
})();
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// DocumentCloud workspace hotkeys. To tell if a key is currently being pressed,
|
|
// just ask `VS.app.hotkeys.[key]` on `keypress`, or ask `VS.app.hotkeys.key(e)`
|
|
// on `keydown`.
|
|
//
|
|
// For the most headache-free way to use this utility, check modifier keys,
|
|
// like shift and command, with `VS.app.hotkeys.shift`, and check every other
|
|
// key with `VS.app.hotkeys.key(e) == 'key_name'`.
|
|
VS.app.hotkeys = {
|
|
|
|
// Keys that will be mapped to the `hotkeys` namespace.
|
|
KEYS: {
|
|
'16': 'shift',
|
|
'17': 'command',
|
|
'91': 'command',
|
|
'93': 'command',
|
|
'224': 'command',
|
|
'13': 'enter',
|
|
'37': 'left',
|
|
'38': 'upArrow',
|
|
'39': 'right',
|
|
'40': 'downArrow',
|
|
'46': 'delete',
|
|
'8': 'backspace',
|
|
'9': 'tab',
|
|
'188': 'comma'
|
|
},
|
|
|
|
// Binds global keydown and keyup events to listen for keys that match `this.KEYS`.
|
|
initialize : function() {
|
|
_.bindAll(this, 'down', 'up', 'blur');
|
|
$(document).bind('keydown', this.down);
|
|
$(document).bind('keyup', this.up);
|
|
$(window).bind('blur', this.blur);
|
|
},
|
|
|
|
// On `keydown`, turn on all keys that match.
|
|
down : function(e) {
|
|
var key = this.KEYS[e.which];
|
|
if (key) this[key] = true;
|
|
},
|
|
|
|
// On `keyup`, turn off all keys that match.
|
|
up : function(e) {
|
|
var key = this.KEYS[e.which];
|
|
if (key) this[key] = false;
|
|
},
|
|
|
|
// If an input is blurred, all keys need to be turned off, since they are no longer
|
|
// able to modify the document.
|
|
blur : function(e) {
|
|
for (var key in this.KEYS) this[this.KEYS[key]] = false;
|
|
},
|
|
|
|
// Check a key from an event and return the common english name.
|
|
key : function(e) {
|
|
return this.KEYS[e.which];
|
|
},
|
|
|
|
// Colon is special, since the value is different between browsers.
|
|
colon : function(e) {
|
|
var charCode = e.which;
|
|
return charCode && String.fromCharCode(charCode) == ":";
|
|
},
|
|
|
|
// Check a key from an event and match it against any known characters.
|
|
// The `keyCode` is different depending on the event type: `keydown` vs. `keypress`.
|
|
//
|
|
// These were determined by looping through every `keyCode` and `charCode` that
|
|
// resulted from `keydown` and `keypress` events and counting what was printable.
|
|
printable : function(e) {
|
|
var code = e.which;
|
|
if (e.type == 'keydown') {
|
|
if (code == 32 || // space
|
|
(code >= 48 && code <= 90) || // 0-1a-z
|
|
(code >= 96 && code <= 111) || // 0-9+-/*.
|
|
(code >= 186 && code <= 192) || // ;=,-./^
|
|
(code >= 219 && code <= 222)) { // (\)'
|
|
return true;
|
|
}
|
|
} else {
|
|
// [space]!"#$%&'()*+,-.0-9:;<=>?@A-Z[\]^_`a-z{|} and unicode characters
|
|
if ((code >= 32 && code <= 126) ||
|
|
(code >= 160 && code <= 500) ||
|
|
(String.fromCharCode(code) == ":")) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
};
|
|
|
|
})();
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// Naive English transformations on words. Only used for a few transformations
|
|
// in VisualSearch.js.
|
|
VS.utils.inflector = {
|
|
|
|
// Delegate to the ECMA5 String.prototype.trim function, if available.
|
|
trim : function(s) {
|
|
return s.trim ? s.trim() : s.replace(/^\s+|\s+$/g, '');
|
|
},
|
|
|
|
// Escape strings that are going to be used in a regex. Escapes punctuation
|
|
// that would be incorrect in a regex.
|
|
escapeRegExp : function(s) {
|
|
return s.replace(/([.*+?^${}()|[\]\/\\])/g, '\\$1');
|
|
}
|
|
};
|
|
|
|
})();
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
$.fn.extend({
|
|
|
|
// Makes the selector enter a mode. Modes have both a 'mode' and a 'group',
|
|
// and are mutually exclusive with any other modes in the same group.
|
|
// Setting will update the view's modes hash, as well as set an HTML class
|
|
// of *[mode]_[group]* on the view's element. Convenient way to swap styles
|
|
// and behavior.
|
|
setMode : function(state, group) {
|
|
group = group || 'mode';
|
|
var re = new RegExp("\\w+_" + group + "(\\s|$)", 'g');
|
|
var mode = (state === null) ? "" : state + "_" + group;
|
|
this.each(function() {
|
|
this.className = (this.className.replace(re, '')+' '+mode)
|
|
.replace(/\s\s/g, ' ');
|
|
});
|
|
return mode;
|
|
},
|
|
|
|
// When attached to an input element, this will cause the width of the input
|
|
// to match its contents. This calculates the width of the contents of the input
|
|
// by measuring a hidden shadow div that should match the styling of the input.
|
|
autoGrowInput: function() {
|
|
return this.each(function() {
|
|
var $input = $(this);
|
|
var $tester = $('<div />').css({
|
|
opacity : 0,
|
|
top : -9999,
|
|
left : -9999,
|
|
position : 'absolute',
|
|
whiteSpace : 'nowrap'
|
|
}).addClass('VS-input-width-tester').addClass('VS-interface');
|
|
|
|
// Watch for input value changes on all of these events. `resize`
|
|
// event is called explicitly when the input has been changed without
|
|
// a single keypress.
|
|
var events = 'keydown.autogrow keypress.autogrow ' +
|
|
'resize.autogrow change.autogrow';
|
|
$input.next('.VS-input-width-tester').remove();
|
|
$input.after($tester);
|
|
$input.unbind(events).bind(events, function(e, realEvent) {
|
|
if (realEvent) e = realEvent;
|
|
var value = $input.val();
|
|
|
|
// Watching for the backspace key is tricky because it may not
|
|
// actually be deleting the character, but instead the key gets
|
|
// redirected to move the cursor from facet to facet.
|
|
if (VS.app.hotkeys.key(e) == 'backspace') {
|
|
var position = $input.getCursorPosition();
|
|
if (position > 0) value = value.slice(0, position-1) +
|
|
value.slice(position, value.length);
|
|
} else if (VS.app.hotkeys.printable(e) &&
|
|
!VS.app.hotkeys.command) {
|
|
value += String.fromCharCode(e.which);
|
|
}
|
|
value = value.replace(/&/g, '&')
|
|
.replace(/\s/g,' ')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
$tester.html(value);
|
|
$input.width($tester.width() + 3);
|
|
$input.trigger('updated.autogrow');
|
|
});
|
|
|
|
// Sets the width of the input on initialization.
|
|
$input.trigger('resize.autogrow');
|
|
});
|
|
},
|
|
|
|
|
|
// Cross-browser method used for calculating where the cursor is in an
|
|
// input field.
|
|
getCursorPosition: function() {
|
|
var position = 0;
|
|
var input = this.get(0);
|
|
|
|
if (document.selection) { // IE
|
|
input.focus();
|
|
var sel = document.selection.createRange();
|
|
var selLen = document.selection.createRange().text.length;
|
|
sel.moveStart('character', -input.value.length);
|
|
position = sel.text.length - selLen;
|
|
} else if (input && $(input).is(':visible') &&
|
|
input.selectionStart != null) { // Firefox/Safari
|
|
position = input.selectionStart;
|
|
}
|
|
|
|
return position;
|
|
},
|
|
|
|
// A simple proxy for `selectRange` that sets the cursor position in an
|
|
// input field.
|
|
setCursorPosition: function(position) {
|
|
return this.each(function() {
|
|
return $(this).selectRange(position, position);
|
|
});
|
|
},
|
|
|
|
// Cross-browser way to select text in an input field.
|
|
selectRange: function(start, end) {
|
|
return this.each(function() {
|
|
if (this.setSelectionRange) { // FF/Webkit
|
|
this.focus();
|
|
this.setSelectionRange(start, end);
|
|
} else if (this.createTextRange) { // IE
|
|
var range = this.createTextRange();
|
|
range.collapse(true);
|
|
range.moveEnd('character', end);
|
|
range.moveStart('character', start);
|
|
if (end - start >= 0) range.select();
|
|
}
|
|
});
|
|
},
|
|
|
|
// Returns an object that contains the text selection range values for
|
|
// an input field.
|
|
getSelection: function() {
|
|
var input = this[0];
|
|
|
|
if (input.selectionStart != null) { // FF/Webkit
|
|
var start = input.selectionStart;
|
|
var end = input.selectionEnd;
|
|
return {
|
|
start : start,
|
|
end : end,
|
|
length : end-start,
|
|
text : input.value.substr(start, end-start)
|
|
};
|
|
} else if (document.selection) { // IE
|
|
var range = document.selection.createRange();
|
|
if (range) {
|
|
var textRange = input.createTextRange();
|
|
var copyRange = textRange.duplicate();
|
|
textRange.moveToBookmark(range.getBookmark());
|
|
copyRange.setEndPoint('EndToStart', textRange);
|
|
var start = copyRange.text.length;
|
|
var end = start + range.text.length;
|
|
return {
|
|
start : start,
|
|
end : end,
|
|
length : end-start,
|
|
text : range.text
|
|
};
|
|
}
|
|
}
|
|
return {start: 0, end: 0, length: 0};
|
|
}
|
|
|
|
});
|
|
|
|
// Debugging in Internet Explorer. This allows you to use
|
|
// `console.log(['message', var1, var2, ...])`. Just remove the `false` and
|
|
// add your console.logs. This will automatically stringify objects using
|
|
// `JSON.stringify', so you can read what's going out. Think of this as a
|
|
// *Diet Firebug Lite Zero with Lemon*.
|
|
if ($.browser.msie && false) {
|
|
window.console = {};
|
|
var _$ied;
|
|
window.console.log = function(msg) {
|
|
if (_.isArray(msg)) {
|
|
var message = msg[0];
|
|
var vars = _.map(msg.slice(1), function(arg) {
|
|
return JSON.stringify(arg);
|
|
}).join(' - ');
|
|
}
|
|
if(!_$ied){
|
|
_$ied = $('<div><ol></ol></div>').css({
|
|
'position': 'fixed',
|
|
'bottom': 10,
|
|
'left': 10,
|
|
'zIndex': 20000,
|
|
'width': $('body').width() - 80,
|
|
'border': '1px solid #000',
|
|
'padding': '10px',
|
|
'backgroundColor': '#fff',
|
|
'fontFamily': 'arial,helvetica,sans-serif',
|
|
'fontSize': '11px'
|
|
});
|
|
$('body').append(_$ied);
|
|
}
|
|
var $message = $('<li>'+message+' - '+vars+'</li>').css({
|
|
'borderBottom': '1px solid #999999'
|
|
});
|
|
_$ied.find('ol').append($message);
|
|
_.delay(function() {
|
|
$message.fadeOut(500);
|
|
}, 5000);
|
|
};
|
|
|
|
}
|
|
|
|
})();
|
|
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// Used to extract keywords and facets from the free text search.
|
|
var FREETEXT_RE = '(\'[^\']+\'|"[^"]+"|[^\'"\\s]\\S*)';
|
|
var CATEGORY_RE = FREETEXT_RE + ':\\s*';
|
|
VS.app.SearchParser = {
|
|
|
|
// Matches `category: "free text"`, with and without quotes.
|
|
ALL_FIELDS : new RegExp(CATEGORY_RE + FREETEXT_RE, 'g'),
|
|
|
|
// Matches a single category without the text. Used to correctly extract facets.
|
|
CATEGORY : new RegExp(CATEGORY_RE),
|
|
|
|
// Called to parse a query into a collection of `SearchFacet` models.
|
|
parse : function(instance, query) {
|
|
var searchFacets = this._extractAllFacets(instance, query);
|
|
instance.searchQuery.reset(searchFacets);
|
|
return searchFacets;
|
|
},
|
|
|
|
// Walks the query and extracts facets, categories, and free text.
|
|
_extractAllFacets : function(instance, query) {
|
|
var facets = [];
|
|
var originalQuery = query;
|
|
|
|
while (query) {
|
|
var category, value;
|
|
originalQuery = query;
|
|
var field = this._extractNextField(query);
|
|
if (!field) {
|
|
category = 'text';
|
|
value = this._extractSearchText(query);
|
|
query = VS.utils.inflector.trim(query.replace(value, ''));
|
|
} else if (field.indexOf(':') != -1) {
|
|
category = field.match(this.CATEGORY)[1].replace(/(^['"]|['"]$)/g, '');
|
|
value = field.replace(this.CATEGORY, '').replace(/(^['"]|['"]$)/g, '');
|
|
query = VS.utils.inflector.trim(query.replace(field, ''));
|
|
} else if (field.indexOf(':') == -1) {
|
|
category = 'text';
|
|
value = field;
|
|
query = VS.utils.inflector.trim(query.replace(value, ''));
|
|
}
|
|
|
|
if (category && value) {
|
|
var searchFacet = new VS.model.SearchFacet({
|
|
category : category,
|
|
value : VS.utils.inflector.trim(value),
|
|
app : instance
|
|
});
|
|
facets.push(searchFacet);
|
|
}
|
|
if (originalQuery == query) break;
|
|
}
|
|
|
|
return facets;
|
|
},
|
|
|
|
// Extracts the first field found, capturing any free text that comes
|
|
// before the category.
|
|
_extractNextField : function(query) {
|
|
var textRe = new RegExp('^\\s*(\\S+)\\s+(?=' + CATEGORY_RE + FREETEXT_RE + ')');
|
|
var textMatch = query.match(textRe);
|
|
if (textMatch && textMatch.length >= 1) {
|
|
return textMatch[1];
|
|
} else {
|
|
return this._extractFirstField(query);
|
|
}
|
|
},
|
|
|
|
// If there is no free text before the facet, extract the category and value.
|
|
_extractFirstField : function(query) {
|
|
var fields = query.match(this.ALL_FIELDS);
|
|
return fields && fields.length && fields[0];
|
|
},
|
|
|
|
// If the found match is not a category and facet, extract the trimmed free text.
|
|
_extractSearchText : function(query) {
|
|
query = query || '';
|
|
var text = VS.utils.inflector.trim(query.replace(this.ALL_FIELDS, ''));
|
|
return text;
|
|
}
|
|
|
|
};
|
|
|
|
})();
|
|
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// The model that holds individual search facets and their categories.
|
|
// Held in a collection by `VS.app.searchQuery`.
|
|
VS.model.SearchFacet = Backbone.Model.extend({
|
|
|
|
// Extract the category and value and serialize it in preparation for
|
|
// turning the entire searchBox into a search query that can be sent
|
|
// to the server for parsing and searching.
|
|
serialize : function() {
|
|
var category = this.quoteCategory(this.get('category'));
|
|
var value = VS.utils.inflector.trim(this.get('value'));
|
|
|
|
if (!value) return '';
|
|
|
|
if (!_.contains(this.get("app").options.unquotable || [], category) && category != 'text') {
|
|
value = this.quoteValue(value);
|
|
}
|
|
|
|
if (category != 'text') {
|
|
category = category + ': ';
|
|
} else {
|
|
category = "";
|
|
}
|
|
return category + value;
|
|
},
|
|
|
|
// Wrap categories that have spaces or any kind of quote with opposite matching
|
|
// quotes to preserve the complex category during serialization.
|
|
quoteCategory : function(category) {
|
|
var hasDoubleQuote = (/"/).test(category);
|
|
var hasSingleQuote = (/'/).test(category);
|
|
var hasSpace = (/\s/).test(category);
|
|
|
|
if (hasDoubleQuote && !hasSingleQuote) {
|
|
return "'" + category + "'";
|
|
} else if (hasSpace || (hasSingleQuote && !hasDoubleQuote)) {
|
|
return '"' + category + '"';
|
|
} else {
|
|
return category;
|
|
}
|
|
},
|
|
|
|
// Wrap values that have quotes in opposite matching quotes. If a value has
|
|
// both single and double quotes, just use the double quotes.
|
|
quoteValue : function(value) {
|
|
var hasDoubleQuote = (/"/).test(value);
|
|
var hasSingleQuote = (/'/).test(value);
|
|
|
|
if (hasDoubleQuote && !hasSingleQuote) {
|
|
return "'" + value + "'";
|
|
} else {
|
|
return '"' + value + '"';
|
|
}
|
|
}
|
|
|
|
});
|
|
|
|
})();
|
|
(function() {
|
|
|
|
var $ = jQuery; // Handle namespaced jQuery
|
|
|
|
// Collection which holds all of the individual facets (category: value).
|
|
// Used for finding and removing specific facets.
|
|
VS.model.SearchQuery = Backbone.Collection.extend({
|
|
|
|
// Model holds the category and value of the facet.
|
|
model : VS.model.SearchFacet,
|
|
|
|
// Turns all of the facets into a single serialized string.
|
|
serialize : function() {
|
|
return this.map(function(facet){ return facet.serialize(); }).join(' ');
|
|
},
|
|
|
|
facets : function() {
|
|
return this.map(function(facet) {
|
|
var value = {};
|
|
value[facet.get('category')] = facet.get('value');
|
|
return value;
|
|
});
|
|
},
|
|
|
|
// Find a facet by its category. Multiple facets with the same category
|
|
// is fine, but only the first is returned.
|
|
find : function(category) {
|
|
var facet = this.detect(function(facet) {
|
|
return facet.get('category') == category;
|
|
});
|
|
return facet && facet.get('value');
|
|
},
|
|
|
|
// Counts the number of times a specific category is in the search query.
|
|
count : function(category) {
|
|
return this.select(function(facet) {
|
|
return facet.get('category') == category;
|
|
}).length;
|
|
},
|
|
|
|
// Returns an array of extracted values from each facet in a category.
|
|
values : function(category) {
|
|
var facets = this.select(function(facet) {
|
|
return facet.get('category') == category;
|
|
});
|
|
return _.map(facets, function(facet) { return facet.get('value'); });
|
|
},
|
|
|
|
// Checks all facets for matches of either a category or both category and value.
|
|
has : function(category, value) {
|
|
return this.any(function(facet) {
|
|
var categoryMatched = facet.get('category') == category;
|
|
if (!value) return categoryMatched;
|
|
return categoryMatched && facet.get('value') == value;
|
|
});
|
|
},
|
|
|
|
// Used to temporarily hide a specific category and serialize the search query.
|
|
withoutCategory : function(category) {
|
|
return this.map(function(facet) {
|
|
if (facet.get('category') != category) return facet.serialize();
|
|
}).join(' ');
|
|
}
|
|
|
|
});
|
|
|
|
})();(function(){
|
|
window.JST = window.JST || {};
|
|
|
|
window.JST['search_box'] = _.template('<div class="VS-search">\n <div class="VS-search-box-wrapper VS-search-box">\n <div class="VS-icon VS-icon-search"></div>\n <div class="VS-search-inner"></div>\n <div class="VS-icon VS-icon-cancel VS-cancel-search-box" title="clear search"></div>\n </div>\n</div>');
|
|
window.JST['search_facet'] = _.template('<% if (model.has(\'category\')) { %>\n <div class="category"><%= model.get(\'category\') %>:</div>\n<% } %>\n\n<div class="search_facet_input_container">\n <input type="text" class="search_facet_input VS-interface" value="" />\n</div>\n\n<div class="search_facet_remove VS-icon VS-icon-cancel"></div>');
|
|
window.JST['search_input'] = _.template('<input type="text" />');
|
|
})(); |