parent
7e42466d53
commit
8bb0eab169
@ -0,0 +1,367 @@ |
||||
AutoCompleteRecords = new Mongo.Collection("autocompleteRecords") |
||||
|
||||
isServerSearch = (rule) -> _.isString(rule.collection) |
||||
|
||||
validateRule = (rule) -> |
||||
if rule.subscription? and not 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.") |
||||
|
||||
isWholeField = (rule) -> |
||||
# either '' or null both count as whole field. |
||||
return !rule.token |
||||
|
||||
getRegExp = (rule) -> |
||||
unless isWholeField(rule) |
||||
# Expressions for the range from the last word break to the current cursor position |
||||
new RegExp('(^|\\b|\\s)' + rule.token + '([\\w.]*)$') |
||||
else |
||||
# Whole-field behavior - word characters or spaces |
||||
new RegExp('(^)(.*)$') |
||||
|
||||
getFindParams = (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 |
||||
selector = _.extend({}, rule.filter || {}) |
||||
options = { limit: limit } |
||||
|
||||
# Match anything, no sort, limit X |
||||
return [ selector, options ] unless filter |
||||
|
||||
if rule.sort and rule.field |
||||
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: if rule.matchAll then filter else "^" + filter |
||||
# default is case insensitive search - empty string is not the same as undefined! |
||||
$options: if (typeof rule.options is 'undefined') then 'i' else rule.options |
||||
} |
||||
|
||||
return [ selector, options ] |
||||
|
||||
getField = (obj, str) -> |
||||
obj = obj[key] for key in str.split(".") |
||||
return obj |
||||
|
||||
class @AutoComplete |
||||
|
||||
@KEYS: [ |
||||
40, # DOWN |
||||
38, # UP |
||||
13, # ENTER |
||||
27, # ESCAPE |
||||
9 # TAB |
||||
] |
||||
|
||||
constructor: (settings) -> |
||||
@limit = settings.limit || 5 |
||||
@position = settings.position || "bottom" |
||||
|
||||
@rules = settings.rules |
||||
validateRule(rule) for rule in @rules |
||||
|
||||
@expressions = (getRegExp(rule) for rule in @rules) |
||||
|
||||
@matched = -1 |
||||
@loaded = true |
||||
|
||||
# Reactive dependencies for current matching rule and filter |
||||
@ruleDep = new Deps.Dependency |
||||
@filterDep = new Deps.Dependency |
||||
@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. |
||||
@sub = null |
||||
@comp = Deps.autorun => |
||||
# Stop any existing sub immediately, don't wait |
||||
@sub?.stop() |
||||
|
||||
return unless (rule = @matchedRule()) and (filter = @getFilter()) isnt null |
||||
|
||||
# subscribe only for server-side collections |
||||
unless isServerSearch(rule) |
||||
@setLoaded(true) # Immediately loaded |
||||
return |
||||
|
||||
[ selector, options ] = getFindParams(rule, filter, @limit) |
||||
|
||||
# console.debug 'Subscribing to <%s> in <%s>.<%s>', filter, rule.collection, rule.field |
||||
@setLoaded(false) |
||||
subName = rule.subscription || "autocomplete-recordset" |
||||
@sub = Meteor.subscribe(subName, |
||||
selector, options, rule.collection, => @setLoaded(true)) |
||||
|
||||
teardown: -> |
||||
# Stop the reactive computation we started for this autocomplete instance |
||||
@comp.stop() |
||||
|
||||
# reactive getters and setters for @filter and the currently matched rule |
||||
matchedRule: -> |
||||
@ruleDep.depend() |
||||
if @matched >= 0 then @rules[@matched] else null |
||||
|
||||
setMatchedRule: (i) -> |
||||
@matched = i |
||||
@ruleDep.changed() |
||||
|
||||
getFilter: -> |
||||
@filterDep.depend() |
||||
return @filter |
||||
|
||||
setFilter: (x) -> |
||||
@filter = x |
||||
@filterDep.changed() |
||||
return @filter |
||||
|
||||
isLoaded: -> |
||||
@loadingDep.depend() |
||||
return @loaded |
||||
|
||||
setLoaded: (val) -> |
||||
return if val is @loaded # Don't cause redraws unnecessarily |
||||
@loaded = val |
||||
@loadingDep.changed() |
||||
|
||||
onKeyUp: -> |
||||
return unless @$element # Don't try to do this while loading |
||||
startpos = @element.selectionStart |
||||
val = @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. |
||||
### |
||||
i = 0 |
||||
breakLoop = false |
||||
while i < @expressions.length |
||||
matches = val.match(@expressions[i]) |
||||
|
||||
# matching -> not matching |
||||
if not matches and @matched is i |
||||
@setMatchedRule(-1) |
||||
breakLoop = true |
||||
|
||||
# not matching -> matching |
||||
if matches and @matched is -1 |
||||
@setMatchedRule(i) |
||||
breakLoop = true |
||||
|
||||
# Did filter change? |
||||
if matches and @filter isnt matches[2] |
||||
@setFilter(matches[2]) |
||||
breakLoop = true |
||||
|
||||
break if breakLoop |
||||
i++ |
||||
|
||||
onKeyDown: (e) -> |
||||
return if @matched is -1 or (@constructor.KEYS.indexOf(e.keyCode) < 0) |
||||
|
||||
switch e.keyCode |
||||
when 9, 13 # TAB, ENTER |
||||
if @select() # Don't jump fields or submit if select successful |
||||
e.preventDefault() |
||||
e.stopPropagation() |
||||
# preventDefault needed below to avoid moving cursor when selecting |
||||
when 40 # DOWN |
||||
e.preventDefault() |
||||
@next() |
||||
when 38 # UP |
||||
e.preventDefault() |
||||
@prev() |
||||
when 27 # ESCAPE |
||||
@$element.blur() |
||||
@hideList() |
||||
|
||||
return |
||||
|
||||
onFocus: -> |
||||
# We need to run onKeyUp after the focus resolves, |
||||
# or the caret position (selectionStart) will not be correct |
||||
Meteor.defer => @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 => |
||||
@hideList() |
||||
, 500 |
||||
|
||||
onItemClick: (doc, e) => @processSelection(doc, @rules[@matched]) |
||||
|
||||
onItemHover: (doc, e) -> |
||||
@tmplInst.$(".-autocomplete-item").removeClass("selected") |
||||
$(e.target).closest(".-autocomplete-item").addClass("selected") |
||||
|
||||
filteredList: -> |
||||
# @ruleDep.depend() # optional as long as we use depend on filter, because list will always get re-rendered |
||||
filter = @getFilter() # Reactively depend on the filter |
||||
return null if @matched is -1 |
||||
|
||||
rule = @rules[@matched] |
||||
# Don't display list unless we have a token or a filter (or both) |
||||
# Single field: nothing displayed until something is typed |
||||
return null unless rule.token or filter |
||||
|
||||
[ selector, options ] = getFindParams(rule, filter, @limit) |
||||
|
||||
Meteor.defer => @ensureSelection() |
||||
|
||||
# if server collection, the server has already done the filtering work |
||||
return AutoCompleteRecords.find({}, options) if isServerSearch(rule) |
||||
|
||||
# Otherwise, search on client |
||||
return rule.collection.find(selector, options) |
||||
|
||||
isShowing: -> |
||||
rule = @matchedRule() |
||||
# Same rules as above |
||||
showing = rule? and (rule.token or @getFilter()) |
||||
|
||||
# Do this after the render |
||||
if showing |
||||
Meteor.defer => |
||||
@positionContainer() |
||||
@ensureSelection() |
||||
|
||||
return showing |
||||
|
||||
# Replace text with currently selected item |
||||
select: -> |
||||
node = @tmplInst.find(".-autocomplete-item.selected") |
||||
return false unless node? |
||||
doc = Blaze.getData(node) |
||||
return false unless doc # Don't select if nothing matched |
||||
|
||||
@processSelection(doc, @rules[@matched]) |
||||
return true |
||||
|
||||
processSelection: (doc, rule) -> |
||||
replacement = getField(doc, rule.field) |
||||
|
||||
unless isWholeField(rule) |
||||
@replace(replacement, rule) |
||||
@hideList() |
||||
|
||||
else |
||||
# Empty string or doesn't exist? |
||||
# Single-field replacement: replace whole field |
||||
@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 |
||||
@onBlur() |
||||
|
||||
@$element.trigger("autocompleteselect", doc) |
||||
return |
||||
|
||||
# Replace the appropriate region |
||||
replace: (replacement) -> |
||||
startpos = @element.selectionStart |
||||
fullStuff = @getText() |
||||
val = fullStuff.substring(0, startpos) |
||||
val = val.replace(@expressions[@matched], "$1" + @rules[@matched].token + replacement) |
||||
posfix = fullStuff.substring(startpos, fullStuff.length) |
||||
separator = (if posfix.match(/^\s/) then "" else " ") |
||||
finalFight = val + separator + posfix |
||||
@setText finalFight |
||||
|
||||
newPosition = val.length + 1 |
||||
@element.setSelectionRange(newPosition, newPosition) |
||||
return |
||||
|
||||
hideList: -> |
||||
@setMatchedRule(-1) |
||||
@setFilter(null) |
||||
|
||||
getText: -> |
||||
return @$element.val() || @$element.text() |
||||
|
||||
setText: (text) -> |
||||
if @$element.is("input,textarea") |
||||
@$element.val(text) |
||||
else |
||||
@$element.html(text) |
||||
|
||||
### |
||||
Rendering functions |
||||
### |
||||
positionContainer: -> |
||||
# First render; Pick the first item and set css whenever list gets shown |
||||
position = @$element.position() |
||||
|
||||
rule = @matchedRule() |
||||
|
||||
offset = getCaretCoordinates(@element, @element.selectionStart) |
||||
|
||||
# In whole-field positioning, we don't move the container and make it the |
||||
# full width of the field. |
||||
if rule? and isWholeField(rule) |
||||
pos = |
||||
left: position.left |
||||
width: @$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 @position is "top" |
||||
pos.bottom = @$element.offsetParent().height() - position.top - offset.top |
||||
else |
||||
pos.top = position.top + offset.top + parseInt(@$element.css('font-size')) |
||||
|
||||
@tmplInst.$(".-autocomplete-container").css(pos) |
||||
|
||||
ensureSelection : -> |
||||
# Re-render; make sure selected item is something in the list or none if list empty |
||||
selectedItem = @tmplInst.$(".-autocomplete-item.selected") |
||||
|
||||
unless selectedItem.length |
||||
# Select anything |
||||
@tmplInst.$(".-autocomplete-item:first-child").addClass("selected") |
||||
|
||||
# Select next item in list |
||||
next: -> |
||||
currentItem = @tmplInst.$(".-autocomplete-item.selected") |
||||
return unless currentItem.length # Don't try to iterate an empty list |
||||
currentItem.removeClass("selected") |
||||
|
||||
next = currentItem.next() |
||||
if next.length |
||||
next.addClass("selected") |
||||
else # End of list or lost selection; Go back to first item |
||||
@tmplInst.$(".-autocomplete-item:first-child").addClass("selected") |
||||
|
||||
# Select previous item in list |
||||
prev: -> |
||||
currentItem = @tmplInst.$(".-autocomplete-item.selected") |
||||
return unless currentItem.length # Don't try to iterate an empty list |
||||
currentItem.removeClass("selected") |
||||
|
||||
prev = currentItem.prev() |
||||
if prev.length |
||||
prev.addClass("selected") |
||||
else # Beginning of list or lost selection; Go to end of list |
||||
@tmplInst.$(".-autocomplete-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: -> @rules[@matched].template |
||||
|
||||
AutocompleteTest = |
||||
records: AutoCompleteRecords |
||||
getRegExp: getRegExp |
||||
getFindParams: getFindParams |
@ -0,0 +1,27 @@ |
||||
class Autocomplete |
||||
@publishCursor: (cursor, sub) -> |
||||
# This also attaches an onStop callback to sub, so we don't need to worry about that. |
||||
# https://github.com/meteor/meteor/blob/devel/packages/mongo/collection.js |
||||
Mongo.Collection._publishCursor(cursor, sub, "autocompleteRecords") |
||||
|
||||
Meteor.publish 'autocomplete-recordset', (selector, options, collName) -> |
||||
collection = global[collName] |
||||
unless collection |
||||
throw new Error(collName + ' is not defined on the global namespace of the server.') |
||||
|
||||
# This is a semi-documented Meteor feature: |
||||
# https://github.com/meteor/meteor/blob/devel/packages/mongo-livedata/collection.js |
||||
unless collection._isInsecure() |
||||
Meteor._debug(collName + ' is a secure collection, therefore no data was returned because the client could compromise security by subscribing to arbitrary server collections via the browser console. Please write your own publish function.') |
||||
return [] # We need this for the subscription to be marked ready |
||||
|
||||
# guard against client-side DOS: hard limit to 50 |
||||
options.limit = Math.min(50, Math.abs(options.limit)) if options.limit |
||||
|
||||
# Push this into our own collection on the client so they don't interfere with other publications of the named collection. |
||||
# This also stops the observer automatically when the subscription is stopped. |
||||
Autocomplete.publishCursor( collection.find(selector, options), this) |
||||
|
||||
# Mark the subscription ready after the initial addition of documents. |
||||
this.ready() |
||||
|
@ -0,0 +1,27 @@ |
||||
.-autocomplete-container { |
||||
position: absolute; |
||||
background: white; |
||||
border: 1px solid #DDD; |
||||
border-radius: 3px; |
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); |
||||
min-width: 180px; |
||||
z-index: 1000; |
||||
} |
||||
|
||||
.-autocomplete-list { |
||||
list-style: none; |
||||
margin: 0; |
||||
padding: 0; |
||||
} |
||||
|
||||
.-autocomplete-item { |
||||
display: block; |
||||
padding: 5px 10px; |
||||
border-bottom: 1px solid #DDD; |
||||
} |
||||
|
||||
.-autocomplete-item.selected { |
||||
color: white; |
||||
background: #4183C4; |
||||
text-decoration: none; |
||||
} |
@ -0,0 +1,39 @@ |
||||
<template name="inputAutocomplete"> |
||||
<input type="text" {{attributes}}> |
||||
{{> autocompleteContainer}} |
||||
</template> |
||||
|
||||
<template name="textareaAutocomplete"> |
||||
<textarea {{attributes}}>{{> UI.contentBlock}}</textarea> |
||||
{{> autocompleteContainer}} |
||||
</template> |
||||
|
||||
<template name="_autocompleteContainer"> |
||||
{{#if isShowing}} |
||||
<div class='-autocomplete-container'> |
||||
{{#if isLoaded}} |
||||
{{#unless empty}} |
||||
<ul class='-autocomplete-list'> |
||||
{{#each filteredList}} |
||||
<li class="-autocomplete-item"> |
||||
{{#with ../currentTemplate }} |
||||
{{#with ..}} {{! original 'data' context to itemTemplate}} |
||||
{{> ..}} {{! return value from itemTemplate }} |
||||
{{/with}} |
||||
{{/with}} |
||||
</li> |
||||
{{/each}} |
||||
</ul> |
||||
{{else}} |
||||
{{> noMatchTemplate }} |
||||
{{/unless}} |
||||
{{else}} |
||||
{{> loading}} |
||||
{{/if}} |
||||
</div> |
||||
{{/if}} |
||||
</template> |
||||
|
||||
<template name="_noMatch"> |
||||
(<i>no matches</i>) |
||||
</template> |
@ -0,0 +1,44 @@ |
||||
Package.describe({ |
||||
name: "mizzao:autocomplete", |
||||
summary: "Client/server autocompletion designed for Meteor's collections and reactivity", |
||||
version: "0.5.1", |
||||
git: "https://github.com/mizzao/meteor-autocomplete.git" |
||||
}); |
||||
|
||||
Package.onUse(function (api) { |
||||
api.versionsFrom("1.0"); |
||||
|
||||
api.use(['blaze', 'templating', 'jquery'], 'client'); |
||||
api.use(['coffeescript', 'underscore']); // both
|
||||
api.use(['mongo', 'ddp']); |
||||
|
||||
api.use("dandv:caret-position@2.1.0-3", 'client'); |
||||
|
||||
// Our files
|
||||
api.addFiles([ |
||||
'autocomplete.css', |
||||
'inputs.html', |
||||
'autocomplete-client.coffee', |
||||
'templates.coffee' |
||||
], 'client'); |
||||
|
||||
api.addFiles([ |
||||
'autocomplete-server.coffee' |
||||
], 'server'); |
||||
|
||||
api.export('Autocomplete', 'server'); |
||||
api.export('AutocompleteTest', {testOnly: true}); |
||||
}); |
||||
|
||||
Package.onTest(function(api) { |
||||
api.use("mizzao:autocomplete"); |
||||
|
||||
api.use('coffeescript'); |
||||
api.use('mongo'); |
||||
api.use('tinytest'); |
||||
|
||||
api.addFiles('tests/rule_tests.coffee', 'client'); |
||||
api.addFiles('tests/regex_tests.coffee', 'client'); |
||||
api.addFiles('tests/param_tests.coffee', 'client'); |
||||
api.addFiles('tests/security_tests.coffee'); |
||||
}); |
@ -0,0 +1,50 @@ |
||||
# Events on template instances, sent to the autocomplete class |
||||
acEvents = |
||||
"keydown": (e, t) -> t.ac.onKeyDown(e) |
||||
"keyup": (e, t) -> t.ac.onKeyUp(e) |
||||
"focus": (e, t) -> t.ac.onFocus(e) |
||||
"blur": (e, t) -> t.ac.onBlur(e) |
||||
|
||||
Template.inputAutocomplete.events(acEvents) |
||||
Template.textareaAutocomplete.events(acEvents) |
||||
|
||||
attributes = -> _.omit(@, 'settings') # Render all but the settings parameter |
||||
|
||||
autocompleteHelpers = { |
||||
attributes, |
||||
autocompleteContainer: new Template('AutocompleteContainer', -> |
||||
ac = new AutoComplete( Blaze.getData().settings ) |
||||
# Set the autocomplete object on the parent template instance |
||||
this.parentView.templateInstance().ac = ac |
||||
|
||||
# Set nodes on render in the autocomplete class |
||||
this.onViewReady -> |
||||
ac.element = this.parentView.firstNode() |
||||
ac.$element = $(ac.element) |
||||
|
||||
return Blaze.With(ac, -> Template._autocompleteContainer) |
||||
) |
||||
} |
||||
|
||||
Template.inputAutocomplete.helpers(autocompleteHelpers) |
||||
Template.textareaAutocomplete.helpers(autocompleteHelpers) |
||||
|
||||
Template._autocompleteContainer.rendered = -> |
||||
@data.tmplInst = this |
||||
|
||||
Template._autocompleteContainer.destroyed = -> |
||||
# Meteor._debug "autocomplete destroyed" |
||||
@data.teardown() |
||||
|
||||
### |
||||
List rendering helpers |
||||
### |
||||
|
||||
Template._autocompleteContainer.events |
||||
# t.data is the AutoComplete instance; `this` is the data item |
||||
"click .-autocomplete-item": (e, t) -> t.data.onItemClick(this, e) |
||||
"mouseenter .-autocomplete-item": (e, t) -> t.data.onItemHover(this, e) |
||||
|
||||
Template._autocompleteContainer.helpers |
||||
empty: -> @filteredList().count() is 0 |
||||
noMatchTemplate: -> @matchedRule().noMatchTemplate || Template._noMatch |
Loading…
Reference in new issue