weixin_pam 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +165 -0
  4. data/Rakefile +37 -0
  5. data/app/assets/javascripts/jquery-sortable.js +699 -0
  6. data/app/assets/javascripts/weixin_pam/application.js +30 -0
  7. data/app/assets/javascripts/weixin_pam/diymenus.js +90 -0
  8. data/app/assets/javascripts/weixin_pam/public_accounts.js +2 -0
  9. data/app/assets/javascripts/weixin_pam/user_accounts.js +23 -0
  10. data/app/assets/stylesheets/weixin_pam/application.scss +31 -0
  11. data/app/assets/stylesheets/weixin_pam/diymenus.scss +52 -0
  12. data/app/assets/stylesheets/weixin_pam/jquery-dragable.scss +26 -0
  13. data/app/assets/stylesheets/weixin_pam/public_accounts.css +4 -0
  14. data/app/assets/stylesheets/weixin_pam/user_accounts.css +4 -0
  15. data/app/controllers/weixin_pam/application_controller.rb +4 -0
  16. data/app/controllers/weixin_pam/diymenus_controller.rb +100 -0
  17. data/app/controllers/weixin_pam/public_accounts_controller.rb +62 -0
  18. data/app/controllers/weixin_pam/user_accounts_controller.rb +76 -0
  19. data/app/decorators/controllers/weixin_rails_middleware/weixin_controller_decorator.rb +31 -0
  20. data/app/helpers/weixin_pam/application_helper.rb +18 -0
  21. data/app/helpers/weixin_pam/diymenus_helper.rb +4 -0
  22. data/app/helpers/weixin_pam/public_accounts_helper.rb +4 -0
  23. data/app/helpers/weixin_pam/user_accounts_helper.rb +4 -0
  24. data/app/models/weixin_pam/diymenu.rb +58 -0
  25. data/app/models/weixin_pam/public_account.rb +87 -0
  26. data/app/models/weixin_pam/user_account.rb +67 -0
  27. data/app/views/layouts/weixin_pam/_nav_bar.html.erb +37 -0
  28. data/app/views/layouts/weixin_pam/application.html.erb +26 -0
  29. data/app/views/weixin_pam/diymenus/_form.html.erb +18 -0
  30. data/app/views/weixin_pam/diymenus/edit.html.erb +3 -0
  31. data/app/views/weixin_pam/diymenus/index.html.slim +32 -0
  32. data/app/views/weixin_pam/diymenus/new.html.erb +3 -0
  33. data/app/views/weixin_pam/public_accounts/_form.html.erb +12 -0
  34. data/app/views/weixin_pam/public_accounts/edit.html.erb +3 -0
  35. data/app/views/weixin_pam/public_accounts/index.html.erb +30 -0
  36. data/app/views/weixin_pam/public_accounts/new.html.erb +3 -0
  37. data/app/views/weixin_pam/public_accounts/show.html.erb +17 -0
  38. data/app/views/weixin_pam/user_accounts/_form.html.erb +37 -0
  39. data/app/views/weixin_pam/user_accounts/edit.html.erb +6 -0
  40. data/app/views/weixin_pam/user_accounts/index.html.slim +25 -0
  41. data/app/views/weixin_pam/user_accounts/new.html.erb +5 -0
  42. data/app/views/weixin_pam/user_accounts/show.html.erb +29 -0
  43. data/config/initializers/simple_form.rb +165 -0
  44. data/config/initializers/simple_form_bootstrap.rb +149 -0
  45. data/config/initializers/weixin_rails_middleware.rb +27 -0
  46. data/config/locales/diymenu.zh-CN.yml +9 -0
  47. data/config/locales/public_account.zh-CN.yml +16 -0
  48. data/config/locales/simple_form.en.yml +31 -0
  49. data/config/locales/user_account.zh-CN.yml +13 -0
  50. data/config/routes.rb +17 -0
  51. data/db/migrate/20151211153307_create_weixin_pam_public_accounts.rb +14 -0
  52. data/db/migrate/20151211153353_create_weixin_pam_user_accounts.rb +19 -0
  53. data/db/migrate/20151212152624_create_weixin_pam_diymenus.rb +18 -0
  54. data/db/migrate/20151215140830_add_weixin_secret_key_and_weixin_token_to_public_accounts.rb +13 -0
  55. data/db/migrate/20151218053505_add_host_to_weixin_pam_public_account.rb +6 -0
  56. data/lib/tasks/weixin_pam_tasks.rake +4 -0
  57. data/lib/templates/erb/scaffold/_form.html.erb +13 -0
  58. data/lib/weixin_pam.rb +3 -0
  59. data/lib/weixin_pam/api_error.rb +3 -0
  60. data/lib/weixin_pam/api_error/failed_result.rb +11 -0
  61. data/lib/weixin_pam/engine.rb +24 -0
  62. data/lib/weixin_pam/public_account_reply.rb +222 -0
  63. data/lib/weixin_pam/version.rb +3 -0
  64. data/test/controllers/weixin_pam/diymenus_controller_test.rb +52 -0
  65. data/test/controllers/weixin_pam/public_accounts_controller_test.rb +52 -0
  66. data/test/controllers/weixin_pam/user_accounts_controller_test.rb +52 -0
  67. data/test/dummy/README.rdoc +28 -0
  68. data/test/dummy/Rakefile +6 -0
  69. data/test/dummy/app/assets/javascripts/application.js +13 -0
  70. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  71. data/test/dummy/app/controllers/application_controller.rb +5 -0
  72. data/test/dummy/app/helpers/application_helper.rb +2 -0
  73. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  74. data/test/dummy/bin/bundle +3 -0
  75. data/test/dummy/bin/rails +4 -0
  76. data/test/dummy/bin/rake +4 -0
  77. data/test/dummy/bin/setup +29 -0
  78. data/test/dummy/config.ru +4 -0
  79. data/test/dummy/config/application.rb +26 -0
  80. data/test/dummy/config/boot.rb +5 -0
  81. data/test/dummy/config/database.yml +25 -0
  82. data/test/dummy/config/environment.rb +5 -0
  83. data/test/dummy/config/environments/development.rb +41 -0
  84. data/test/dummy/config/environments/production.rb +79 -0
  85. data/test/dummy/config/environments/test.rb +42 -0
  86. data/test/dummy/config/initializers/assets.rb +11 -0
  87. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  88. data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
  89. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  90. data/test/dummy/config/initializers/inflections.rb +16 -0
  91. data/test/dummy/config/initializers/mime_types.rb +4 -0
  92. data/test/dummy/config/initializers/session_store.rb +3 -0
  93. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  94. data/test/dummy/config/locales/en.yml +23 -0
  95. data/test/dummy/config/routes.rb +4 -0
  96. data/test/dummy/config/secrets.yml +22 -0
  97. data/test/dummy/db/development.sqlite3 +0 -0
  98. data/test/dummy/db/schema.rb +60 -0
  99. data/test/dummy/db/test.sqlite3 +0 -0
  100. data/test/dummy/log/development.log +594 -0
  101. data/test/dummy/public/404.html +67 -0
  102. data/test/dummy/public/422.html +67 -0
  103. data/test/dummy/public/500.html +66 -0
  104. data/test/dummy/public/favicon.ico +0 -0
  105. data/test/fixtures/weixin_pam/diymenus.yml +19 -0
  106. data/test/fixtures/weixin_pam/public_accounts.yml +17 -0
  107. data/test/fixtures/weixin_pam/user_accounts.yml +15 -0
  108. data/test/integration/navigation_test.rb +8 -0
  109. data/test/models/weixin_pam/diymenu_test.rb +9 -0
  110. data/test/models/weixin_pam/public_account_test.rb +9 -0
  111. data/test/models/weixin_pam/user_account_test.rb +9 -0
  112. data/test/test_helper.rb +21 -0
  113. data/test/weixin_pam_test.rb +7 -0
  114. metadata +374 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: f677ffbc6f2a5589e10e63fc8960e640569a86bc
4
+ data.tar.gz: 1aee68abde79cf9acef34ac501219905cba3c5a2
5
+ SHA512:
6
+ metadata.gz: 4db095f8b09afd0284190486eb6984f3f71d5cc2bd547fd68eed3b454dfd5f096e7ef12780f27a3e4b2f10eeea16dc23422946569962093c06a84ba8b488b3ed
7
+ data.tar.gz: 2d9763357947eead939122e43f37032dae84655b54eb82ec039c4310b87b887535553b6c505f3df150850c9fdeb0ebe046bf964e563530bae755ce98315e5226
@@ -0,0 +1,20 @@
1
+ Copyright 2015 xiaohui
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,165 @@
1
+ # 微信公众号管理工具
2
+ ## WeixinPam(Weixin Public Account Management)
3
+
4
+ 本Rails Engine是为了方便公众号运维人员统一管理公众号的开发接口,不同公众号由同一个Rails管理和驱动,对不同的公众号,可实现如下功能:
5
+
6
+ 1. 修改自定义菜单
7
+ 2. 实现网页授权登录,获取用户信息并保存在数据库
8
+ 3. 接收微信服务器推送信息,实现自动回复和其他高级功能
9
+
10
+ ## 如何使用
11
+
12
+
13
+ Gemfile:
14
+
15
+ ```
16
+ gem 'weixin_pem'
17
+ ```
18
+
19
+ config/routes:
20
+
21
+ ```
22
+ mount WeixinPam::Engine => '/'
23
+ ```
24
+
25
+ 启动Rails server
26
+
27
+ ```
28
+ bundle exec rails s
29
+ ```
30
+
31
+ 访问:http://localhost:3000/public_accounts 即可添加/删除公众号配置
32
+
33
+
34
+ ## 修改自定义菜单
35
+
36
+ 点击已添加的公众号名字,点击“微信菜单”按钮,该页面可实现如下功能
37
+
38
+ 1. 下载公众号菜单
39
+ 2. 上传公众号菜单
40
+ 3. 添加菜单到数据库
41
+ 4. 移动菜单到“未启用的列表”
42
+ 5. 对菜单排序
43
+
44
+ ## 如何使用多公众号网页授权
45
+
46
+ 本例子使用Devise作为用户登录模块,用到gem omniauth-wechat-oauth2
47
+
48
+ Gemfile中:
49
+
50
+ ```
51
+ gem 'devise'
52
+ gem "omniauth-wechat-oauth2"
53
+ ```
54
+ config/initializers/devise.rb
55
+
56
+ ```
57
+ require 'omniauth_setup'
58
+ # 此处 setup: OmniauthSetup是关键,他实现不同公众号的api身份切换
59
+ config.omniauth :wechat, nil, nil, setup: OmniauthSetup
60
+ ```
61
+
62
+ lib/omniauth_setup.rb
63
+
64
+ ```
65
+ class OmniauthSetup
66
+ # OmniAuth expects the class passed to setup to respond to the #call method.
67
+ # env - Rack environment
68
+ def self.call(env)
69
+ new(env).setup
70
+ end
71
+
72
+ # Assign variables and create a request object for use later.
73
+ # env - Rack environment
74
+ def initialize(env)
75
+ @env = env
76
+ @request = ActionDispatch::Request.new(env)
77
+ end
78
+
79
+ # The main purpose of this method is to set the consumer key and secret.
80
+ def setup
81
+ @env['omniauth.strategy'].options.merge!(custom_credentials)
82
+ puts
83
+ puts @env['omniauth.strategy'].options.inspect
84
+ puts
85
+ end
86
+
87
+ private
88
+
89
+ # Use the subdomain in the request to find the account with credentials
90
+ def custom_credentials
91
+ h = {}
92
+ scope = @request.params.delete(:scope).presence
93
+ h[:scope] = scope if scope
94
+ if account = WeixinPam::PublicAccount.find_by(host: @request.host)
95
+ h.update client_id: account.app_id, client_secret: account.app_secret
96
+ end
97
+ h
98
+ end
99
+
100
+ end
101
+ ```
102
+
103
+ config/routes.rb
104
+
105
+ ```
106
+ devise_for :users, :controllers => { :omniauth_callbacks => "users/omniauth_callbacks" }
107
+ ```
108
+
109
+ app/controllers/users/omniauth_callbacks_controller.rb
110
+
111
+ ```
112
+ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
113
+ def wechat
114
+ public_account = WeixinPam::PublicAccount.find_by!(host: request.host)
115
+ ua = WeixinPam::UserAccount.from_omniauth(public_account, request.env["omniauth.auth"])
116
+ if ua.user.persisted?
117
+ update_stored_location_url ua.user
118
+ sign_in_and_redirect ua.user, :event => :authentication
119
+ set_flash_message(:notice, :success, :kind => '微信') if is_navigational_format?
120
+ else
121
+ session["devise.wechat_data"] = request.env["omniauth.auth"]
122
+ redirect_to new_user_registration_url
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def update_stored_location_url(user)
129
+ if url = request.env['omniauth.params']['redirect'] || request.env['omniauth.origin']
130
+ store_location_for user, url
131
+ end
132
+ end
133
+ end
134
+
135
+ ```
136
+ ## 如何多公众号响应微信服务器事件推送
137
+
138
+ 创建的公众号如果不填写"微信服务器事件推送的响应Class",WeixinPam会使用开发模式下的默认值PublicAccountReply(lib/public_account_reply.rb)
139
+
140
+ 针对不同公众号,我们可以编写不同的Reply Class去继承PublicAccountReply,实现不同公众号有不同的回复内容。
141
+
142
+
143
+ 可用属性 | 说明
144
+ ---|---
145
+ weixin_public_account | 当前公众号WeixinPam::PublicAccount实例
146
+ weixin_user_account | 当前微信用户WeixinPam::UserAccount实例
147
+ weixin_message | 微信推送的消息
148
+ keyword | 用户发送的内容或事件的关键字
149
+
150
+
151
+ ## 用Devise保护资源
152
+
153
+ 添加config/initializers/weixin_pam.rb,加入如下代码
154
+ ```
155
+
156
+ WeixinPam::ApplicationController.class_eval do
157
+ before_action :authenticate_user!
158
+ before_action :ensure_admin_user
159
+
160
+ private
161
+
162
+ def ensure_admin_user
163
+ fail "没有访问权限" unless current_user.admin?
164
+ end
165
+ end
@@ -0,0 +1,37 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'WeixinPam'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.rdoc')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path("../test/dummy/Rakefile", __FILE__)
18
+ load 'rails/tasks/engine.rake'
19
+
20
+
21
+ load 'rails/tasks/statistics.rake'
22
+
23
+
24
+
25
+ Bundler::GemHelper.install_tasks
26
+
27
+ require 'rake/testtask'
28
+
29
+ Rake::TestTask.new(:test) do |t|
30
+ t.libs << 'lib'
31
+ t.libs << 'test'
32
+ t.pattern = 'test/**/*_test.rb'
33
+ t.verbose = false
34
+ end
35
+
36
+
37
+ task default: :test
@@ -0,0 +1,699 @@
1
+ /* ===================================================
2
+ * jquery-sortable.js v0.9.13
3
+ * http://johnny.github.com/jquery-sortable/
4
+ * ===================================================
5
+ * Copyright (c) 2012 Jonas von Andrian
6
+ * All rights reserved.
7
+ *
8
+ * Redistribution and use in source and binary forms, with or without
9
+ * modification, are permitted provided that the following conditions are met:
10
+ * * Redistributions of source code must retain the above copyright
11
+ * notice, this list of conditions and the following disclaimer.
12
+ * * Redistributions in binary form must reproduce the above copyright
13
+ * notice, this list of conditions and the following disclaimer in the
14
+ * documentation and/or other materials provided with the distribution.
15
+ * * The name of the author may not be used to endorse or promote products
16
+ * derived from this software without specific prior written permission.
17
+ *
18
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
22
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
+ * ========================================================== */
29
+
30
+
31
+ !function ( $, window, pluginName, undefined){
32
+ var containerDefaults = {
33
+ // If true, items can be dragged from this container
34
+ drag: true,
35
+ // If true, items can be droped onto this container
36
+ drop: true,
37
+ // Exclude items from being draggable, if the
38
+ // selector matches the item
39
+ exclude: "",
40
+ // If true, search for nested containers within an item.If you nest containers,
41
+ // either the original selector with which you call the plugin must only match the top containers,
42
+ // or you need to specify a group (see the bootstrap nav example)
43
+ nested: true,
44
+ // If true, the items are assumed to be arranged vertically
45
+ vertical: true
46
+ }, // end container defaults
47
+ groupDefaults = {
48
+ // This is executed after the placeholder has been moved.
49
+ // $closestItemOrContainer contains the closest item, the placeholder
50
+ // has been put at or the closest empty Container, the placeholder has
51
+ // been appended to.
52
+ afterMove: function ($placeholder, container, $closestItemOrContainer) {
53
+ },
54
+ // The exact css path between the container and its items, e.g. "> tbody"
55
+ containerPath: "",
56
+ // The css selector of the containers
57
+ containerSelector: "ol, ul",
58
+ // Distance the mouse has to travel to start dragging
59
+ distance: 0,
60
+ // Time in milliseconds after mousedown until dragging should start.
61
+ // This option can be used to prevent unwanted drags when clicking on an element.
62
+ delay: 0,
63
+ // The css selector of the drag handle
64
+ handle: "",
65
+ // The exact css path between the item and its subcontainers.
66
+ // It should only match the immediate items of a container.
67
+ // No item of a subcontainer should be matched. E.g. for ol>div>li the itemPath is "> div"
68
+ itemPath: "",
69
+ // The css selector of the items
70
+ itemSelector: "li",
71
+ // The class given to "body" while an item is being dragged
72
+ bodyClass: "dragging",
73
+ // The class giving to an item while being dragged
74
+ draggedClass: "dragged",
75
+ // Check if the dragged item may be inside the container.
76
+ // Use with care, since the search for a valid container entails a depth first search
77
+ // and may be quite expensive.
78
+ isValidTarget: function ($item, container) {
79
+ return true
80
+ },
81
+ // Executed before onDrop if placeholder is detached.
82
+ // This happens if pullPlaceholder is set to false and the drop occurs outside a container.
83
+ onCancel: function ($item, container, _super, event) {
84
+ },
85
+ // Executed at the beginning of a mouse move event.
86
+ // The Placeholder has not been moved yet.
87
+ onDrag: function ($item, position, _super, event) {
88
+ $item.css(position)
89
+ },
90
+ // Called after the drag has been started,
91
+ // that is the mouse button is being held down and
92
+ // the mouse is moving.
93
+ // The container is the closest initialized container.
94
+ // Therefore it might not be the container, that actually contains the item.
95
+ onDragStart: function ($item, container, _super, event) {
96
+ $item.css({
97
+ height: $item.outerHeight(),
98
+ width: $item.outerWidth()
99
+ })
100
+ $item.addClass(container.group.options.draggedClass)
101
+ $("body").addClass(container.group.options.bodyClass)
102
+ },
103
+ // Called when the mouse button is being released
104
+ onDrop: function ($item, container, _super, event) {
105
+ $item.removeClass(container.group.options.draggedClass).removeAttr("style")
106
+ $("body").removeClass(container.group.options.bodyClass)
107
+ },
108
+ // Called on mousedown. If falsy value is returned, the dragging will not start.
109
+ // Ignore if element clicked is input, select or textarea
110
+ onMousedown: function ($item, _super, event) {
111
+ if (!event.target.nodeName.match(/^(input|select|textarea)$/i)) {
112
+ event.preventDefault()
113
+ return true
114
+ }
115
+ },
116
+ // The class of the placeholder (must match placeholder option markup)
117
+ placeholderClass: "placeholder",
118
+ // Template for the placeholder. Can be any valid jQuery input
119
+ // e.g. a string, a DOM element.
120
+ // The placeholder must have the class "placeholder"
121
+ placeholder: '<li class="placeholder"></li>',
122
+ // If true, the position of the placeholder is calculated on every mousemove.
123
+ // If false, it is only calculated when the mouse is above a container.
124
+ pullPlaceholder: true,
125
+ // Specifies serialization of the container group.
126
+ // The pair $parent/$children is either container/items or item/subcontainers.
127
+ serialize: function ($parent, $children, parentIsContainer) {
128
+ var result = $.extend({}, $parent.data())
129
+
130
+ if(parentIsContainer)
131
+ return [$children]
132
+ else if ($children[0]){
133
+ result.children = $children
134
+ }
135
+
136
+ delete result.subContainers
137
+ delete result.sortable
138
+
139
+ return result
140
+ },
141
+ // Set tolerance while dragging. Positive values decrease sensitivity,
142
+ // negative values increase it.
143
+ tolerance: 0
144
+ }, // end group defaults
145
+ containerGroups = {},
146
+ groupCounter = 0,
147
+ emptyBox = {
148
+ left: 0,
149
+ top: 0,
150
+ bottom: 0,
151
+ right:0
152
+ },
153
+ eventNames = {
154
+ start: "touchstart.sortable mousedown.sortable",
155
+ drop: "touchend.sortable touchcancel.sortable mouseup.sortable",
156
+ drag: "touchmove.sortable mousemove.sortable",
157
+ scroll: "scroll.sortable"
158
+ },
159
+ subContainerKey = "subContainers"
160
+
161
+ /*
162
+ * a is Array [left, right, top, bottom]
163
+ * b is array [left, top]
164
+ */
165
+ function d(a,b) {
166
+ var x = Math.max(0, a[0] - b[0], b[0] - a[1]),
167
+ y = Math.max(0, a[2] - b[1], b[1] - a[3])
168
+ return x+y;
169
+ }
170
+
171
+ function setDimensions(array, dimensions, tolerance, useOffset) {
172
+ var i = array.length,
173
+ offsetMethod = useOffset ? "offset" : "position"
174
+ tolerance = tolerance || 0
175
+
176
+ while(i--){
177
+ var el = array[i].el ? array[i].el : $(array[i]),
178
+ // use fitting method
179
+ pos = el[offsetMethod]()
180
+ pos.left += parseInt(el.css('margin-left'), 10)
181
+ pos.top += parseInt(el.css('margin-top'),10)
182
+ dimensions[i] = [
183
+ pos.left - tolerance,
184
+ pos.left + el.outerWidth() + tolerance,
185
+ pos.top - tolerance,
186
+ pos.top + el.outerHeight() + tolerance
187
+ ]
188
+ }
189
+ }
190
+
191
+ function getRelativePosition(pointer, element) {
192
+ var offset = element.offset()
193
+ return {
194
+ left: pointer.left - offset.left,
195
+ top: pointer.top - offset.top
196
+ }
197
+ }
198
+
199
+ function sortByDistanceDesc(dimensions, pointer, lastPointer) {
200
+ pointer = [pointer.left, pointer.top]
201
+ lastPointer = lastPointer && [lastPointer.left, lastPointer.top]
202
+
203
+ var dim,
204
+ i = dimensions.length,
205
+ distances = []
206
+
207
+ while(i--){
208
+ dim = dimensions[i]
209
+ distances[i] = [i,d(dim,pointer), lastPointer && d(dim, lastPointer)]
210
+ }
211
+ distances = distances.sort(function (a,b) {
212
+ return b[1] - a[1] || b[2] - a[2] || b[0] - a[0]
213
+ })
214
+
215
+ // last entry is the closest
216
+ return distances
217
+ }
218
+
219
+ function ContainerGroup(options) {
220
+ this.options = $.extend({}, groupDefaults, options)
221
+ this.containers = []
222
+
223
+ if(!this.options.rootGroup){
224
+ this.scrollProxy = $.proxy(this.scroll, this)
225
+ this.dragProxy = $.proxy(this.drag, this)
226
+ this.dropProxy = $.proxy(this.drop, this)
227
+ this.placeholder = $(this.options.placeholder)
228
+
229
+ if(!options.isValidTarget)
230
+ this.options.isValidTarget = undefined
231
+ }
232
+ }
233
+
234
+ ContainerGroup.get = function (options) {
235
+ if(!containerGroups[options.group]) {
236
+ if(options.group === undefined)
237
+ options.group = groupCounter ++
238
+
239
+ containerGroups[options.group] = new ContainerGroup(options)
240
+ }
241
+
242
+ return containerGroups[options.group]
243
+ }
244
+
245
+ ContainerGroup.prototype = {
246
+ dragInit: function (e, itemContainer) {
247
+ this.$document = $(itemContainer.el[0].ownerDocument)
248
+
249
+ // get item to drag
250
+ var closestItem = $(e.target).closest(this.options.itemSelector);
251
+ // using the length of this item, prevents the plugin from being started if there is no handle being clicked on.
252
+ // this may also be helpful in instantiating multidrag.
253
+ if (closestItem.length) {
254
+ this.item = closestItem;
255
+ this.itemContainer = itemContainer;
256
+ if (this.item.is(this.options.exclude) || !this.options.onMousedown(this.item, groupDefaults.onMousedown, e)) {
257
+ return;
258
+ }
259
+ this.setPointer(e);
260
+ this.toggleListeners('on');
261
+ this.setupDelayTimer();
262
+ this.dragInitDone = true;
263
+ }
264
+ },
265
+ drag: function (e) {
266
+ if(!this.dragging){
267
+ if(!this.distanceMet(e) || !this.delayMet)
268
+ return
269
+
270
+ this.options.onDragStart(this.item, this.itemContainer, groupDefaults.onDragStart, e)
271
+ this.item.before(this.placeholder)
272
+ this.dragging = true
273
+ }
274
+
275
+ this.setPointer(e)
276
+ // place item under the cursor
277
+ this.options.onDrag(this.item,
278
+ getRelativePosition(this.pointer, this.item.offsetParent()),
279
+ groupDefaults.onDrag,
280
+ e)
281
+
282
+ var p = this.getPointer(e),
283
+ box = this.sameResultBox,
284
+ t = this.options.tolerance
285
+
286
+ if(!box || box.top - t > p.top || box.bottom + t < p.top || box.left - t > p.left || box.right + t < p.left)
287
+ if(!this.searchValidTarget()){
288
+ this.placeholder.detach()
289
+ this.lastAppendedItem = undefined
290
+ }
291
+ },
292
+ drop: function (e) {
293
+ this.toggleListeners('off')
294
+
295
+ this.dragInitDone = false
296
+
297
+ if(this.dragging){
298
+ // processing Drop, check if placeholder is detached
299
+ if(this.placeholder.closest("html")[0]){
300
+ this.placeholder.before(this.item).detach()
301
+ } else {
302
+ this.options.onCancel(this.item, this.itemContainer, groupDefaults.onCancel, e)
303
+ }
304
+ this.options.onDrop(this.item, this.getContainer(this.item), groupDefaults.onDrop, e)
305
+
306
+ // cleanup
307
+ this.clearDimensions()
308
+ this.clearOffsetParent()
309
+ this.lastAppendedItem = this.sameResultBox = undefined
310
+ this.dragging = false
311
+ }
312
+ },
313
+ searchValidTarget: function (pointer, lastPointer) {
314
+ if(!pointer){
315
+ pointer = this.relativePointer || this.pointer
316
+ lastPointer = this.lastRelativePointer || this.lastPointer
317
+ }
318
+
319
+ var distances = sortByDistanceDesc(this.getContainerDimensions(),
320
+ pointer,
321
+ lastPointer),
322
+ i = distances.length
323
+
324
+ while(i--){
325
+ var index = distances[i][0],
326
+ distance = distances[i][1]
327
+
328
+ if(!distance || this.options.pullPlaceholder){
329
+ var container = this.containers[index]
330
+ if(!container.disabled){
331
+ if(!this.$getOffsetParent()){
332
+ var offsetParent = container.getItemOffsetParent()
333
+ pointer = getRelativePosition(pointer, offsetParent)
334
+ lastPointer = getRelativePosition(lastPointer, offsetParent)
335
+ }
336
+ if(container.searchValidTarget(pointer, lastPointer))
337
+ return true
338
+ }
339
+ }
340
+ }
341
+ if(this.sameResultBox)
342
+ this.sameResultBox = undefined
343
+ },
344
+ movePlaceholder: function (container, item, method, sameResultBox) {
345
+ var lastAppendedItem = this.lastAppendedItem
346
+ if(!sameResultBox && lastAppendedItem && lastAppendedItem[0] === item[0])
347
+ return;
348
+
349
+ item[method](this.placeholder)
350
+ this.lastAppendedItem = item
351
+ this.sameResultBox = sameResultBox
352
+ this.options.afterMove(this.placeholder, container, item)
353
+ },
354
+ getContainerDimensions: function () {
355
+ if(!this.containerDimensions)
356
+ setDimensions(this.containers, this.containerDimensions = [], this.options.tolerance, !this.$getOffsetParent())
357
+ return this.containerDimensions
358
+ },
359
+ getContainer: function (element) {
360
+ return element.closest(this.options.containerSelector).data(pluginName)
361
+ },
362
+ $getOffsetParent: function () {
363
+ if(this.offsetParent === undefined){
364
+ var i = this.containers.length - 1,
365
+ offsetParent = this.containers[i].getItemOffsetParent()
366
+
367
+ if(!this.options.rootGroup){
368
+ while(i--){
369
+ if(offsetParent[0] != this.containers[i].getItemOffsetParent()[0]){
370
+ // If every container has the same offset parent,
371
+ // use position() which is relative to this parent,
372
+ // otherwise use offset()
373
+ // compare #setDimensions
374
+ offsetParent = false
375
+ break;
376
+ }
377
+ }
378
+ }
379
+
380
+ this.offsetParent = offsetParent
381
+ }
382
+ return this.offsetParent
383
+ },
384
+ setPointer: function (e) {
385
+ var pointer = this.getPointer(e)
386
+
387
+ if(this.$getOffsetParent()){
388
+ var relativePointer = getRelativePosition(pointer, this.$getOffsetParent())
389
+ this.lastRelativePointer = this.relativePointer
390
+ this.relativePointer = relativePointer
391
+ }
392
+
393
+ this.lastPointer = this.pointer
394
+ this.pointer = pointer
395
+ },
396
+ distanceMet: function (e) {
397
+ var currentPointer = this.getPointer(e)
398
+ return (Math.max(
399
+ Math.abs(this.pointer.left - currentPointer.left),
400
+ Math.abs(this.pointer.top - currentPointer.top)
401
+ ) >= this.options.distance)
402
+ },
403
+ getPointer: function(e) {
404
+ var o = e.originalEvent || e.originalEvent.touches && e.originalEvent.touches[0]
405
+ return {
406
+ left: e.pageX || o.pageX,
407
+ top: e.pageY || o.pageY
408
+ }
409
+ },
410
+ setupDelayTimer: function () {
411
+ var that = this
412
+ this.delayMet = !this.options.delay
413
+
414
+ // init delay timer if needed
415
+ if (!this.delayMet) {
416
+ clearTimeout(this._mouseDelayTimer);
417
+ this._mouseDelayTimer = setTimeout(function() {
418
+ that.delayMet = true
419
+ }, this.options.delay)
420
+ }
421
+ },
422
+ scroll: function (e) {
423
+ this.clearDimensions()
424
+ this.clearOffsetParent() // TODO is this needed?
425
+ },
426
+ toggleListeners: function (method) {
427
+ var that = this,
428
+ events = ['drag','drop','scroll']
429
+
430
+ $.each(events,function (i,event) {
431
+ that.$document[method](eventNames[event], that[event + 'Proxy'])
432
+ })
433
+ },
434
+ clearOffsetParent: function () {
435
+ this.offsetParent = undefined
436
+ },
437
+ // Recursively clear container and item dimensions
438
+ clearDimensions: function () {
439
+ this.traverse(function(object){
440
+ object._clearDimensions()
441
+ })
442
+ },
443
+ traverse: function(callback) {
444
+ callback(this)
445
+ var i = this.containers.length
446
+ while(i--){
447
+ this.containers[i].traverse(callback)
448
+ }
449
+ },
450
+ _clearDimensions: function(){
451
+ this.containerDimensions = undefined
452
+ },
453
+ _destroy: function () {
454
+ containerGroups[this.options.group] = undefined
455
+ }
456
+ }
457
+
458
+ function Container(element, options) {
459
+ this.el = element
460
+ this.options = $.extend( {}, containerDefaults, options)
461
+
462
+ this.group = ContainerGroup.get(this.options)
463
+ this.rootGroup = this.options.rootGroup || this.group
464
+ this.handle = this.rootGroup.options.handle || this.rootGroup.options.itemSelector
465
+
466
+ var itemPath = this.rootGroup.options.itemPath
467
+ this.target = itemPath ? this.el.find(itemPath) : this.el
468
+
469
+ this.target.on(eventNames.start, this.handle, $.proxy(this.dragInit, this))
470
+
471
+ if(this.options.drop)
472
+ this.group.containers.push(this)
473
+ }
474
+
475
+ Container.prototype = {
476
+ dragInit: function (e) {
477
+ var rootGroup = this.rootGroup
478
+
479
+ if( !this.disabled &&
480
+ !rootGroup.dragInitDone &&
481
+ this.options.drag &&
482
+ this.isValidDrag(e)) {
483
+ rootGroup.dragInit(e, this)
484
+ }
485
+ },
486
+ isValidDrag: function(e) {
487
+ return e.which == 1 ||
488
+ e.type == "touchstart" && e.originalEvent.touches.length == 1
489
+ },
490
+ searchValidTarget: function (pointer, lastPointer) {
491
+ var distances = sortByDistanceDesc(this.getItemDimensions(),
492
+ pointer,
493
+ lastPointer),
494
+ i = distances.length,
495
+ rootGroup = this.rootGroup,
496
+ validTarget = !rootGroup.options.isValidTarget ||
497
+ rootGroup.options.isValidTarget(rootGroup.item, this)
498
+
499
+ if(!i && validTarget){
500
+ rootGroup.movePlaceholder(this, this.target, "append")
501
+ return true
502
+ } else
503
+ while(i--){
504
+ var index = distances[i][0],
505
+ distance = distances[i][1]
506
+ if(!distance && this.hasChildGroup(index)){
507
+ var found = this.getContainerGroup(index).searchValidTarget(pointer, lastPointer)
508
+ if(found)
509
+ return true
510
+ }
511
+ else if(validTarget){
512
+ this.movePlaceholder(index, pointer)
513
+ return true
514
+ }
515
+ }
516
+ },
517
+ movePlaceholder: function (index, pointer) {
518
+ var item = $(this.items[index]),
519
+ dim = this.itemDimensions[index],
520
+ method = "after",
521
+ width = item.outerWidth(),
522
+ height = item.outerHeight(),
523
+ offset = item.offset(),
524
+ sameResultBox = {
525
+ left: offset.left,
526
+ right: offset.left + width,
527
+ top: offset.top,
528
+ bottom: offset.top + height
529
+ }
530
+ if(this.options.vertical){
531
+ var yCenter = (dim[2] + dim[3]) / 2,
532
+ inUpperHalf = pointer.top <= yCenter
533
+ if(inUpperHalf){
534
+ method = "before"
535
+ sameResultBox.bottom -= height / 2
536
+ } else
537
+ sameResultBox.top += height / 2
538
+ } else {
539
+ var xCenter = (dim[0] + dim[1]) / 2,
540
+ inLeftHalf = pointer.left <= xCenter
541
+ if(inLeftHalf){
542
+ method = "before"
543
+ sameResultBox.right -= width / 2
544
+ } else
545
+ sameResultBox.left += width / 2
546
+ }
547
+ if(this.hasChildGroup(index))
548
+ sameResultBox = emptyBox
549
+ this.rootGroup.movePlaceholder(this, item, method, sameResultBox)
550
+ },
551
+ getItemDimensions: function () {
552
+ if(!this.itemDimensions){
553
+ this.items = this.$getChildren(this.el, "item").filter(
554
+ ":not(." + this.group.options.placeholderClass + ", ." + this.group.options.draggedClass + ")"
555
+ ).get()
556
+ setDimensions(this.items, this.itemDimensions = [], this.options.tolerance)
557
+ }
558
+ return this.itemDimensions
559
+ },
560
+ getItemOffsetParent: function () {
561
+ var offsetParent,
562
+ el = this.el
563
+ // Since el might be empty we have to check el itself and
564
+ // can not do something like el.children().first().offsetParent()
565
+ if(el.css("position") === "relative" || el.css("position") === "absolute" || el.css("position") === "fixed")
566
+ offsetParent = el
567
+ else
568
+ offsetParent = el.offsetParent()
569
+ return offsetParent
570
+ },
571
+ hasChildGroup: function (index) {
572
+ return this.options.nested && this.getContainerGroup(index)
573
+ },
574
+ getContainerGroup: function (index) {
575
+ var childGroup = $.data(this.items[index], subContainerKey)
576
+ if( childGroup === undefined){
577
+ var childContainers = this.$getChildren(this.items[index], "container")
578
+ childGroup = false
579
+
580
+ if(childContainers[0]){
581
+ var options = $.extend({}, this.options, {
582
+ rootGroup: this.rootGroup,
583
+ group: groupCounter ++
584
+ })
585
+ childGroup = childContainers[pluginName](options).data(pluginName).group
586
+ }
587
+ $.data(this.items[index], subContainerKey, childGroup)
588
+ }
589
+ return childGroup
590
+ },
591
+ $getChildren: function (parent, type) {
592
+ var options = this.rootGroup.options,
593
+ path = options[type + "Path"],
594
+ selector = options[type + "Selector"]
595
+
596
+ parent = $(parent)
597
+ if(path)
598
+ parent = parent.find(path)
599
+
600
+ return parent.children(selector)
601
+ },
602
+ _serialize: function (parent, isContainer) {
603
+ var that = this,
604
+ childType = isContainer ? "item" : "container",
605
+
606
+ children = this.$getChildren(parent, childType).not(this.options.exclude).map(function () {
607
+ return that._serialize($(this), !isContainer)
608
+ }).get()
609
+
610
+ return this.rootGroup.options.serialize(parent, children, isContainer)
611
+ },
612
+ traverse: function(callback) {
613
+ $.each(this.items || [], function(item){
614
+ var group = $.data(this, subContainerKey)
615
+ if(group)
616
+ group.traverse(callback)
617
+ });
618
+
619
+ callback(this)
620
+ },
621
+ _clearDimensions: function () {
622
+ this.itemDimensions = undefined
623
+ },
624
+ _destroy: function() {
625
+ console.log('destroy in container')
626
+ var that = this;
627
+
628
+ this.target.off(eventNames.start, this.handle);
629
+ this.el.removeData(pluginName)
630
+
631
+ if(this.options.drop)
632
+ this.group.containers = $.grep(this.group.containers, function(val){
633
+ return val != that
634
+ })
635
+
636
+ $.each(this.items || [], function(){
637
+ $.removeData(this, subContainerKey)
638
+ })
639
+ }
640
+ }
641
+
642
+ var API = {
643
+ enable: function() {
644
+ this.traverse(function(object){
645
+ object.disabled = false
646
+ })
647
+ },
648
+ disable: function (){
649
+ this.traverse(function(object){
650
+ object.disabled = true
651
+ })
652
+ },
653
+ serialize: function () {
654
+ return this._serialize(this.el, true)
655
+ },
656
+ refresh: function() {
657
+ this.traverse(function(object){
658
+ object._clearDimensions()
659
+ })
660
+ },
661
+ destroy: function () {
662
+ this.traverse(function(object){
663
+ object._destroy();
664
+ })
665
+ }
666
+ }
667
+
668
+ $.extend(Container.prototype, API)
669
+
670
+ /**
671
+ * jQuery API
672
+ *
673
+ * Parameters are
674
+ * either options on init
675
+ * or a method name followed by arguments to pass to the method
676
+ */
677
+ $.fn[pluginName] = function(methodOrOptions) {
678
+ var args = Array.prototype.slice.call(arguments, 1)
679
+
680
+ // clear containerGroups for turbolinks
681
+ if(typeof methodOrOptions === 'object' && methodOrOptions.clear){
682
+ containerGroups = [];
683
+ }
684
+
685
+ return this.map(function(){
686
+ var $t = $(this),
687
+ object = $t.data(pluginName)
688
+
689
+ if(object && API[methodOrOptions])
690
+ return API[methodOrOptions].apply(object, args) || this
691
+ else if(!object && (methodOrOptions === undefined ||
692
+ typeof methodOrOptions === "object"))
693
+ $t.data(pluginName, new Container($t, methodOrOptions))
694
+
695
+ return this
696
+ });
697
+ };
698
+
699
+ }(jQuery, window, 'sortable');