thredded 0.7.0 → 0.8.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +119 -17
  3. data/app/assets/javascripts/thredded/components/currently_online.es6 +3 -3
  4. data/app/assets/javascripts/thredded/components/flash_messages.es6 +11 -0
  5. data/app/assets/javascripts/thredded/components/post_form.es6 +21 -4
  6. data/app/assets/javascripts/thredded/components/time_stamps.es6 +8 -6
  7. data/app/assets/javascripts/thredded/components/topic_form.es6 +14 -3
  8. data/app/assets/javascripts/thredded/components/topics.es6 +3 -3
  9. data/app/assets/javascripts/thredded/components/turboforms.es6 +15 -0
  10. data/app/assets/javascripts/thredded/components/user_preferences_form.es6 +47 -20
  11. data/app/assets/javascripts/thredded/components/users_select.es6 +25 -9
  12. data/app/assets/javascripts/thredded/core/hide_soft_keyboard.es6 +6 -0
  13. data/app/assets/javascripts/thredded/core/mention_autocompletion.es6 +54 -0
  14. data/app/assets/javascripts/thredded/core/on_page_load.es6 +46 -0
  15. data/app/assets/javascripts/thredded/dependencies.js +2 -1
  16. data/app/assets/javascripts/thredded/dependencies/jquery.js +1 -0
  17. data/app/assets/javascripts/thredded/thredded.es6 +1 -0
  18. data/app/assets/stylesheets/thredded/_thredded.scss +1 -0
  19. data/app/assets/stylesheets/thredded/base/_alerts.scss +5 -1
  20. data/app/assets/stylesheets/thredded/base/_grid.scss +8 -0
  21. data/app/assets/stylesheets/thredded/base/_nav.scss +0 -5
  22. data/app/assets/stylesheets/thredded/components/_following.scss +0 -3
  23. data/app/assets/stylesheets/thredded/components/_mention-autocomplete.scss +35 -0
  24. data/app/assets/stylesheets/thredded/components/_topic-header.scss +37 -17
  25. data/app/assets/stylesheets/thredded/components/_topics.scss +13 -0
  26. data/app/assets/stylesheets/thredded/layout/_main-navigation.scss +57 -14
  27. data/app/assets/stylesheets/thredded/layout/_moderation.scss +5 -0
  28. data/app/assets/stylesheets/thredded/layout/_navigation.scss +14 -17
  29. data/app/assets/stylesheets/thredded/layout/_search-navigation.scss +15 -3
  30. data/app/assets/stylesheets/thredded/layout/_user-navigation.scss +3 -11
  31. data/app/commands/thredded/at_notification_extractor.rb +2 -2
  32. data/app/commands/thredded/autofollow_mentioned_users.rb +2 -2
  33. data/app/commands/thredded/create_messageboard.rb +45 -0
  34. data/app/commands/thredded/notify_following_users.rb +10 -0
  35. data/app/controllers/thredded/autocomplete_users_controller.rb +2 -3
  36. data/app/controllers/thredded/messageboards_controller.rb +1 -24
  37. data/app/controllers/thredded/moderation_controller.rb +1 -1
  38. data/app/controllers/thredded/post_permalinks_controller.rb +1 -1
  39. data/app/controllers/thredded/preferences_controller.rb +4 -2
  40. data/app/controllers/thredded/private_post_permalinks_controller.rb +1 -1
  41. data/app/controllers/thredded/theme_previews_controller.rb +1 -1
  42. data/app/controllers/thredded/topics_controller.rb +0 -1
  43. data/app/forms/thredded/user_preferences_form.rb +4 -2
  44. data/app/helpers/thredded/application_helper.rb +5 -0
  45. data/app/helpers/thredded/nav_helper.rb +41 -0
  46. data/app/mailer_previews/thredded/base_mailer_preview.rb +0 -1
  47. data/app/mailers/thredded/post_mailer.rb +0 -1
  48. data/app/mailers/thredded/private_topic_mailer.rb +0 -1
  49. data/app/models/concerns/thredded/user_topic_read_state_common.rb +2 -2
  50. data/app/models/thredded/messageboard.rb +0 -1
  51. data/app/models/thredded/null_preference.rb +5 -1
  52. data/app/models/thredded/private_post.rb +2 -2
  53. data/app/policies/thredded/messageboard_group_policy.rb +1 -1
  54. data/app/view_hooks/thredded/all_view_hooks.rb +68 -0
  55. data/app/view_models/thredded/post_view.rb +3 -3
  56. data/app/view_models/thredded/topic_email_view.rb +0 -4
  57. data/app/view_models/thredded/topics_page_view.rb +12 -1
  58. data/app/views/layouts/thredded/application.html.erb +5 -2
  59. data/app/views/thredded/messageboard_groups/new.html.erb +3 -1
  60. data/app/views/thredded/messageboards/edit.html.erb +3 -1
  61. data/app/views/thredded/messageboards/new.html.erb +3 -1
  62. data/app/views/thredded/moderation/_users_search_form.html.erb +4 -1
  63. data/app/views/thredded/moderation/user.html.erb +34 -28
  64. data/app/views/thredded/posts_common/_form.html.erb +6 -1
  65. data/app/views/thredded/posts_common/form/_content_field.html.erb +5 -3
  66. data/app/views/thredded/preferences/_form.html.erb +30 -12
  67. data/app/views/thredded/private_topics/_form.html.erb +4 -3
  68. data/app/views/thredded/private_topics/index.html.erb +5 -5
  69. data/app/views/thredded/search/_form.html.erb +2 -1
  70. data/app/views/thredded/shared/_flash_messages.html.erb +1 -1
  71. data/app/views/thredded/shared/_page.html.erb +10 -1
  72. data/app/views/thredded/shared/nav/_moderation.html.erb +3 -2
  73. data/app/views/thredded/shared/nav/_notification_preferences.html.erb +5 -3
  74. data/app/views/thredded/shared/nav/_private_topics.html.erb +3 -2
  75. data/app/views/thredded/topics/_form.html.erb +6 -1
  76. data/app/views/thredded/topics/_header.html.erb +8 -5
  77. data/config/locales/en.yml +15 -7
  78. data/config/locales/pt-BR.yml +21 -13
  79. data/config/routes.rb +4 -2
  80. data/db/migrate/20160329231848_create_thredded.rb +5 -5
  81. data/db/upgrade_migrations/20161019150201_upgrade_v0_7_to_v0_8.rb +31 -0
  82. data/lib/generators/thredded/install/templates/initializer.rb +20 -8
  83. data/lib/tasks/thredded_tasks.rake +0 -7
  84. data/lib/thredded.rb +19 -5
  85. data/lib/thredded/content_formatter.rb +43 -8
  86. data/lib/thredded/database_seeder.rb +7 -1
  87. data/lib/thredded/engine.rb +2 -21
  88. data/lib/{html/pipeline → thredded/html_pipeline}/at_mention_filter.rb +4 -4
  89. data/lib/thredded/html_pipeline/autolink_filter.rb +14 -0
  90. data/lib/thredded/html_pipeline/kramdown_filter.rb +34 -0
  91. data/lib/thredded/version.rb +1 -1
  92. data/lib/thredded/view_hooks/config.rb +36 -0
  93. data/lib/thredded/view_hooks/renderer.rb +29 -0
  94. data/vendor/assets/javascripts/jquery.textcomplete.js +1488 -0
  95. metadata +65 -52
  96. data/app/commands/thredded/messageboard_destroyer.rb +0 -65
  97. data/lib/html/pipeline/bbcode_filter.rb +0 -33
  98. data/lib/thredded/main_app_route_delegator.rb +0 -25
@@ -39,18 +39,53 @@ module Thredded
39
39
  }
40
40
  )
41
41
 
42
- # HTML::Pipeline filters.
43
- mattr_accessor :pipeline_filters
44
-
45
- self.pipeline_filters = [
42
+ # Filters that run before processing the markup.
43
+ # input: markup, output: markup.
44
+ mattr_accessor :before_markup_filters
45
+ self.before_markup_filters = [
46
46
  HTML::Pipeline::VimeoFilter,
47
47
  HTML::Pipeline::YoutubeFilter,
48
- HTML::Pipeline::BbcodeFilter,
49
- HTML::Pipeline::MarkdownFilter,
50
- HTML::Pipeline::AtMentionFilter,
48
+ ]
49
+
50
+ # Markup filters, such as BBCode, Markdown, Autolink, etc.
51
+ # input: markup, output: html.
52
+ mattr_accessor :markup_filters
53
+ self.markup_filters = [
54
+ Thredded::HtmlPipeline::KramdownFilter,
55
+ ]
56
+
57
+ # Filters that run after processing the markup.
58
+ # input: html, output: html.
59
+ mattr_accessor :after_markup_filters
60
+ self.after_markup_filters = [
61
+ # AutolinkFilter is required because Kramdown does not autolink by default.
62
+ # https://github.com/gettalong/kramdown/issues/306
63
+ Thredded::HtmlPipeline::AutolinkFilter,
51
64
  HTML::Pipeline::EmojiFilter,
65
+ Thredded::HtmlPipeline::AtMentionFilter,
52
66
  HTML::Pipeline::SanitizationFilter,
53
- ].freeze
67
+ ]
68
+
69
+ # Filters that sanitize the resulting HTML.
70
+ # input: html, output: sanitized html.
71
+ mattr_accessor :sanitization_filters
72
+ self.sanitization_filters = [
73
+ HTML::Pipeline::SanitizationFilter,
74
+ ]
75
+
76
+ # All the HTML::Pipeline filters, read-only.
77
+ def self.pipeline_filters
78
+ filters = [
79
+ *before_markup_filters,
80
+ *markup_filters,
81
+ *after_markup_filters,
82
+ *sanitization_filters,
83
+ ]
84
+ # Changing the result in-place has no effect on the ContentFormatter output,
85
+ # and is most likely the result of a programmer error.
86
+ # Freeze the array so that in-place changes raise an error.
87
+ filters.freeze
88
+ end
54
89
 
55
90
  # @param view_context [Object] the context of the rendering view.
56
91
  # @param pipeline_options [Hash]
@@ -131,10 +131,15 @@ module Thredded
131
131
  class BaseSeedData
132
132
  attr_reader :seeder
133
133
 
134
- def initialize(seed_database)
134
+ def initialize(seed_database = DatabaseSeeder.new)
135
135
  @seeder = seed_database
136
136
  end
137
137
 
138
+ # Utility method
139
+ def self.create(*args)
140
+ new.create(*args)
141
+ end
142
+
138
143
  delegate :log, to: :seeder
139
144
 
140
145
  def find_or_create(*args)
@@ -181,6 +186,7 @@ module Thredded
181
186
  end
182
187
  end
183
188
 
189
+ # Thredded::DatabaseSeeder::Users.create(count:200)
184
190
  class Users < CollectionSeedData
185
191
  MODEL_CLASS = User
186
192
 
@@ -1,10 +1,9 @@
1
1
  # frozen_string_literal: true
2
- require_dependency 'thredded/main_app_route_delegator'
3
2
  module Thredded
4
3
  class Engine < ::Rails::Engine
5
4
  isolate_namespace Thredded
6
5
 
7
- %w(app/view_models app/forms app/commands app/jobs lib).each do |path|
6
+ %w(app/view_hooks app/view_models app/forms app/commands app/jobs lib).each do |path|
8
7
  config.autoload_paths << File.expand_path("../../#{path}", File.dirname(__FILE__))
9
8
  end
10
9
 
@@ -15,12 +14,10 @@ module Thredded
15
14
  end
16
15
 
17
16
  config.to_prepare do
17
+ Thredded::AllViewHooks.reset_instance!
18
18
  if Thredded.user_class
19
19
  Thredded.user_class.send(:include, Thredded::UserExtender)
20
20
  end
21
-
22
- # Delegate all main_app routes to allow calling them directly.
23
- ::Thredded::ApplicationController.helper ::Thredded::MainAppRouteDelegator
24
21
  end
25
22
 
26
23
  initializer 'thredded.setup_assets' do
@@ -30,21 +27,5 @@ module Thredded
30
27
  thredded/*.svg
31
28
  )
32
29
  end
33
-
34
- initializer 'thredded.setup_bbcoder' do
35
- BBCoder.configure do
36
- tag :img, match: %r{^https?://.*(png|bmp|jpe?g|gif)$}, singular: false do
37
- %(<img src="#{singular? ? meta : content}" />)
38
- end
39
-
40
- tag :spoilers do
41
- %(<span class="thredded--post--content--spoiler">#{content}</span>)
42
- end
43
-
44
- tag :spoiler do
45
- %(<span class="thredded--post--content--spoiler">#{content}</span>)
46
- end
47
- end
48
- end
49
30
  end
50
31
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
- module HTML
3
- class Pipeline
4
- class AtMentionFilter < Filter
2
+ module Thredded
3
+ module HtmlPipeline
4
+ class AtMentionFilter < ::HTML::Pipeline::Filter
5
5
  DEFAULT_IGNORED_ANCESTOR_TAGS = %w(pre code tt a style).freeze
6
6
 
7
7
  # @param context [Hash]
8
- # @options context :users_provider [#call(usernames)] given usernames, returns a list of users.
8
+ # @option context :users_provider [#call(usernames)] given usernames, returns a list of users.
9
9
  def initialize(doc, context = nil, result = nil)
10
10
  super doc, context, result
11
11
  @users_provider = context[:users_provider]
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module Thredded
3
+ module HtmlPipeline
4
+ # HTML Filter for auto_linking urls in HTML.
5
+ #
6
+ # AutolinkFilter is required because Kramdown does not autolink by default.
7
+ # https://github.com/gettalong/kramdown/issues/306
8
+ class AutolinkFilter < ::HTML::Pipeline::Filter
9
+ def call
10
+ Rinku.auto_link(html, :all)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+ require 'kramdown'
3
+ module Thredded
4
+ module HtmlPipeline
5
+ class KramdownFilter < ::HTML::Pipeline::TextFilter
6
+ class << self
7
+ attr_accessor :options
8
+ end
9
+
10
+ # See http://kramdown.gettalong.org/options.html
11
+ self.options = {
12
+ input: 'GFM',
13
+ gfm_quirks: 'paragraph_end',
14
+ # Smart quotes conflict with @"at mentions". Disable smart quotes.
15
+ smart_quotes: %w(apos apos quot quot),
16
+ remove_block_html_tags: false,
17
+ syntax_highlighter: nil
18
+ }
19
+
20
+ def initialize(text, context = nil, result = nil)
21
+ super text, context, result
22
+ @text.delete! "\r"
23
+ end
24
+
25
+ # Convert Markdown to HTML using the best available implementation
26
+ # and convert into a DocumentFragment.
27
+ def call
28
+ result = Kramdown::Document.new(@text, self.class.options).to_html
29
+ result.rstrip!
30
+ result
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Thredded
3
- VERSION = '0.7.0'
3
+ VERSION = '0.8.2'
4
4
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ module Thredded
3
+ module ViewHooks
4
+ class Config
5
+ def initialize
6
+ # @type Array<Proc>
7
+ @before = []
8
+ # @type Array<Proc>
9
+ @replace = []
10
+ # @type Array<Proc>
11
+ @after = []
12
+ end
13
+
14
+ # @param [Proc] block
15
+ # @return [Array<Proc>]
16
+ def before(&block)
17
+ @before << block if block
18
+ @before
19
+ end
20
+
21
+ # @param [Proc] block
22
+ # @return [Array<Proc>]
23
+ def replace(&block)
24
+ @replace << block if block
25
+ @replace
26
+ end
27
+
28
+ # @param [Proc] block
29
+ # @return [Array<Proc>]
30
+ def after(&block)
31
+ @after << block if block
32
+ @after
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ module Thredded
3
+ module ViewHooks
4
+ class Renderer
5
+ # @param config [Thredded::ViewHooks::Config]
6
+ def initialize(view_context, config)
7
+ @view_context = view_context
8
+ @config = config
9
+ end
10
+
11
+ # @return [String]
12
+ def render(**args, &original_content)
13
+ @view_context.safe_join [
14
+ *@config.before,
15
+ *(@config.replace.presence || [original_content]),
16
+ *@config.after,
17
+ ].map { |proc| render_proc(**args, &proc) }, ''
18
+ end
19
+
20
+ private
21
+
22
+ def render_proc(**args, &proc)
23
+ @view_context.capture do
24
+ @view_context.instance_exec(**args, &proc)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,1488 @@
1
+ /*!
2
+ jQuery.textcomplete 1.7.13
3
+ license: MIT
4
+ https://github.com/yuku-t/jquery-textcomplete
5
+ */
6
+ (function (factory) {
7
+ if (typeof define === 'function' && define.amd) {
8
+ // AMD. Register as an anonymous module.
9
+ define(['jquery'], factory);
10
+ } else if (typeof module === "object" && module.exports) {
11
+ var $ = require('jquery');
12
+ module.exports = factory($);
13
+ } else {
14
+ // Browser globals
15
+ factory(jQuery);
16
+ }
17
+ }(function (jQuery) {
18
+
19
+ /*!
20
+ * jQuery.textcomplete
21
+ *
22
+ * Repository: https://github.com/yuku-t/jquery-textcomplete
23
+ * License: MIT (https://github.com/yuku-t/jquery-textcomplete/blob/master/LICENSE)
24
+ * Author: Yuku Takahashi
25
+ */
26
+
27
+ if (typeof jQuery === 'undefined') {
28
+ throw new Error('jQuery.textcomplete requires jQuery');
29
+ }
30
+
31
+ +function ($) {
32
+ 'use strict';
33
+
34
+ var warn = function (message) {
35
+ if (console.warn) { console.warn(message); }
36
+ };
37
+
38
+ var id = 1;
39
+
40
+ $.fn.textcomplete = function (strategies, option) {
41
+ var args = Array.prototype.slice.call(arguments);
42
+ return this.each(function () {
43
+ var self = this;
44
+ var $this = $(this);
45
+ var completer = $this.data('textComplete');
46
+ if (!completer) {
47
+ option || (option = {});
48
+ option._oid = id++; // unique object id
49
+ completer = new $.fn.textcomplete.Completer(this, option);
50
+ $this.data('textComplete', completer);
51
+ }
52
+ if (typeof strategies === 'string') {
53
+ if (!completer) return;
54
+ args.shift()
55
+ completer[strategies].apply(completer, args);
56
+ if (strategies === 'destroy') {
57
+ $this.removeData('textComplete');
58
+ }
59
+ } else {
60
+ // For backward compatibility.
61
+ // TODO: Remove at v0.4
62
+ $.each(strategies, function (obj) {
63
+ $.each(['header', 'footer', 'placement', 'maxCount'], function (name) {
64
+ if (obj[name]) {
65
+ completer.option[name] = obj[name];
66
+ warn(name + 'as a strategy param is deprecated. Use option.');
67
+ delete obj[name];
68
+ }
69
+ });
70
+ });
71
+ completer.register($.fn.textcomplete.Strategy.parse(strategies, {
72
+ el: self,
73
+ $el: $this
74
+ }));
75
+ }
76
+ });
77
+ };
78
+
79
+ }(jQuery);
80
+
81
+ +function ($) {
82
+ 'use strict';
83
+
84
+ // Exclusive execution control utility.
85
+ //
86
+ // func - The function to be locked. It is executed with a function named
87
+ // `free` as the first argument. Once it is called, additional
88
+ // execution are ignored until the free is invoked. Then the last
89
+ // ignored execution will be replayed immediately.
90
+ //
91
+ // Examples
92
+ //
93
+ // var lockedFunc = lock(function (free) {
94
+ // setTimeout(function { free(); }, 1000); // It will be free in 1 sec.
95
+ // console.log('Hello, world');
96
+ // });
97
+ // lockedFunc(); // => 'Hello, world'
98
+ // lockedFunc(); // none
99
+ // lockedFunc(); // none
100
+ // // 1 sec past then
101
+ // // => 'Hello, world'
102
+ // lockedFunc(); // => 'Hello, world'
103
+ // lockedFunc(); // none
104
+ //
105
+ // Returns a wrapped function.
106
+ var lock = function (func) {
107
+ var locked, queuedArgsToReplay;
108
+
109
+ return function () {
110
+ // Convert arguments into a real array.
111
+ var args = Array.prototype.slice.call(arguments);
112
+ if (locked) {
113
+ // Keep a copy of this argument list to replay later.
114
+ // OK to overwrite a previous value because we only replay
115
+ // the last one.
116
+ queuedArgsToReplay = args;
117
+ return;
118
+ }
119
+ locked = true;
120
+ var self = this;
121
+ args.unshift(function replayOrFree() {
122
+ if (queuedArgsToReplay) {
123
+ // Other request(s) arrived while we were locked.
124
+ // Now that the lock is becoming available, replay
125
+ // the latest such request, then call back here to
126
+ // unlock (or replay another request that arrived
127
+ // while this one was in flight).
128
+ var replayArgs = queuedArgsToReplay;
129
+ queuedArgsToReplay = undefined;
130
+ replayArgs.unshift(replayOrFree);
131
+ func.apply(self, replayArgs);
132
+ } else {
133
+ locked = false;
134
+ }
135
+ });
136
+ func.apply(this, args);
137
+ };
138
+ };
139
+
140
+ var isString = function (obj) {
141
+ return Object.prototype.toString.call(obj) === '[object String]';
142
+ };
143
+
144
+ var uniqueId = 0;
145
+
146
+ function Completer(element, option) {
147
+ this.$el = $(element);
148
+ this.id = 'textcomplete' + uniqueId++;
149
+ this.strategies = [];
150
+ this.views = [];
151
+ this.option = $.extend({}, Completer.defaults, option);
152
+
153
+ if (!this.$el.is('input[type=text]') && !this.$el.is('input[type=search]') && !this.$el.is('textarea') && !element.isContentEditable && element.contentEditable != 'true') {
154
+ throw new Error('textcomplete must be called on a Textarea or a ContentEditable.');
155
+ }
156
+
157
+ // use ownerDocument to fix iframe / IE issues
158
+ if (element === element.ownerDocument.activeElement) {
159
+ // element has already been focused. Initialize view objects immediately.
160
+ this.initialize()
161
+ } else {
162
+ // Initialize view objects lazily.
163
+ var self = this;
164
+ this.$el.one('focus.' + this.id, function () { self.initialize(); });
165
+
166
+ // Special handling for CKEditor: lazy init on instance load
167
+ if ((!this.option.adapter || this.option.adapter == 'CKEditor') && typeof CKEDITOR != 'undefined' && (this.$el.is('textarea'))) {
168
+ CKEDITOR.on("instanceReady", function(event) {
169
+ event.editor.once("focus", function(event2) {
170
+ // replace the element with the Iframe element and flag it as CKEditor
171
+ self.$el = $(event.editor.editable().$);
172
+ if (!self.option.adapter) {
173
+ self.option.adapter = $.fn.textcomplete['CKEditor'];
174
+ self.option.ckeditor_instance = event.editor;
175
+ }
176
+ self.initialize();
177
+ });
178
+ });
179
+ }
180
+ }
181
+ }
182
+
183
+ Completer.defaults = {
184
+ appendTo: 'body',
185
+ className: '', // deprecated option
186
+ dropdownClassName: 'dropdown-menu textcomplete-dropdown',
187
+ maxCount: 10,
188
+ zIndex: '100',
189
+ rightEdgeOffset: 30
190
+ };
191
+
192
+ $.extend(Completer.prototype, {
193
+ // Public properties
194
+ // -----------------
195
+
196
+ id: null,
197
+ option: null,
198
+ strategies: null,
199
+ adapter: null,
200
+ dropdown: null,
201
+ $el: null,
202
+ $iframe: null,
203
+
204
+ // Public methods
205
+ // --------------
206
+
207
+ initialize: function () {
208
+ var element = this.$el.get(0);
209
+
210
+ // check if we are in an iframe
211
+ // we need to alter positioning logic if using an iframe
212
+ if (this.$el.prop('ownerDocument') !== document && window.frames.length) {
213
+ for (var iframeIndex = 0; iframeIndex < window.frames.length; iframeIndex++) {
214
+ if (this.$el.prop('ownerDocument') === window.frames[iframeIndex].document) {
215
+ this.$iframe = $(window.frames[iframeIndex].frameElement);
216
+ break;
217
+ }
218
+ }
219
+ }
220
+
221
+
222
+ // Initialize view objects.
223
+ this.dropdown = new $.fn.textcomplete.Dropdown(element, this, this.option);
224
+ var Adapter, viewName;
225
+ if (this.option.adapter) {
226
+ Adapter = this.option.adapter;
227
+ } else {
228
+ if (this.$el.is('textarea') || this.$el.is('input[type=text]') || this.$el.is('input[type=search]')) {
229
+ viewName = typeof element.selectionEnd === 'number' ? 'Textarea' : 'IETextarea';
230
+ } else {
231
+ viewName = 'ContentEditable';
232
+ }
233
+ Adapter = $.fn.textcomplete[viewName];
234
+ }
235
+ this.adapter = new Adapter(element, this, this.option);
236
+ },
237
+
238
+ destroy: function () {
239
+ this.$el.off('.' + this.id);
240
+ if (this.adapter) {
241
+ this.adapter.destroy();
242
+ }
243
+ if (this.dropdown) {
244
+ this.dropdown.destroy();
245
+ }
246
+ this.$el = this.adapter = this.dropdown = null;
247
+ },
248
+
249
+ deactivate: function () {
250
+ if (this.dropdown) {
251
+ this.dropdown.deactivate();
252
+ }
253
+ },
254
+
255
+ // Invoke textcomplete.
256
+ trigger: function (text, skipUnchangedTerm) {
257
+ if (!this.dropdown) { this.initialize(); }
258
+ text != null || (text = this.adapter.getTextFromHeadToCaret());
259
+ var searchQuery = this._extractSearchQuery(text);
260
+ if (searchQuery.length) {
261
+ var term = searchQuery[1];
262
+ // Ignore shift-key, ctrl-key and so on.
263
+ if (skipUnchangedTerm && this._term === term && term !== "") { return; }
264
+ this._term = term;
265
+ this._search.apply(this, searchQuery);
266
+ } else {
267
+ this._term = null;
268
+ this.dropdown.deactivate();
269
+ }
270
+ },
271
+
272
+ fire: function (eventName) {
273
+ var args = Array.prototype.slice.call(arguments, 1);
274
+ this.$el.trigger(eventName, args);
275
+ return this;
276
+ },
277
+
278
+ register: function (strategies) {
279
+ Array.prototype.push.apply(this.strategies, strategies);
280
+ },
281
+
282
+ // Insert the value into adapter view. It is called when the dropdown is clicked
283
+ // or selected.
284
+ //
285
+ // value - The selected element of the array callbacked from search func.
286
+ // strategy - The Strategy object.
287
+ // e - Click or keydown event object.
288
+ select: function (value, strategy, e) {
289
+ this._term = null;
290
+ this.adapter.select(value, strategy, e);
291
+ this.fire('change').fire('textComplete:select', value, strategy);
292
+ this.adapter.focus();
293
+ },
294
+
295
+ // Private properties
296
+ // ------------------
297
+
298
+ _clearAtNext: true,
299
+ _term: null,
300
+
301
+ // Private methods
302
+ // ---------------
303
+
304
+ // Parse the given text and extract the first matching strategy.
305
+ //
306
+ // Returns an array including the strategy, the query term and the match
307
+ // object if the text matches an strategy; otherwise returns an empty array.
308
+ _extractSearchQuery: function (text) {
309
+ for (var i = 0; i < this.strategies.length; i++) {
310
+ var strategy = this.strategies[i];
311
+ var context = strategy.context(text);
312
+ if (context || context === '') {
313
+ var matchRegexp = $.isFunction(strategy.match) ? strategy.match(text) : strategy.match;
314
+ if (isString(context)) { text = context; }
315
+ var match = text.match(matchRegexp);
316
+ if (match) { return [strategy, match[strategy.index], match]; }
317
+ }
318
+ }
319
+ return []
320
+ },
321
+
322
+ // Call the search method of selected strategy..
323
+ _search: lock(function (free, strategy, term, match) {
324
+ var self = this;
325
+ strategy.search(term, function (data, stillSearching) {
326
+ if (!self.dropdown.shown) {
327
+ self.dropdown.activate();
328
+ }
329
+ if (self._clearAtNext) {
330
+ // The first callback in the current lock.
331
+ self.dropdown.clear();
332
+ self._clearAtNext = false;
333
+ }
334
+ self.dropdown.setPosition(self.adapter.getCaretPosition());
335
+ self.dropdown.render(self._zip(data, strategy, term));
336
+ if (!stillSearching) {
337
+ // The last callback in the current lock.
338
+ free();
339
+ self._clearAtNext = true; // Call dropdown.clear at the next time.
340
+ }
341
+ }, match);
342
+ }),
343
+
344
+ // Build a parameter for Dropdown#render.
345
+ //
346
+ // Examples
347
+ //
348
+ // this._zip(['a', 'b'], 's');
349
+ // //=> [{ value: 'a', strategy: 's' }, { value: 'b', strategy: 's' }]
350
+ _zip: function (data, strategy, term) {
351
+ return $.map(data, function (value) {
352
+ return { value: value, strategy: strategy, term: term };
353
+ });
354
+ }
355
+ });
356
+
357
+ $.fn.textcomplete.Completer = Completer;
358
+ }(jQuery);
359
+
360
+ +function ($) {
361
+ 'use strict';
362
+
363
+ var $window = $(window);
364
+
365
+ var include = function (zippedData, datum) {
366
+ var i, elem;
367
+ var idProperty = datum.strategy.idProperty
368
+ for (i = 0; i < zippedData.length; i++) {
369
+ elem = zippedData[i];
370
+ if (elem.strategy !== datum.strategy) continue;
371
+ if (idProperty) {
372
+ if (elem.value[idProperty] === datum.value[idProperty]) return true;
373
+ } else {
374
+ if (elem.value === datum.value) return true;
375
+ }
376
+ }
377
+ return false;
378
+ };
379
+
380
+ var dropdownViews = {};
381
+ $(document).on('click', function (e) {
382
+ var id = e.originalEvent && e.originalEvent.keepTextCompleteDropdown;
383
+ $.each(dropdownViews, function (key, view) {
384
+ if (key !== id) { view.deactivate(); }
385
+ });
386
+ });
387
+
388
+ var commands = {
389
+ SKIP_DEFAULT: 0,
390
+ KEY_UP: 1,
391
+ KEY_DOWN: 2,
392
+ KEY_ENTER: 3,
393
+ KEY_PAGEUP: 4,
394
+ KEY_PAGEDOWN: 5,
395
+ KEY_ESCAPE: 6
396
+ };
397
+
398
+ // Dropdown view
399
+ // =============
400
+
401
+ // Construct Dropdown object.
402
+ //
403
+ // element - Textarea or contenteditable element.
404
+ function Dropdown(element, completer, option) {
405
+ this.$el = Dropdown.createElement(option);
406
+ this.completer = completer;
407
+ this.id = completer.id + 'dropdown';
408
+ this._data = []; // zipped data.
409
+ this.$inputEl = $(element);
410
+ this.option = option;
411
+
412
+ // Override setPosition method.
413
+ if (option.listPosition) { this.setPosition = option.listPosition; }
414
+ if (option.height) { this.$el.height(option.height); }
415
+ var self = this;
416
+ $.each(['maxCount', 'placement', 'footer', 'header', 'noResultsMessage', 'className'], function (_i, name) {
417
+ if (option[name] != null) { self[name] = option[name]; }
418
+ });
419
+ this._bindEvents(element);
420
+ dropdownViews[this.id] = this;
421
+ }
422
+
423
+ $.extend(Dropdown, {
424
+ // Class methods
425
+ // -------------
426
+
427
+ createElement: function (option) {
428
+ var $parent = option.appendTo;
429
+ if (!($parent instanceof $)) { $parent = $($parent); }
430
+ var $el = $('<ul></ul>')
431
+ .addClass(option.dropdownClassName)
432
+ .attr('id', 'textcomplete-dropdown-' + option._oid)
433
+ .css({
434
+ display: 'none',
435
+ left: 0,
436
+ position: 'absolute',
437
+ zIndex: option.zIndex
438
+ })
439
+ .appendTo($parent);
440
+ return $el;
441
+ }
442
+ });
443
+
444
+ $.extend(Dropdown.prototype, {
445
+ // Public properties
446
+ // -----------------
447
+
448
+ $el: null, // jQuery object of ul.dropdown-menu element.
449
+ $inputEl: null, // jQuery object of target textarea.
450
+ completer: null,
451
+ footer: null,
452
+ header: null,
453
+ id: null,
454
+ maxCount: null,
455
+ placement: '',
456
+ shown: false,
457
+ data: [], // Shown zipped data.
458
+ className: '',
459
+
460
+ // Public methods
461
+ // --------------
462
+
463
+ destroy: function () {
464
+ // Don't remove $el because it may be shared by several textcompletes.
465
+ this.deactivate();
466
+
467
+ this.$el.off('.' + this.id);
468
+ this.$inputEl.off('.' + this.id);
469
+ this.clear();
470
+ this.$el.remove();
471
+ this.$el = this.$inputEl = this.completer = null;
472
+ delete dropdownViews[this.id]
473
+ },
474
+
475
+ render: function (zippedData) {
476
+ var contentsHtml = this._buildContents(zippedData);
477
+ var unzippedData = $.map(zippedData, function (d) { return d.value; });
478
+ if (zippedData.length) {
479
+ var strategy = zippedData[0].strategy;
480
+ if (strategy.id) {
481
+ this.$el.attr('data-strategy', strategy.id);
482
+ } else {
483
+ this.$el.removeAttr('data-strategy');
484
+ }
485
+ this._renderHeader(unzippedData);
486
+ this._renderFooter(unzippedData);
487
+ if (contentsHtml) {
488
+ this._renderContents(contentsHtml);
489
+ this._fitToBottom();
490
+ this._fitToRight();
491
+ this._activateIndexedItem();
492
+ }
493
+ this._setScroll();
494
+ } else if (this.noResultsMessage) {
495
+ this._renderNoResultsMessage(unzippedData);
496
+ } else if (this.shown) {
497
+ this.deactivate();
498
+ }
499
+ },
500
+
501
+ setPosition: function (pos) {
502
+ // Make the dropdown fixed if the input is also fixed
503
+ // This can't be done during init, as textcomplete may be used on multiple elements on the same page
504
+ // Because the same dropdown is reused behind the scenes, we need to recheck every time the dropdown is showed
505
+ var position = 'absolute';
506
+ // Check if input or one of its parents has positioning we need to care about
507
+ this.$inputEl.add(this.$inputEl.parents()).each(function() {
508
+ if($(this).css('position') === 'absolute') // The element has absolute positioning, so it's all OK
509
+ return false;
510
+ if($(this).css('position') === 'fixed') {
511
+ pos.top -= $window.scrollTop();
512
+ pos.left -= $window.scrollLeft();
513
+ position = 'fixed';
514
+ return false;
515
+ }
516
+ });
517
+ this.$el.css(this._applyPlacement(pos));
518
+ this.$el.css({ position: position }); // Update positioning
519
+
520
+ return this;
521
+ },
522
+
523
+ clear: function () {
524
+ this.$el.html('');
525
+ this.data = [];
526
+ this._index = 0;
527
+ this._$header = this._$footer = this._$noResultsMessage = null;
528
+ },
529
+
530
+ activate: function () {
531
+ if (!this.shown) {
532
+ this.clear();
533
+ this.$el.show();
534
+ if (this.className) { this.$el.addClass(this.className); }
535
+ this.completer.fire('textComplete:show');
536
+ this.shown = true;
537
+ }
538
+ return this;
539
+ },
540
+
541
+ deactivate: function () {
542
+ if (this.shown) {
543
+ this.$el.hide();
544
+ if (this.className) { this.$el.removeClass(this.className); }
545
+ this.completer.fire('textComplete:hide');
546
+ this.shown = false;
547
+ }
548
+ return this;
549
+ },
550
+
551
+ isUp: function (e) {
552
+ return e.keyCode === 38 || (e.ctrlKey && e.keyCode === 80); // UP, Ctrl-P
553
+ },
554
+
555
+ isDown: function (e) {
556
+ return e.keyCode === 40 || (e.ctrlKey && e.keyCode === 78); // DOWN, Ctrl-N
557
+ },
558
+
559
+ isEnter: function (e) {
560
+ var modifiers = e.ctrlKey || e.altKey || e.metaKey || e.shiftKey;
561
+ return !modifiers && (e.keyCode === 13 || e.keyCode === 9 || (this.option.completeOnSpace === true && e.keyCode === 32)) // ENTER, TAB
562
+ },
563
+
564
+ isPageup: function (e) {
565
+ return e.keyCode === 33; // PAGEUP
566
+ },
567
+
568
+ isPagedown: function (e) {
569
+ return e.keyCode === 34; // PAGEDOWN
570
+ },
571
+
572
+ isEscape: function (e) {
573
+ return e.keyCode === 27; // ESCAPE
574
+ },
575
+
576
+ // Private properties
577
+ // ------------------
578
+
579
+ _data: null, // Currently shown zipped data.
580
+ _index: null,
581
+ _$header: null,
582
+ _$noResultsMessage: null,
583
+ _$footer: null,
584
+
585
+ // Private methods
586
+ // ---------------
587
+
588
+ _bindEvents: function () {
589
+ this.$el.on('mousedown.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this));
590
+ this.$el.on('touchstart.' + this.id, '.textcomplete-item', $.proxy(this._onClick, this));
591
+ this.$el.on('mouseover.' + this.id, '.textcomplete-item', $.proxy(this._onMouseover, this));
592
+ this.$inputEl.on('keydown.' + this.id, $.proxy(this._onKeydown, this));
593
+ },
594
+
595
+ _onClick: function (e) {
596
+ var $el = $(e.target);
597
+ e.preventDefault();
598
+ e.originalEvent.keepTextCompleteDropdown = this.id;
599
+ if (!$el.hasClass('textcomplete-item')) {
600
+ $el = $el.closest('.textcomplete-item');
601
+ }
602
+ var datum = this.data[parseInt($el.data('index'), 10)];
603
+ this.completer.select(datum.value, datum.strategy, e);
604
+ var self = this;
605
+ // Deactive at next tick to allow other event handlers to know whether
606
+ // the dropdown has been shown or not.
607
+ setTimeout(function () {
608
+ self.deactivate();
609
+ if (e.type === 'touchstart') {
610
+ self.$inputEl.focus();
611
+ }
612
+ }, 0);
613
+ },
614
+
615
+ // Activate hovered item.
616
+ _onMouseover: function (e) {
617
+ var $el = $(e.target);
618
+ e.preventDefault();
619
+ if (!$el.hasClass('textcomplete-item')) {
620
+ $el = $el.closest('.textcomplete-item');
621
+ }
622
+ this._index = parseInt($el.data('index'), 10);
623
+ this._activateIndexedItem();
624
+ },
625
+
626
+ _onKeydown: function (e) {
627
+ if (!this.shown) { return; }
628
+
629
+ var command;
630
+
631
+ if ($.isFunction(this.option.onKeydown)) {
632
+ command = this.option.onKeydown(e, commands);
633
+ }
634
+
635
+ if (command == null) {
636
+ command = this._defaultKeydown(e);
637
+ }
638
+
639
+ switch (command) {
640
+ case commands.KEY_UP:
641
+ e.preventDefault();
642
+ this._up();
643
+ break;
644
+ case commands.KEY_DOWN:
645
+ e.preventDefault();
646
+ this._down();
647
+ break;
648
+ case commands.KEY_ENTER:
649
+ e.preventDefault();
650
+ this._enter(e);
651
+ break;
652
+ case commands.KEY_PAGEUP:
653
+ e.preventDefault();
654
+ this._pageup();
655
+ break;
656
+ case commands.KEY_PAGEDOWN:
657
+ e.preventDefault();
658
+ this._pagedown();
659
+ break;
660
+ case commands.KEY_ESCAPE:
661
+ e.preventDefault();
662
+ this.deactivate();
663
+ break;
664
+ }
665
+ },
666
+
667
+ _defaultKeydown: function (e) {
668
+ if (this.isUp(e)) {
669
+ return commands.KEY_UP;
670
+ } else if (this.isDown(e)) {
671
+ return commands.KEY_DOWN;
672
+ } else if (this.isEnter(e)) {
673
+ return commands.KEY_ENTER;
674
+ } else if (this.isPageup(e)) {
675
+ return commands.KEY_PAGEUP;
676
+ } else if (this.isPagedown(e)) {
677
+ return commands.KEY_PAGEDOWN;
678
+ } else if (this.isEscape(e)) {
679
+ return commands.KEY_ESCAPE;
680
+ }
681
+ },
682
+
683
+ _up: function () {
684
+ if (this._index === 0) {
685
+ this._index = this.data.length - 1;
686
+ } else {
687
+ this._index -= 1;
688
+ }
689
+ this._activateIndexedItem();
690
+ this._setScroll();
691
+ },
692
+
693
+ _down: function () {
694
+ if (this._index === this.data.length - 1) {
695
+ this._index = 0;
696
+ } else {
697
+ this._index += 1;
698
+ }
699
+ this._activateIndexedItem();
700
+ this._setScroll();
701
+ },
702
+
703
+ _enter: function (e) {
704
+ var datum = this.data[parseInt(this._getActiveElement().data('index'), 10)];
705
+ this.completer.select(datum.value, datum.strategy, e);
706
+ this.deactivate();
707
+ },
708
+
709
+ _pageup: function () {
710
+ var target = 0;
711
+ var threshold = this._getActiveElement().position().top - this.$el.innerHeight();
712
+ this.$el.children().each(function (i) {
713
+ if ($(this).position().top + $(this).outerHeight() > threshold) {
714
+ target = i;
715
+ return false;
716
+ }
717
+ });
718
+ this._index = target;
719
+ this._activateIndexedItem();
720
+ this._setScroll();
721
+ },
722
+
723
+ _pagedown: function () {
724
+ var target = this.data.length - 1;
725
+ var threshold = this._getActiveElement().position().top + this.$el.innerHeight();
726
+ this.$el.children().each(function (i) {
727
+ if ($(this).position().top > threshold) {
728
+ target = i;
729
+ return false
730
+ }
731
+ });
732
+ this._index = target;
733
+ this._activateIndexedItem();
734
+ this._setScroll();
735
+ },
736
+
737
+ _activateIndexedItem: function () {
738
+ this.$el.find('.textcomplete-item.active').removeClass('active');
739
+ this._getActiveElement().addClass('active');
740
+ },
741
+
742
+ _getActiveElement: function () {
743
+ return this.$el.children('.textcomplete-item:nth(' + this._index + ')');
744
+ },
745
+
746
+ _setScroll: function () {
747
+ var $activeEl = this._getActiveElement();
748
+ var itemTop = $activeEl.position().top;
749
+ var itemHeight = $activeEl.outerHeight();
750
+ var visibleHeight = this.$el.innerHeight();
751
+ var visibleTop = this.$el.scrollTop();
752
+ if (this._index === 0 || this._index == this.data.length - 1 || itemTop < 0) {
753
+ this.$el.scrollTop(itemTop + visibleTop);
754
+ } else if (itemTop + itemHeight > visibleHeight) {
755
+ this.$el.scrollTop(itemTop + itemHeight + visibleTop - visibleHeight);
756
+ }
757
+ },
758
+
759
+ _buildContents: function (zippedData) {
760
+ var datum, i, index;
761
+ var html = '';
762
+ for (i = 0; i < zippedData.length; i++) {
763
+ if (this.data.length === this.maxCount) break;
764
+ datum = zippedData[i];
765
+ if (include(this.data, datum)) { continue; }
766
+ index = this.data.length;
767
+ this.data.push(datum);
768
+ html += '<li class="textcomplete-item" data-index="' + index + '"><a>';
769
+ html += datum.strategy.template(datum.value, datum.term);
770
+ html += '</a></li>';
771
+ }
772
+ return html;
773
+ },
774
+
775
+ _renderHeader: function (unzippedData) {
776
+ if (this.header) {
777
+ if (!this._$header) {
778
+ this._$header = $('<li class="textcomplete-header"></li>').prependTo(this.$el);
779
+ }
780
+ var html = $.isFunction(this.header) ? this.header(unzippedData) : this.header;
781
+ this._$header.html(html);
782
+ }
783
+ },
784
+
785
+ _renderFooter: function (unzippedData) {
786
+ if (this.footer) {
787
+ if (!this._$footer) {
788
+ this._$footer = $('<li class="textcomplete-footer"></li>').appendTo(this.$el);
789
+ }
790
+ var html = $.isFunction(this.footer) ? this.footer(unzippedData) : this.footer;
791
+ this._$footer.html(html);
792
+ }
793
+ },
794
+
795
+ _renderNoResultsMessage: function (unzippedData) {
796
+ if (this.noResultsMessage) {
797
+ if (!this._$noResultsMessage) {
798
+ this._$noResultsMessage = $('<li class="textcomplete-no-results-message"></li>').appendTo(this.$el);
799
+ }
800
+ var html = $.isFunction(this.noResultsMessage) ? this.noResultsMessage(unzippedData) : this.noResultsMessage;
801
+ this._$noResultsMessage.html(html);
802
+ }
803
+ },
804
+
805
+ _renderContents: function (html) {
806
+ if (this._$footer) {
807
+ this._$footer.before(html);
808
+ } else {
809
+ this.$el.append(html);
810
+ }
811
+ },
812
+
813
+ _fitToBottom: function() {
814
+ var windowScrollBottom = $window.scrollTop() + $window.height();
815
+ var height = this.$el.height();
816
+ if ((this.$el.position().top + height) > windowScrollBottom) {
817
+ // only do this if we are not in an iframe
818
+ if (!this.completer.$iframe) {
819
+ this.$el.offset({top: windowScrollBottom - height});
820
+ }
821
+ }
822
+ },
823
+
824
+ _fitToRight: function() {
825
+ // We don't know how wide our content is until the browser positions us, and at that point it clips us
826
+ // to the document width so we don't know if we would have overrun it. As a heuristic to avoid that clipping
827
+ // (which makes our elements wrap onto the next line and corrupt the next item), if we're close to the right
828
+ // edge, move left. We don't know how far to move left, so just keep nudging a bit.
829
+ var tolerance = this.option.rightEdgeOffset; // pixels. Make wider than vertical scrollbar because we might not be able to use that space.
830
+ var lastOffset = this.$el.offset().left, offset;
831
+ var width = this.$el.width();
832
+ var maxLeft = $window.width() - tolerance;
833
+ while (lastOffset + width > maxLeft) {
834
+ this.$el.offset({left: lastOffset - tolerance});
835
+ offset = this.$el.offset().left;
836
+ if (offset >= lastOffset) { break; }
837
+ lastOffset = offset;
838
+ }
839
+ },
840
+
841
+ _applyPlacement: function (position) {
842
+ // If the 'placement' option set to 'top', move the position above the element.
843
+ if (this.placement.indexOf('top') !== -1) {
844
+ // Overwrite the position object to set the 'bottom' property instead of the top.
845
+ position = {
846
+ top: 'auto',
847
+ bottom: this.$el.parent().height() - position.top + position.lineHeight,
848
+ left: position.left
849
+ };
850
+ } else {
851
+ position.bottom = 'auto';
852
+ delete position.lineHeight;
853
+ }
854
+ if (this.placement.indexOf('absleft') !== -1) {
855
+ position.left = 0;
856
+ } else if (this.placement.indexOf('absright') !== -1) {
857
+ position.right = 0;
858
+ position.left = 'auto';
859
+ }
860
+ return position;
861
+ }
862
+ });
863
+
864
+ $.fn.textcomplete.Dropdown = Dropdown;
865
+ $.extend($.fn.textcomplete, commands);
866
+ }(jQuery);
867
+
868
+ +function ($) {
869
+ 'use strict';
870
+
871
+ // Memoize a search function.
872
+ var memoize = function (func) {
873
+ var memo = {};
874
+ return function (term, callback) {
875
+ if (memo[term]) {
876
+ callback(memo[term]);
877
+ } else {
878
+ func.call(this, term, function (data) {
879
+ memo[term] = (memo[term] || []).concat(data);
880
+ callback.apply(null, arguments);
881
+ });
882
+ }
883
+ };
884
+ };
885
+
886
+ function Strategy(options) {
887
+ $.extend(this, options);
888
+ if (this.cache) { this.search = memoize(this.search); }
889
+ }
890
+
891
+ Strategy.parse = function (strategiesArray, params) {
892
+ return $.map(strategiesArray, function (strategy) {
893
+ var strategyObj = new Strategy(strategy);
894
+ strategyObj.el = params.el;
895
+ strategyObj.$el = params.$el;
896
+ return strategyObj;
897
+ });
898
+ };
899
+
900
+ $.extend(Strategy.prototype, {
901
+ // Public properties
902
+ // -----------------
903
+
904
+ // Required
905
+ match: null,
906
+ replace: null,
907
+ search: null,
908
+
909
+ // Optional
910
+ id: null,
911
+ cache: false,
912
+ context: function () { return true; },
913
+ index: 2,
914
+ template: function (obj) { return obj; },
915
+ idProperty: null
916
+ });
917
+
918
+ $.fn.textcomplete.Strategy = Strategy;
919
+
920
+ }(jQuery);
921
+
922
+ +function ($) {
923
+ 'use strict';
924
+
925
+ var now = Date.now || function () { return new Date().getTime(); };
926
+
927
+ // Returns a function, that, as long as it continues to be invoked, will not
928
+ // be triggered. The function will be called after it stops being called for
929
+ // `wait` msec.
930
+ //
931
+ // This utility function was originally implemented at Underscore.js.
932
+ var debounce = function (func, wait) {
933
+ var timeout, args, context, timestamp, result;
934
+ var later = function () {
935
+ var last = now() - timestamp;
936
+ if (last < wait) {
937
+ timeout = setTimeout(later, wait - last);
938
+ } else {
939
+ timeout = null;
940
+ result = func.apply(context, args);
941
+ context = args = null;
942
+ }
943
+ };
944
+
945
+ return function () {
946
+ context = this;
947
+ args = arguments;
948
+ timestamp = now();
949
+ if (!timeout) {
950
+ timeout = setTimeout(later, wait);
951
+ }
952
+ return result;
953
+ };
954
+ };
955
+
956
+ function Adapter () {}
957
+
958
+ $.extend(Adapter.prototype, {
959
+ // Public properties
960
+ // -----------------
961
+
962
+ id: null, // Identity.
963
+ completer: null, // Completer object which creates it.
964
+ el: null, // Textarea element.
965
+ $el: null, // jQuery object of the textarea.
966
+ option: null,
967
+
968
+ // Public methods
969
+ // --------------
970
+
971
+ initialize: function (element, completer, option) {
972
+ this.el = element;
973
+ this.$el = $(element);
974
+ this.id = completer.id + this.constructor.name;
975
+ this.completer = completer;
976
+ this.option = option;
977
+
978
+ if (this.option.debounce) {
979
+ this._onKeyup = debounce(this._onKeyup, this.option.debounce);
980
+ }
981
+
982
+ this._bindEvents();
983
+ },
984
+
985
+ destroy: function () {
986
+ this.$el.off('.' + this.id); // Remove all event handlers.
987
+ this.$el = this.el = this.completer = null;
988
+ },
989
+
990
+ // Update the element with the given value and strategy.
991
+ //
992
+ // value - The selected object. It is one of the item of the array
993
+ // which was callbacked from the search function.
994
+ // strategy - The Strategy associated with the selected value.
995
+ select: function (/* value, strategy */) {
996
+ throw new Error('Not implemented');
997
+ },
998
+
999
+ // Returns the caret's relative coordinates from body's left top corner.
1000
+ getCaretPosition: function () {
1001
+ var position = this._getCaretRelativePosition();
1002
+ var offset = this.$el.offset();
1003
+
1004
+ // Calculate the left top corner of `this.option.appendTo` element.
1005
+ var $parent = this.option.appendTo;
1006
+ if ($parent) {
1007
+ if (!($parent instanceof $)) { $parent = $($parent); }
1008
+ var parentOffset = $parent.offsetParent().offset();
1009
+ offset.top -= parentOffset.top;
1010
+ offset.left -= parentOffset.left;
1011
+ }
1012
+
1013
+ position.top += offset.top;
1014
+ position.left += offset.left;
1015
+ return position;
1016
+ },
1017
+
1018
+ // Focus on the element.
1019
+ focus: function () {
1020
+ this.$el.focus();
1021
+ },
1022
+
1023
+ // Private methods
1024
+ // ---------------
1025
+
1026
+ _bindEvents: function () {
1027
+ this.$el.on('keyup.' + this.id, $.proxy(this._onKeyup, this));
1028
+ },
1029
+
1030
+ _onKeyup: function (e) {
1031
+ if (this._skipSearch(e)) { return; }
1032
+ this.completer.trigger(this.getTextFromHeadToCaret(), true);
1033
+ },
1034
+
1035
+ // Suppress searching if it returns true.
1036
+ _skipSearch: function (clickEvent) {
1037
+ switch (clickEvent.keyCode) {
1038
+ case 9: // TAB
1039
+ case 13: // ENTER
1040
+ case 40: // DOWN
1041
+ case 38: // UP
1042
+ case 27: // ESC
1043
+ return true;
1044
+ }
1045
+ if (clickEvent.ctrlKey) switch (clickEvent.keyCode) {
1046
+ case 78: // Ctrl-N
1047
+ case 80: // Ctrl-P
1048
+ return true;
1049
+ }
1050
+ }
1051
+ });
1052
+
1053
+ $.fn.textcomplete.Adapter = Adapter;
1054
+ }(jQuery);
1055
+
1056
+ +function ($) {
1057
+ 'use strict';
1058
+
1059
+ // Textarea adapter
1060
+ // ================
1061
+ //
1062
+ // Managing a textarea. It doesn't know a Dropdown.
1063
+ function Textarea(element, completer, option) {
1064
+ this.initialize(element, completer, option);
1065
+ }
1066
+
1067
+ $.extend(Textarea.prototype, $.fn.textcomplete.Adapter.prototype, {
1068
+ // Public methods
1069
+ // --------------
1070
+
1071
+ // Update the textarea with the given value and strategy.
1072
+ select: function (value, strategy, e) {
1073
+ var pre = this.getTextFromHeadToCaret();
1074
+ var post = this.el.value.substring(this.el.selectionEnd);
1075
+ var newSubstr = strategy.replace(value, e);
1076
+ var regExp;
1077
+ if (typeof newSubstr !== 'undefined') {
1078
+ if ($.isArray(newSubstr)) {
1079
+ post = newSubstr[1] + post;
1080
+ newSubstr = newSubstr[0];
1081
+ }
1082
+ regExp = $.isFunction(strategy.match) ? strategy.match(pre) : strategy.match;
1083
+ pre = pre.replace(regExp, newSubstr);
1084
+ this.$el.val(pre + post);
1085
+ this.el.selectionStart = this.el.selectionEnd = pre.length;
1086
+ }
1087
+ },
1088
+
1089
+ getTextFromHeadToCaret: function () {
1090
+ return this.el.value.substring(0, this.el.selectionEnd);
1091
+ },
1092
+
1093
+ // Private methods
1094
+ // ---------------
1095
+
1096
+ _getCaretRelativePosition: function () {
1097
+ var p = $.fn.textcomplete.getCaretCoordinates(this.el, this.el.selectionStart);
1098
+ return {
1099
+ top: p.top + this._calculateLineHeight() - this.$el.scrollTop(),
1100
+ left: p.left - this.$el.scrollLeft(),
1101
+ lineHeight: this._calculateLineHeight()
1102
+ };
1103
+ },
1104
+
1105
+ _calculateLineHeight: function () {
1106
+ var lineHeight = parseInt(this.$el.css('line-height'), 10);
1107
+ if (isNaN(lineHeight)) {
1108
+ // http://stackoverflow.com/a/4515470/1297336
1109
+ var parentNode = this.el.parentNode;
1110
+ var temp = document.createElement(this.el.nodeName);
1111
+ var style = this.el.style;
1112
+ temp.setAttribute(
1113
+ 'style',
1114
+ 'margin:0px;padding:0px;font-family:' + style.fontFamily + ';font-size:' + style.fontSize
1115
+ );
1116
+ temp.innerHTML = 'test';
1117
+ parentNode.appendChild(temp);
1118
+ lineHeight = temp.clientHeight;
1119
+ parentNode.removeChild(temp);
1120
+ }
1121
+ return lineHeight;
1122
+ }
1123
+ });
1124
+
1125
+ $.fn.textcomplete.Textarea = Textarea;
1126
+ }(jQuery);
1127
+
1128
+ +function ($) {
1129
+ 'use strict';
1130
+
1131
+ var sentinelChar = '吶';
1132
+
1133
+ function IETextarea(element, completer, option) {
1134
+ this.initialize(element, completer, option);
1135
+ $('<span>' + sentinelChar + '</span>').css({
1136
+ position: 'absolute',
1137
+ top: -9999,
1138
+ left: -9999
1139
+ }).insertBefore(element);
1140
+ }
1141
+
1142
+ $.extend(IETextarea.prototype, $.fn.textcomplete.Textarea.prototype, {
1143
+ // Public methods
1144
+ // --------------
1145
+
1146
+ select: function (value, strategy, e) {
1147
+ var pre = this.getTextFromHeadToCaret();
1148
+ var post = this.el.value.substring(pre.length);
1149
+ var newSubstr = strategy.replace(value, e);
1150
+ var regExp;
1151
+ if (typeof newSubstr !== 'undefined') {
1152
+ if ($.isArray(newSubstr)) {
1153
+ post = newSubstr[1] + post;
1154
+ newSubstr = newSubstr[0];
1155
+ }
1156
+ regExp = $.isFunction(strategy.match) ? strategy.match(pre) : strategy.match;
1157
+ pre = pre.replace(regExp, newSubstr);
1158
+ this.$el.val(pre + post);
1159
+ this.el.focus();
1160
+ var range = this.el.createTextRange();
1161
+ range.collapse(true);
1162
+ range.moveEnd('character', pre.length);
1163
+ range.moveStart('character', pre.length);
1164
+ range.select();
1165
+ }
1166
+ },
1167
+
1168
+ getTextFromHeadToCaret: function () {
1169
+ this.el.focus();
1170
+ var range = document.selection.createRange();
1171
+ range.moveStart('character', -this.el.value.length);
1172
+ var arr = range.text.split(sentinelChar)
1173
+ return arr.length === 1 ? arr[0] : arr[1];
1174
+ }
1175
+ });
1176
+
1177
+ $.fn.textcomplete.IETextarea = IETextarea;
1178
+ }(jQuery);
1179
+
1180
+ // NOTE: TextComplete plugin has contenteditable support but it does not work
1181
+ // fine especially on old IEs.
1182
+ // Any pull requests are REALLY welcome.
1183
+
1184
+ +function ($) {
1185
+ 'use strict';
1186
+
1187
+ // ContentEditable adapter
1188
+ // =======================
1189
+ //
1190
+ // Adapter for contenteditable elements.
1191
+ function ContentEditable (element, completer, option) {
1192
+ this.initialize(element, completer, option);
1193
+ }
1194
+
1195
+ $.extend(ContentEditable.prototype, $.fn.textcomplete.Adapter.prototype, {
1196
+ // Public methods
1197
+ // --------------
1198
+
1199
+ // Update the content with the given value and strategy.
1200
+ // When an dropdown item is selected, it is executed.
1201
+ select: function (value, strategy, e) {
1202
+ var pre = this.getTextFromHeadToCaret();
1203
+ // use ownerDocument instead of window to support iframes
1204
+ var sel = this.el.ownerDocument.getSelection();
1205
+
1206
+ var range = sel.getRangeAt(0);
1207
+ var selection = range.cloneRange();
1208
+ selection.selectNodeContents(range.startContainer);
1209
+ var content = selection.toString();
1210
+ var post = content.substring(range.startOffset);
1211
+ var newSubstr = strategy.replace(value, e);
1212
+ var regExp;
1213
+ if (typeof newSubstr !== 'undefined') {
1214
+ if ($.isArray(newSubstr)) {
1215
+ post = newSubstr[1] + post;
1216
+ newSubstr = newSubstr[0];
1217
+ }
1218
+ regExp = $.isFunction(strategy.match) ? strategy.match(pre) : strategy.match;
1219
+ pre = pre.replace(regExp, newSubstr)
1220
+ .replace(/ $/, "&nbsp"); // &nbsp necessary at least for CKeditor to not eat spaces
1221
+ range.selectNodeContents(range.startContainer);
1222
+ range.deleteContents();
1223
+
1224
+ // create temporary elements
1225
+ var preWrapper = this.el.ownerDocument.createElement("div");
1226
+ preWrapper.innerHTML = pre;
1227
+ var postWrapper = this.el.ownerDocument.createElement("div");
1228
+ postWrapper.innerHTML = post;
1229
+
1230
+ // create the fragment thats inserted
1231
+ var fragment = this.el.ownerDocument.createDocumentFragment();
1232
+ var childNode;
1233
+ var lastOfPre;
1234
+ while (childNode = preWrapper.firstChild) {
1235
+ lastOfPre = fragment.appendChild(childNode);
1236
+ }
1237
+ while (childNode = postWrapper.firstChild) {
1238
+ fragment.appendChild(childNode);
1239
+ }
1240
+
1241
+ // insert the fragment & jump behind the last node in "pre"
1242
+ range.insertNode(fragment);
1243
+ range.setStartAfter(lastOfPre);
1244
+
1245
+ range.collapse(true);
1246
+ sel.removeAllRanges();
1247
+ sel.addRange(range);
1248
+ }
1249
+ },
1250
+
1251
+ // Private methods
1252
+ // ---------------
1253
+
1254
+ // Returns the caret's relative position from the contenteditable's
1255
+ // left top corner.
1256
+ //
1257
+ // Examples
1258
+ //
1259
+ // this._getCaretRelativePosition()
1260
+ // //=> { top: 18, left: 200, lineHeight: 16 }
1261
+ //
1262
+ // Dropdown's position will be decided using the result.
1263
+ _getCaretRelativePosition: function () {
1264
+ var range = this.el.ownerDocument.getSelection().getRangeAt(0).cloneRange();
1265
+ var node = this.el.ownerDocument.createElement('span');
1266
+ range.insertNode(node);
1267
+ range.selectNodeContents(node);
1268
+ range.deleteContents();
1269
+ var $node = $(node);
1270
+ var position = $node.offset();
1271
+ position.left -= this.$el.offset().left;
1272
+ position.top += $node.height() - this.$el.offset().top;
1273
+ position.lineHeight = $node.height();
1274
+
1275
+ // special positioning logic for iframes
1276
+ // this is typically used for contenteditables such as tinymce or ckeditor
1277
+ if (this.completer.$iframe) {
1278
+ var iframePosition = this.completer.$iframe.offset();
1279
+ position.top += iframePosition.top;
1280
+ position.left += iframePosition.left;
1281
+ //subtract scrollTop from element in iframe
1282
+ position.top -= this.$el.scrollTop();
1283
+ }
1284
+
1285
+ $node.remove();
1286
+ return position;
1287
+ },
1288
+
1289
+ // Returns the string between the first character and the caret.
1290
+ // Completer will be triggered with the result for start autocompleting.
1291
+ //
1292
+ // Example
1293
+ //
1294
+ // // Suppose the html is '<b>hello</b> wor|ld' and | is the caret.
1295
+ // this.getTextFromHeadToCaret()
1296
+ // // => ' wor' // not '<b>hello</b> wor'
1297
+ getTextFromHeadToCaret: function () {
1298
+ var range = this.el.ownerDocument.getSelection().getRangeAt(0);
1299
+ var selection = range.cloneRange();
1300
+ selection.selectNodeContents(range.startContainer);
1301
+ return selection.toString().substring(0, range.startOffset);
1302
+ }
1303
+ });
1304
+
1305
+ $.fn.textcomplete.ContentEditable = ContentEditable;
1306
+ }(jQuery);
1307
+
1308
+ // NOTE: TextComplete plugin has contenteditable support but it does not work
1309
+ // fine especially on old IEs.
1310
+ // Any pull requests are REALLY welcome.
1311
+
1312
+ +function ($) {
1313
+ 'use strict';
1314
+
1315
+ // CKEditor adapter
1316
+ // =======================
1317
+ //
1318
+ // Adapter for CKEditor, based on contenteditable elements.
1319
+ function CKEditor (element, completer, option) {
1320
+ this.initialize(element, completer, option);
1321
+ }
1322
+
1323
+ $.extend(CKEditor.prototype, $.fn.textcomplete.ContentEditable.prototype, {
1324
+ _bindEvents: function () {
1325
+ var $this = this;
1326
+ this.option.ckeditor_instance.on('key', function(event) {
1327
+ var domEvent = event.data;
1328
+ $this._onKeyup(domEvent);
1329
+ if ($this.completer.dropdown.shown && $this._skipSearch(domEvent)) {
1330
+ return false;
1331
+ }
1332
+ }, null, null, 1); // 1 = Priority = Important!
1333
+ // we actually also need the native event, as the CKEditor one is happening to late
1334
+ this.$el.on('keyup.' + this.id, $.proxy(this._onKeyup, this));
1335
+ },
1336
+ });
1337
+
1338
+ $.fn.textcomplete.CKEditor = CKEditor;
1339
+ }(jQuery);
1340
+
1341
+ // The MIT License (MIT)
1342
+ //
1343
+ // Copyright (c) 2015 Jonathan Ong me@jongleberry.com
1344
+ //
1345
+ // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
1346
+ // associated documentation files (the "Software"), to deal in the Software without restriction,
1347
+ // including without limitation the rights to use, copy, modify, merge, publish, distribute,
1348
+ // sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
1349
+ // furnished to do so, subject to the following conditions:
1350
+ //
1351
+ // The above copyright notice and this permission notice shall be included in all copies or
1352
+ // substantial portions of the Software.
1353
+ //
1354
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
1355
+ // NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
1356
+ // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
1357
+ // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1358
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1359
+ //
1360
+ // https://github.com/component/textarea-caret-position
1361
+
1362
+ (function ($) {
1363
+
1364
+ // The properties that we copy into a mirrored div.
1365
+ // Note that some browsers, such as Firefox,
1366
+ // do not concatenate properties, i.e. padding-top, bottom etc. -> padding,
1367
+ // so we have to do every single property specifically.
1368
+ var properties = [
1369
+ 'direction', // RTL support
1370
+ 'boxSizing',
1371
+ 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
1372
+ 'height',
1373
+ 'overflowX',
1374
+ 'overflowY', // copy the scrollbar for IE
1375
+
1376
+ 'borderTopWidth',
1377
+ 'borderRightWidth',
1378
+ 'borderBottomWidth',
1379
+ 'borderLeftWidth',
1380
+ 'borderStyle',
1381
+
1382
+ 'paddingTop',
1383
+ 'paddingRight',
1384
+ 'paddingBottom',
1385
+ 'paddingLeft',
1386
+
1387
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/font
1388
+ 'fontStyle',
1389
+ 'fontVariant',
1390
+ 'fontWeight',
1391
+ 'fontStretch',
1392
+ 'fontSize',
1393
+ 'fontSizeAdjust',
1394
+ 'lineHeight',
1395
+ 'fontFamily',
1396
+
1397
+ 'textAlign',
1398
+ 'textTransform',
1399
+ 'textIndent',
1400
+ 'textDecoration', // might not make a difference, but better be safe
1401
+
1402
+ 'letterSpacing',
1403
+ 'wordSpacing',
1404
+
1405
+ 'tabSize',
1406
+ 'MozTabSize'
1407
+
1408
+ ];
1409
+
1410
+ var isBrowser = (typeof window !== 'undefined');
1411
+ var isFirefox = (isBrowser && window.mozInnerScreenX != null);
1412
+
1413
+ function getCaretCoordinates(element, position, options) {
1414
+ if(!isBrowser) {
1415
+ throw new Error('textarea-caret-position#getCaretCoordinates should only be called in a browser');
1416
+ }
1417
+
1418
+ var debug = options && options.debug || false;
1419
+ if (debug) {
1420
+ var el = document.querySelector('#input-textarea-caret-position-mirror-div');
1421
+ if ( el ) { el.parentNode.removeChild(el); }
1422
+ }
1423
+
1424
+ // mirrored div
1425
+ var div = document.createElement('div');
1426
+ div.id = 'input-textarea-caret-position-mirror-div';
1427
+ document.body.appendChild(div);
1428
+
1429
+ var style = div.style;
1430
+ var computed = window.getComputedStyle? getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
1431
+
1432
+ // default textarea styles
1433
+ style.whiteSpace = 'pre-wrap';
1434
+ if (element.nodeName !== 'INPUT')
1435
+ style.wordWrap = 'break-word'; // only for textarea-s
1436
+
1437
+ // position off-screen
1438
+ style.position = 'absolute'; // required to return coordinates properly
1439
+ if (!debug)
1440
+ style.visibility = 'hidden'; // not 'display: none' because we want rendering
1441
+
1442
+ // transfer the element's properties to the div
1443
+ properties.forEach(function (prop) {
1444
+ style[prop] = computed[prop];
1445
+ });
1446
+
1447
+ if (isFirefox) {
1448
+ // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
1449
+ if (element.scrollHeight > parseInt(computed.height))
1450
+ style.overflowY = 'scroll';
1451
+ } else {
1452
+ style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
1453
+ }
1454
+
1455
+ div.textContent = element.value.substring(0, position);
1456
+ // the second special handling for input type="text" vs textarea: spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
1457
+ if (element.nodeName === 'INPUT')
1458
+ div.textContent = div.textContent.replace(/\s/g, '\u00a0');
1459
+
1460
+ var span = document.createElement('span');
1461
+ // Wrapping must be replicated *exactly*, including when a long word gets
1462
+ // onto the next line, with whitespace at the end of the line before (#7).
1463
+ // The *only* reliable way to do that is to copy the *entire* rest of the
1464
+ // textarea's content into the <span> created at the caret position.
1465
+ // for inputs, just '.' would be enough, but why bother?
1466
+ span.textContent = element.value.substring(position) || '.'; // || because a completely empty faux span doesn't render at all
1467
+ div.appendChild(span);
1468
+
1469
+ var coordinates = {
1470
+ top: span.offsetTop + parseInt(computed['borderTopWidth']),
1471
+ left: span.offsetLeft + parseInt(computed['borderLeftWidth'])
1472
+ };
1473
+
1474
+ if (debug) {
1475
+ span.style.backgroundColor = '#aaa';
1476
+ } else {
1477
+ document.body.removeChild(div);
1478
+ }
1479
+
1480
+ return coordinates;
1481
+ }
1482
+
1483
+ $.fn.textcomplete.getCaretCoordinates = getCaretCoordinates;
1484
+
1485
+ }(jQuery));
1486
+
1487
+ return jQuery;
1488
+ }));