/* globals Deps, getCaretCoordinates*/ import _ from 'underscore'; import AutoCompleteRecords from './collection'; const isServerSearch = function(rule) { return _.isString(rule.collection); }; const validateRule = function(rule) { if (rule.subscription != null && !Match.test(rule.collection, String)) { throw new Error('Collection name must be specified as string for server-side search'); } // XXX back-compat message, to be removed if (rule.callback) { console.warn('autocomplete no longer supports callbacks; use event listeners instead.'); } }; const isWholeField = function(rule) { // either '' or null both count as whole field. return !rule.token; }; const getRegExp = function(rule) { if (!isWholeField(rule)) { // Expressions for the range from the last word break to the current cursor position return new RegExp(`(^|\\b|\\s)${ rule.token }([\\w.]*)$`); } else { // Whole-field behavior - word characters or spaces return new RegExp('(^)(.*)$'); } }; const getFindParams = function(rule, filter, limit) { // This is a different 'filter' - the selector from the settings // We need to extend so that we don't copy over rule.filter const selector = _.extend({}, rule.filter || {}); const options = { limit, }; if (!filter) { // Match anything, no sort, limit X return [selector, options]; } if (rule.sort && rule.field) { const sortspec = {}; // Only sort if there is a filter, for faster performance on a match of anything sortspec[rule.field] = 1; options.sort = sortspec; } if (_.isFunction(rule.selector)) { // Custom selector _.extend(selector, rule.selector(filter)); } else { selector[rule.field] = { $regex: rule.matchAll ? filter : `^${ filter }`, // default is case insensitive search - empty string is not the same as undefined! $options: typeof rule.options === 'undefined' ? 'i' : rule.options, }; } return [selector, options]; }; const getField = function(obj, str) { const string = str.split('.'); string.forEach((key) => { obj = obj[key]; }); return obj; }; this.AutoComplete = class { constructor(settings) { this.KEYS = [40, 38, 13, 27, 9]; this.limit = settings.limit || 5; this.position = settings.position || 'bottom'; this.rules = settings.rules; this.selector = { constainer: '.-autocomplete-container', item: '.-autocomplete-item', ...settings.selector, }; const rules = this.rules; Object.keys(rules).forEach((key) => { const rule = rules[key]; validateRule(rule); }); this.expressions = (() => Object.keys(rules).map((key) => { const rule = rules[key]; return getRegExp(rule); }))(); this.matched = -1; this.loaded = true; // Reactive dependencies for current matching rule and filter this.ruleDep = new Deps.Dependency; this.filterDep = new Deps.Dependency; this.loadingDep = new Deps.Dependency; // Autosubscribe to the record set published by the server based on the filter // This will tear down server subscriptions when they are no longer being used. this.sub = null; this.comp = Deps.autorun(() => { const rule = this.matchedRule(); const filter = this.getFilter(); if (this.sub) { // Stop any existing sub immediately, don't wait this.sub.stop(); } if (!(rule && filter)) { return; } // subscribe only for server-side collections if (!isServerSearch(rule)) { this.setLoaded(true); return; } const params = getFindParams(rule, filter, this.limit); const selector = params[0]; const options = params[1]; // console.debug 'Subscribing to <%s> in <%s>.<%s>', filter, rule.collection, rule.field this.setLoaded(false); const subName = rule.subscription || 'autocomplete-recordset'; this.sub = Meteor.subscribe(subName, selector, options, rule.collection, () => { this.setLoaded(true); }); }); } teardown() { // Stop the reactive computation we started for this autocomplete instance this.comp.stop(); } matchedRule() { // reactive getters and setters for @filter and the currently matched rule this.ruleDep.depend(); if (this.matched >= 0) { return this.rules[this.matched]; } else { return null; } } setMatchedRule(i) { this.matched = i; this.ruleDep.changed(); } getFilter() { this.filterDep.depend(); return this.filter; } setFilter(x) { this.filter = x; this.filterDep.changed(); return this.filter; } isLoaded() { this.loadingDep.depend(); return this.loaded; } setLoaded(val) { if (val === this.loaded) { return; // Don't cause redraws unnecessarily } this.loaded = val; this.loadingDep.changed(); } onKeyUp() { if (!this.$element) { return; // Don't try to do this while loading } const startpos = this.element.selectionStart; const val = this.getText().substring(0, startpos); /* Matching on multiple expressions. We always go from a matched state to an unmatched one before going to a different matched one. */ let i = 0; let breakLoop = false; while (i < this.expressions.length) { const matches = val.match(this.expressions[i]); // matching -> not matching if (!matches && this.matched === i) { this.setMatchedRule(-1); breakLoop = true; } // not matching -> matching if (matches && this.matched === -1) { this.setMatchedRule(i); breakLoop = true; } // Did filter change? if (matches && this.filter !== matches[2]) { this.setFilter(matches[2]); breakLoop = true; } if (breakLoop) { break; } i++; } } onKeyDown(e) { if (this.matched === -1 || (this.KEYS.indexOf(e.keyCode) < 0)) { return; } switch (e.keyCode) { case 9: // TAB case 13: // ENTER if (this.select()) { // Don't jump fields or submit if select successful e.preventDefault(); e.stopPropagation(); } break; // preventDefault needed below to avoid moving cursor when selecting case 40: // DOWN e.preventDefault(); this.next(); break; case 38: // UP e.preventDefault(); this.prev(); break; case 27: // ESCAPE this.$element.blur(); this.hideList(); } } onFocus() { // We need to run onKeyUp after the focus resolves, // or the caret position (selectionStart) will not be correct Meteor.defer(() => this.onKeyUp()); } onBlur() { // We need to delay this so click events work // TODO this is a bit of a hack, see if we can't be smarter Meteor.setTimeout(() => { this.hideList(); }, 500); } onItemClick(doc) { this.processSelection(doc, this.rules[this.matched]); } onItemHover(doc, e) { this.tmplInst.$(this.selector.item).removeClass('selected'); $(e.target).closest(this.selector.item).addClass('selected'); } filteredList() { // @ruleDep.depend() # optional as long as we use depend on filter, because list will always get re-rendered const filter = this.getFilter(); // Reactively depend on the filter if (this.matched === -1) { return null; } const rule = this.rules[this.matched]; // Don't display list unless we have a token or a filter (or both) // Single field: nothing displayed until something is typed if (!(rule.token || filter)) { return null; } const params = getFindParams(rule, filter, this.limit); const selector = params[0]; const options = params[1]; Meteor.defer(() => this.ensureSelection()); // if server collection, the server has already done the filtering work if (isServerSearch(rule)) { return AutoCompleteRecords.find({}, options); } // Otherwise, search on client return rule.collection.find(selector, options); } isShowing() { const rule = this.matchedRule(); // Same rules as above const showing = rule && (rule.token || this.getFilter()); // Do this after the render if (showing) { Meteor.defer(() => { this.positionContainer(); this.ensureSelection(); }); } return showing; } // Replace text with currently selected item select() { const node = this.tmplInst.find(`${ this.selector.item }.selected`); if (node == null) { return false; } const doc = Blaze.getData(node); if (!doc) { return false; // Don't select if nothing matched } this.processSelection(doc, this.rules[this.matched]); return true; } processSelection(doc, rule) { const replacement = getField(doc, rule.field); if (!isWholeField(rule)) { this.replace(replacement, rule); this.hideList(); } else { // Empty string or doesn't exist? // Single-field replacement: replace whole field this.setText(replacement); // Field retains focus, but list is hidden unless another key is pressed // Must be deferred or onKeyUp will trigger and match again // TODO this is a hack; see above this.onBlur(); } this.$element.trigger('autocompleteselect', doc); } // Replace the appropriate region replace(replacement) { const startpos = this.element.selectionStart; const fullStuff = this.getText(); let val = fullStuff.substring(0, startpos); val = val.replace(this.expressions[this.matched], `$1${ this.rules[this.matched].token }${ replacement }`); const posfix = fullStuff.substring(startpos, fullStuff.length); const separator = (posfix.match(/^\s/) ? '' : ' '); const finalFight = val + separator + posfix; this.setText(finalFight); const newPosition = val.length + 1; this.element.setSelectionRange(newPosition, newPosition); } hideList() { this.setMatchedRule(-1); this.setFilter(null); } getText() { return this.$element.val() || this.$element.text(); } setText(text) { if (this.$element.is('input,textarea')) { this.$element.val(text); } else { this.$element.html(text); } } /* Rendering functions */ positionContainer() { // First render; Pick the first item and set css whenever list gets shown let pos = {}; const position = this.$element.position(); const rule = this.matchedRule(); const offset = getCaretCoordinates(this.element, this.element.selectionStart); // In whole-field positioning, we don't move the container and make it the // full width of the field. if (rule && isWholeField(rule)) { pos.left = position.left; if (rule.doNotChangeWidth !== false) { pos.width = this.$element.outerWidth(); // position.offsetWidth } } else { // Normal positioning, at token word pos = { left: position.left + offset.left }; } // Position menu from top (above) or from bottom of caret (below, default) if (this.position === 'top') { pos.bottom = this.$element.offsetParent().height() - position.top - offset.top; } else { pos.top = position.top + offset.top + parseInt(this.$element.css('font-size')); } this.tmplInst.$(this.selector.container).css(pos); } ensureSelection() { // Re-render; make sure selected item is something in the list or none if list empty const selectedItem = this.tmplInst.$(`${ this.selector.item }.selected`); if (!selectedItem.length) { // Select anything this.tmplInst.$(`${ this.selector.item }:first-child`).addClass('selected'); } } // Select next item in list next() { const currentItem = this.tmplInst.$(`${ this.selector.item }.selected`); if (!currentItem.length) { return this.tmplInst.$(`${ this.selector.item }:first-child`).addClass('selected'); } currentItem.removeClass('selected'); const next = currentItem.next(); if (next.length) { next.addClass('selected'); } else { // End of list or lost selection; Go back to first item this.tmplInst.$(`${ this.selector.item }:first-child`).addClass('selected'); } } // Select previous item in list prev() { const currentItem = this.tmplInst.$(`${ this.selector.item }.selected`); if (!currentItem.length) { return; // Don't try to iterate an empty list } currentItem.removeClass('selected'); const prev = currentItem.prev(); if (prev.length) { prev.addClass('selected'); } else { // Beginning of list or lost selection; Go to end of list this.tmplInst.$(`${ this.selector.item }:last-child`).addClass('selected'); } } // This doesn't need to be reactive because list already changes reactively // and will cause all of the items to re-render anyway currentTemplate() { return this.rules[this.matched].template; } };