| // 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.4.0 |
| * (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.5.0'; |
| |
| VS.VisualSearch = function(options) { |
| var defaults = { |
| container : '', |
| query : '', |
| autosearch : true, |
| unquotable : [], |
| remainder : 'text', |
| showFacets : true, |
| readOnly : false, |
| callbacks : { |
| search : $.noop, |
| focus : $.noop, |
| blur : $.noop, |
| facetMatches : $.noop, |
| valueMatches : $.noop, |
| clearSearch : $.noop, |
| removedFacet : $.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, |
| showFacets: this.options.showFacets |
| }); |
| |
| 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 forces 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(options) { |
| this.options = _.extend({}, this.options, options); |
| |
| 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']({ |
| readOnly: this.app.options.readOnly |
| })); |
| $(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(' '); |
| }, |
| |
| // Returns any facet views that are currently selected. Useful for changing the value |
| // callbacks based on what else is in the search box and which facet is being edited. |
| selected: function() { |
| return _.select(this.facetViews, function(view) { |
| return view.modes.editing == 'is' || view.modes.selected == 'is'; |
| }); |
| }, |
| |
| // Similar to `this.selected`, returns any facet models that are currently selected. |
| selectedModels: function() { |
| return _.pluck(this.selected(), 'model'); |
| }, |
| |
| // 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) { |
| this.app.options.callbacks.removedFacet(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(this.renderFacet, this)); |
| |
| // Add on an n+1 empty search input on the very end. |
| this.renderSearchInput(); |
| this.renderPlaceholder(); |
| }, |
| |
| // 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, |
| showFacets: this.options.showFacets |
| }); |
| this.$('.VS-search-inner').append(input.render().el); |
| this.inputViews.push(input); |
| }, |
| |
| // Handles showing/hiding the placeholder text |
| renderPlaceholder : function() { |
| var $placeholder = this.$('.VS-placeholder'); |
| if (this.app.searchQuery.length) { |
| $placeholder.addClass("VS-hidden"); |
| } else { |
| $placeholder.removeClass("VS-hidden") |
| .text(this.app.options.placeholder); |
| } |
| }, |
| |
| // # 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) { |
| if (this.app.options.readOnly) return; |
| 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 != $.noop) { |
| 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. |
| return false; |
| } |
| 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(); |
| |
| return true; |
| }, |
| |
| maybeFocusSearch : function(e) { |
| if (this.app.options.readOnly) return; |
| 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) { |
| if (this.app.options.readOnly) return; |
| 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 (this.app.options.readOnly) return; |
| 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() { |
| if (this.app.options.readOnly) return; |
| 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.options = _.extend({}, this.options, options); |
| |
| this.flags = { |
| canClose : false |
| }; |
| _.bindAll(this, 'set', 'keydown', 'deselectFacet', 'deferDisableEdit'); |
| this.app = this.options.app; |
| }, |
| |
| // 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, |
| readOnly: this.app.options.readOnly |
| })); |
| |
| this.setMode('not', 'editing'); |
| this.setMode('not', 'selected'); |
| this.box = this.$('input'); |
| this.box.val(this.model.label()); |
| 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 : this.app.options.delay, |
| autoFocus : false, |
| 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) { |
| if (this.app.options.autosearch) { |
| this.search(e); |
| } else { |
| this.app.searchBox.renderFacets(); |
| this.app.searchBox.focusNextFacet(this, 1, {viewPosition: this.options.order}); |
| } |
| } |
| 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), |
| autoCompleteData = $value.data('item.autocomplete') || $value.data('ui-autocomplete-item'); |
| |
| if (autoCompleteData['value'] == box.val() && box.data('autocomplete').menu.activate) { |
| 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.app.options.callbacks.valueMatches(category, searchTerm, function(matches, options) { |
| options = options || {}; |
| matches = matches || []; |
| |
| if (searchTerm && value != searchTerm) { |
| if (options.preserveMatches) { |
| resp(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.app.searchBox.searchEvent(e); |
| _.defer(_.bind(function() { |
| this.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.app.options.readOnly) return; |
| 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.app.searchBox.disableFacets(this); |
| this.app.searchBox.addFocus(); |
| _.defer(_.bind(function() { |
| this.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.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(); |
| if (this.app.options.readOnly) return; |
| var allSelected = this.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.app.searchBox.disableFacets(this); |
| this.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.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.app.searchQuery.remove(this.model); |
| if (committed && this.app.options.autosearch) { |
| this.search(e, -1); |
| } else { |
| this.app.searchBox.renderFacets(); |
| this.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.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.app.searchBox.focusNextFacet(this, 1); |
| } |
| } else if (VS.app.hotkeys.shift && key == 'tab') { |
| e.preventDefault(); |
| this.app.searchBox.focusNextFacet(this, -1, { |
| startAtEnd : -1, |
| skipToFacet : true, |
| selectText : true |
| }); |
| } else if (key == 'tab') { |
| e.preventDefault(); |
| this.app.searchBox.focusNextFacet(this, 1, { |
| skipToFacet : true, |
| selectText : true |
| }); |
| } else if (VS.app.hotkeys.command && (e.which == 97 || e.which == 65)) { |
| e.preventDefault(); |
| this.app.searchBox.selectAllFacets(); |
| return false; |
| } else if (VS.app.hotkeys.printable(e) && this.modes.selected == 'is') { |
| this.app.searchBox.focusNextFacet(this, -1, {startAtEnd: -1}); |
| this.remove(e); |
| } else if (key == 'backspace') { |
| $(document).on('keydown.backspace', function(e) { |
| if (VS.app.hotkeys.key(e) === 'backspace') { |
| e.preventDefault(); |
| } |
| }); |
| |
| $(document).on('keyup.backspace', function(e) { |
| $(document).off('.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(); |
| } |
| e.stopPropagation(); |
| } |
| |
| // Handle paste events |
| if (e.which == null) { |
| // this.searchAutocomplete(e); |
| _.defer(_.bind(this.resize, this, e)); |
| } else { |
| this.resize(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 ui-menu', |
| |
| events : { |
| 'keypress input' : 'keypress', |
| 'keydown input' : 'keydown', |
| 'keyup input' : 'keyup', |
| 'click input' : 'maybeTripleClick', |
| 'dblclick input' : 'startTripleClickTimer' |
| }, |
| |
| initialize : function(options) { |
| this.options = _.extend({}, this.options, options); |
| |
| 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']({ |
| readOnly: this.app.options.readOnly |
| })); |
| |
| 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 : this.options.showFacets ? 0 : 1, |
| delay : 50, |
| autoFocus : true, |
| position : {offset : "0 -1"}, |
| source : _.bind(this.autocompleteValues, this), |
| // Prevent changing the input value on focus of an option |
| focus : function() { return false; }, |
| create : _.bind(function(e, ui) { |
| $(this.el).find('.ui-autocomplete-input').css('z-index','auto'); |
| }, this), |
| select : _.bind(function(e, ui) { |
| e.preventDefault(); |
| // stopPropogation does weird things in jquery-ui 1.9 |
| // e.stopPropagation(); |
| var remainder = this.addTextFacetRemainder(ui.item.label || ui.item.value); |
| var position = this.options.position + (remainder ? 1 : 0); |
| this.app.searchBox.addFacet(ui.item instanceof String ? ui.item : 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; |
| } |
| |
| if(this._renderItemData) { |
| this._renderItemData(ul, item); |
| } else { |
| 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+)$/); |
| |
| if (!lastWord) { |
| return ''; |
| } |
| |
| var matcher = new RegExp(lastWord[0], "i"); |
| if (facetValue.search(matcher) == 0) { |
| boxValue = boxValue.replace(/\b(\w+)$/, ''); |
| } |
| boxValue = boxValue.replace('^\s+|\s+$', ''); |
| |
| if (boxValue) { |
| this.app.searchBox.addFacet(this.app.options.remainder, 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'); |
| if (!this.app.searchBox.allSelected()) { |
| 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.app.options.readOnly) return; |
| 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 = []; |
| 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') { |
| var value = this.box.val(); |
| if (value.length) { |
| e.preventDefault(); |
| var remainder = this.addTextFacetRemainder(value); |
| var position = this.options.position + (remainder?1:0); |
| if (value != remainder) { |
| this.app.searchBox.addFacet(value, '', position); |
| } |
| } else { |
| var foundFacet = this.app.searchBox.focusNextFacet(this, 0, { |
| skipToFacet: true, |
| selectText: true |
| }); |
| if (foundFacet) { |
| e.preventDefault(); |
| } |
| } |
| } 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; |
| } |
| } else if (key == 'end') { |
| var view = this.app.searchBox.inputViews[this.app.searchBox.inputViews.length-1]; |
| view.setCursorAtEnd(-1); |
| } else if (key == 'home') { |
| var view = this.app.searchBox.inputViews[0]; |
| view.setCursorAtEnd(-1); |
| } |
| |
| }, |
| |
| // We should get the value of an input should be done |
| // on keyup since keydown gets the previous value and not the current one |
| keyup : function(e) { |
| 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', |
| '35': 'end', |
| '36': 'home', |
| '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 + parseInt($input.css('min-width'))); |
| $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.filter(':visible').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 (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 QUOTES_RE = "('[^']+'|\"[^\"]+\")"; |
| 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 = instance.options.remainder; |
| 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 = instance.options.remainder; |
| 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+(?=' + QUOTES_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')); |
| var remainder = this.get("app").options.remainder; |
| |
| if (!value) return ''; |
| |
| if (!_.contains(this.get("app").options.unquotable || [], category) && category != remainder) { |
| value = this.quoteValue(value); |
| } |
| |
| if (category != remainder) { |
| 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 + '"'; |
| } |
| }, |
| |
| // If provided, use a custom label instead of the raw value. |
| label : function() { |
| return this.get('label') || this.get('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').toLowerCase() == category.toLowerCase(); |
| }); |
| 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').toLowerCase() == category.toLowerCase(); |
| }).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').toLowerCase() == category.toLowerCase(); |
| }); |
| 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').toLowerCase() == category.toLowerCase(); |
| if (!value) return categoryMatched; |
| return categoryMatched && facet.get('value') == value; |
| }); |
| }, |
| |
| // Used to temporarily hide specific categories and serialize the search query. |
| withoutCategory : function() { |
| var categories = _.map(_.toArray(arguments), function(cat) { return cat.toLowerCase(); }); |
| return this.map(function(facet) { |
| if (!_.include(categories, facet.get('category').toLowerCase())) { |
| return facet.serialize(); |
| }; |
| }).join(' '); |
| } |
| |
| }); |
| |
| })(); |
| (function(){ |
| window.JST = window.JST || {}; |
| |
| window.JST['search_box'] = _.template('<div class="VS-search <% if (readOnly) { %>VS-readonly<% } %>">\n <div class="VS-search-box-wrapper VS-search-box">\n <div class="VS-icon VS-icon-search"></div>\n <div class="VS-placeholder"></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 ui-menu VS-interface" value="" <% if (readOnly) { %>disabled="disabled"<% } %> />\n</div>\n\n<div class="search_facet_remove VS-icon VS-icon-cancel"></div>'); |
| window.JST['search_input'] = _.template('<input type="text" class="ui-menu" <% if (readOnly) { %>disabled="disabled"<% } %> />'); |
| })(); |