All Downloads are FREE. Search and download functionalities are using the official Maven repository.

visualsearch.0.4.0.build.visualsearch.js Maven / Gradle / Ivy

The newest version!
// 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.4.0';

  VS.VisualSearch = function(options) {
    var defaults = {
      container   : '',
      query       : '',
      autosearch  : true,
      unquotable  : [],
      remainder   : 'text',
      showFacets  : true,
      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, 
        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 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(' ');
  },
  
  // 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) {},

  // 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) {
    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.
      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 ($(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.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     : 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) {
          if (this.options.app.options.autosearch) {
            this.search(e);
          } else {
              this.options.app.searchBox.renderFacets();
              this.options.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('uiAutocomplete').menu.activate) {
            box.data('uiAutocomplete').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('uiAutocomplete');
    if (autocomplete) {
      autocomplete.menu.element.position({
        my        : "left top",
        at        : "left bottom",
        of        : this.box.data('uiAutocomplete').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('uiAutocomplete');
    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('uiAutocomplete');
    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) {
          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.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.options.app.options.autosearch) {
      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();
      }
    }

    // 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',
    '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 : this.options.showFacets ? 0 : 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();
        // stopPropogation does weird things in jquery-ui 1.9
        // e.stopPropagation();
        var remainder = this.addTextFacetRemainder(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('uiAutocomplete')._renderMenu = function(ul, items) {
      var category = '';
      _.each(items, _.bind(function(item, i) {
        if (item.category && item.category != category) {
          ul.append('
  • '+item.category+'
  • '); 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('uiAutocomplete'); 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('uiAutocomplete'); if (autocomplete) { autocomplete.menu.element.position({ my : "left top", at : "left bottom", of : this.box.data('uiAutocomplete').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('uiAutocomplete'); 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.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') { 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); } 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 = $('
    ').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, '>'); $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 = $('
      ').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 = $('
    1. '+message+' - '+vars+'
    2. ').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(''); window.JST['search_facet'] = _.template('<% if (model.has(\'category\')) { %>\n
      <%= model.get(\'category\') %>:
      \n<% } %>\n\n
      \n \n
      \n\n
      '); window.JST['search_input'] = _.template(''); })();




      © 2015 - 2025 Weber Informatics LLC | Privacy Policy