forked from Meteor-Community-Packages/meteor-autocomplete
-
Notifications
You must be signed in to change notification settings - Fork 0
/
autocomplete-client.coffee
371 lines (292 loc) · 11.1 KB
/
autocomplete-client.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
AutoCompleteRecords = new Mongo.Collection("autocompleteRecords")
isServerSearch = (rule) -> rule.subscription? || _.isString(rule.collection)
validateRule = (rule) ->
if rule.subscription? and rule.collection?
throw new Error("Rule cannot specify both a server-side subscription and a client/server collection to search simultaneously")
unless rule.subscription? or Match.test(rule.collection, Match.OneOf(String, Mongo.Collection))
throw new Error("Collection to search must be either a Mongo collection or server-side name")
# 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
isServerSearch: isServerSearch
getRegExp: getRegExp
getFindParams: getFindParams