Fixed time zone calculation issue
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WCF.js
1 /**
2 * Class and function collection for WCF.
3 *
4 * Major Contributors: Markus Bartz, Tim Duesterhus, Matthias Schmidt and Marcel Werk
5 *
6 * @author Alexander Ebert
7 * @copyright 2001-2014 WoltLab GmbH
8 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
9 */
10
11 (function() {
12 // store original implementation
13 var $jQueryData = jQuery.fn.data;
14
15 /**
16 * Override jQuery.fn.data() to support custom 'ID' suffix which will
17 * be translated to '-id' at runtime.
18 *
19 * @see jQuery.fn.data()
20 */
21 jQuery.fn.data = function(key, value) {
22 if (key) {
23 switch (typeof key) {
24 case 'object':
25 for (var $key in key) {
26 if ($key.match(/ID$/)) {
27 var $value = key[$key];
28 delete key[$key];
29
30 $key = $key.replace(/ID$/, '-id');
31 key[$key] = $value;
32 }
33 }
34
35 arguments[0] = key;
36 break;
37
38 case 'string':
39 if (key.match(/ID$/)) {
40 arguments[0] = key.replace(/ID$/, '-id');
41 }
42 break;
43 }
44 }
45
46 // call jQuery's own data method
47 var $data = $jQueryData.apply(this, arguments);
48
49 // handle .data() call without arguments
50 if (key === undefined) {
51 for (var $key in $data) {
52 if ($key.match(/Id$/)) {
53 $data[$key.replace(/Id$/, 'ID')] = $data[$key];
54 delete $data[$key];
55 }
56 }
57 }
58
59 return $data;
60 };
61
62 // provide a sane window.console implementation
63 if (!window.console) window.console = { };
64 var consoleProperties = [ "log",/* "debug",*/ "info", "warn", "exception", "assert", "dir", "dirxml", "trace", "group", "groupEnd", "groupCollapsed", "profile", "profileEnd", "count", "clear", "time", "timeEnd", "timeStamp", "table", "error" ];
65 for (var i = 0; i < consoleProperties.length; i++) {
66 if (typeof (console[consoleProperties[i]]) === 'undefined') {
67 console[consoleProperties[i]] = function () { };
68 }
69 }
70
71 if (typeof(console.debug) === 'undefined') {
72 // forward console.debug to console.log (IE9)
73 console.debug = function(string) { console.log(string); };
74 }
75 })();
76
77 /**
78 * Simple JavaScript Inheritance
79 * By John Resig http://ejohn.org/
80 * MIT Licensed.
81 */
82 // Inspired by base2 and Prototype
83 (function(){var a=false,b=/xyz/.test(function(){xyz})?/\b_super\b/:/.*/;this.Class=function(){};Class.extend=function(c){function g(){if(!a&&this.init)this.init.apply(this,arguments);}var d=this.prototype;a=true;var e=new this;a=false;for(var f in c){e[f]=typeof c[f]=="function"&&typeof d[f]=="function"&&b.test(c[f])?function(a,b){return function(){var c=this._super;this._super=d[a];var e=b.apply(this,arguments);this._super=c;return e;};}(f,c[f]):c[f]}g.prototype=e;g.prototype.constructor=g;g.extend=arguments.callee;return g;};})();
84
85 /*! matchMedia() polyfill - Test a CSS media type/query in JS. Authors & copyright (c) 2012: Scott Jehl, Paul Irish, Nicholas Zakas, David Knight. Dual MIT/BSD license */
86 window.matchMedia||(window.matchMedia=function(){"use strict";var e=window.styleMedia||window.media;if(!e){var t=document.createElement("style"),n=document.getElementsByTagName("script")[0],r=null;t.type="text/css";t.id="matchmediajs-test";n.parentNode.insertBefore(t,n);r="getComputedStyle"in window&&window.getComputedStyle(t,null)||t.currentStyle;e={matchMedium:function(e){var n="@media "+e+"{ #matchmediajs-test { width: 1px; } }";if(t.styleSheet){t.styleSheet.cssText=n}else{t.textContent=n}return r.width==="1px"}}}return function(t){return{matches:e.matchMedium(t||"all"),media:t||"all"}}}());
87
88 /*! matchMedia() polyfill addListener/removeListener extension. Author & copyright (c) 2012: Scott Jehl. Dual MIT/BSD license */
89 (function(){if(window.matchMedia&&window.matchMedia("all").addListener){return false}var e=window.matchMedia,t=e("only all").matches,n=false,r=0,i=[],s=function(t){clearTimeout(r);r=setTimeout(function(){for(var t=0,n=i.length;t<n;t++){var r=i[t].mql,s=i[t].listeners||[],o=e(r.media).matches;if(o!==r.matches){r.matches=o;for(var u=0,a=s.length;u<a;u++){s[u].call(window,r)}}}},30)};window.matchMedia=function(r){var o=e(r),u=[],a=0;o.addListener=function(e){if(!t){return}if(!n){n=true;window.addEventListener("resize",s,true)}if(a===0){a=i.push({mql:o,listeners:u})}u.push(e)};o.removeListener=function(e){for(var t=0,n=u.length;t<n;t++){if(u[t]===e){u.splice(t,1)}}};return o}})();
90
91 /*!
92 * enquire.js v2.1.0 - Awesome Media Queries in JavaScript
93 * Copyright (c) 2013 Nick Williams - http://wicky.nillia.ms/enquire.js
94 * License: MIT (http://www.opensource.org/licenses/mit-license.php)
95 */
96 (function(t,i,n){var e=i.matchMedia;"undefined"!=typeof module&&module.exports?module.exports=n(e):"function"==typeof define&&define.amd?define(function(){return i[t]=n(e)}):i[t]=n(e)})("enquire",this,function(t){"use strict";function i(t,i){var n,e=0,s=t.length;for(e;s>e&&(n=i(t[e],e),n!==!1);e++);}function n(t){return"[object Array]"===Object.prototype.toString.apply(t)}function e(t){return"function"==typeof t}function s(t){this.options=t,!t.deferSetup&&this.setup()}function o(i,n){this.query=i,this.isUnconditional=n,this.handlers=[],this.mql=t(i);var e=this;this.listener=function(t){e.mql=t,e.assess()},this.mql.addListener(this.listener)}function r(){if(!t)throw Error("matchMedia not present, legacy browsers require a polyfill");this.queries={},this.browserIsIncapable=!t("only all").matches}return s.prototype={setup:function(){this.options.setup&&this.options.setup(),this.initialised=!0},on:function(){!this.initialised&&this.setup(),this.options.match&&this.options.match()},off:function(){this.options.unmatch&&this.options.unmatch()},destroy:function(){this.options.destroy?this.options.destroy():this.off()},equals:function(t){return this.options===t||this.options.match===t}},o.prototype={addHandler:function(t){var i=new s(t);this.handlers.push(i),this.matches()&&i.on()},removeHandler:function(t){var n=this.handlers;i(n,function(i,e){return i.equals(t)?(i.destroy(),!n.splice(e,1)):void 0})},matches:function(){return this.mql.matches||this.isUnconditional},clear:function(){i(this.handlers,function(t){t.destroy()}),this.mql.removeListener(this.listener),this.handlers.length=0},assess:function(){var t=this.matches()?"on":"off";i(this.handlers,function(i){i[t]()})}},r.prototype={register:function(t,s,r){var h=this.queries,u=r&&this.browserIsIncapable;return h[t]||(h[t]=new o(t,u)),e(s)&&(s={match:s}),n(s)||(s=[s]),i(s,function(i){h[t].addHandler(i)}),this},unregister:function(t,i){var n=this.queries[t];return n&&(i?n.removeHandler(i):(n.clear(),delete this.queries[t])),this}},new r});
97
98 /*! head.load - v1.0.3 */
99 (function(n,t){"use strict";function w(){}function u(n,t){if(n){typeof n=="object"&&(n=[].slice.call(n));for(var i=0,r=n.length;i<r;i++)t.call(n,n[i],i)}}function it(n,i){var r=Object.prototype.toString.call(i).slice(8,-1);return i!==t&&i!==null&&r===n}function s(n){return it("Function",n)}function a(n){return it("Array",n)}function et(n){var i=n.split("/"),t=i[i.length-1],r=t.indexOf("?");return r!==-1?t.substring(0,r):t}function f(n){(n=n||w,n._done)||(n(),n._done=1)}function ot(n,t,r,u){var f=typeof n=="object"?n:{test:n,success:!t?!1:a(t)?t:[t],failure:!r?!1:a(r)?r:[r],callback:u||w},e=!!f.test;return e&&!!f.success?(f.success.push(f.callback),i.load.apply(null,f.success)):e||!f.failure?u():(f.failure.push(f.callback),i.load.apply(null,f.failure)),i}function v(n){var t={},i,r;if(typeof n=="object")for(i in n)!n[i]||(t={name:i,url:n[i]});else t={name:et(n),url:n};return(r=c[t.name],r&&r.url===t.url)?r:(c[t.name]=t,t)}function y(n){n=n||c;for(var t in n)if(n.hasOwnProperty(t)&&n[t].state!==l)return!1;return!0}function st(n){n.state=ft;u(n.onpreload,function(n){n.call()})}function ht(n){n.state===t&&(n.state=nt,n.onpreload=[],rt({url:n.url,type:"cache"},function(){st(n)}))}function ct(){var n=arguments,t=n[n.length-1],r=[].slice.call(n,1),f=r[0];return(s(t)||(t=null),a(n[0]))?(n[0].push(t),i.load.apply(null,n[0]),i):(f?(u(r,function(n){s(n)||!n||ht(v(n))}),b(v(n[0]),s(f)?f:function(){i.load.apply(null,r)})):b(v(n[0])),i)}function lt(){var n=arguments,t=n[n.length-1],r={};return(s(t)||(t=null),a(n[0]))?(n[0].push(t),i.load.apply(null,n[0]),i):(u(n,function(n){n!==t&&(n=v(n),r[n.name]=n)}),u(n,function(n){n!==t&&(n=v(n),b(n,function(){y(r)&&f(t)}))}),i)}function b(n,t){if(t=t||w,n.state===l){t();return}if(n.state===tt){i.ready(n.name,t);return}if(n.state===nt){n.onpreload.push(function(){b(n,t)});return}n.state=tt;rt(n,function(){n.state=l;t();u(h[n.name],function(n){f(n)});o&&y()&&u(h.ALL,function(n){f(n)})})}function at(n){n=n||"";var t=n.split("?")[0].split(".");return t[t.length-1].toLowerCase()}function rt(t,i){function e(t){t=t||n.event;u.onload=u.onreadystatechange=u.onerror=null;i()}function o(f){f=f||n.event;(f.type==="load"||/loaded|complete/.test(u.readyState)&&(!r.documentMode||r.documentMode<9))&&(n.clearTimeout(t.errorTimeout),n.clearTimeout(t.cssTimeout),u.onload=u.onreadystatechange=u.onerror=null,i())}function s(){if(t.state!==l&&t.cssRetries<=20){for(var i=0,f=r.styleSheets.length;i<f;i++)if(r.styleSheets[i].href===u.href){o({type:"load"});return}t.cssRetries++;t.cssTimeout=n.setTimeout(s,250)}}var u,h,f;i=i||w;h=at(t.url);h==="css"?(u=r.createElement("link"),u.type="text/"+(t.type||"css"),u.rel="stylesheet",u.href=t.url,t.cssRetries=0,t.cssTimeout=n.setTimeout(s,500)):(u=r.createElement("script"),u.type="text/"+(t.type||"javascript"),u.src=t.url);u.onload=u.onreadystatechange=o;u.onerror=e;u.async=!1;u.defer=!1;t.errorTimeout=n.setTimeout(function(){e({type:"timeout"})},7e3);f=r.head||r.getElementsByTagName("head")[0];f.insertBefore(u,f.lastChild)}function vt(){for(var t,u=r.getElementsByTagName("script"),n=0,f=u.length;n<f;n++)if(t=u[n].getAttribute("data-headjs-load"),!!t){i.load(t);return}}function yt(n,t){var v,p,e;return n===r?(o?f(t):d.push(t),i):(s(n)&&(t=n,n="ALL"),a(n))?(v={},u(n,function(n){v[n]=c[n];i.ready(n,function(){y(v)&&f(t)})}),i):typeof n!="string"||!s(t)?i:(p=c[n],p&&p.state===l||n==="ALL"&&y()&&o)?(f(t),i):(e=h[n],e?e.push(t):e=h[n]=[t],i)}function e(){if(!r.body){n.clearTimeout(i.readyTimeout);i.readyTimeout=n.setTimeout(e,50);return}o||(o=!0,vt(),u(d,function(n){f(n)}))}function k(){r.addEventListener?(r.removeEventListener("DOMContentLoaded",k,!1),e()):r.readyState==="complete"&&(r.detachEvent("onreadystatechange",k),e())}var r=n.document,d=[],h={},c={},ut="async"in r.createElement("script")||"MozAppearance"in r.documentElement.style||n.opera,o,g=n.head_conf&&n.head_conf.head||"head",i=n[g]=n[g]||function(){i.ready.apply(null,arguments)},nt=1,ft=2,tt=3,l=4,p;if(r.readyState==="complete")e();else if(r.addEventListener)r.addEventListener("DOMContentLoaded",k,!1),n.addEventListener("load",e,!1);else{r.attachEvent("onreadystatechange",k);n.attachEvent("onload",e);p=!1;try{p=!n.frameElement&&r.documentElement}catch(wt){}p&&p.doScroll&&function pt(){if(!o){try{p.doScroll("left")}catch(t){n.clearTimeout(i.readyTimeout);i.readyTimeout=n.setTimeout(pt,50);return}e()}}()}i.load=i.js=ut?lt:ct;i.test=ot;i.ready=yt;i.ready(r,function(){y()&&u(h.ALL,function(n){f(n)});i.feature&&i.feature("domloaded",!0)})})(window);
100 /*
101 //# sourceMappingURL=head.load.min.js.map
102 */
103
104 /**
105 * Provides a hashCode() method for strings, similar to Java's String.hashCode().
106 *
107 * @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
108 */
109 String.prototype.hashCode = function() {
110 var $char;
111 var $hash = 0;
112
113 if (this.length) {
114 for (var $i = 0, $length = this.length; $i < $length; $i++) {
115 $char = this.charCodeAt($i);
116 $hash = (($hash << 5) - $hash) + $char;
117 $hash = $hash & $hash; // convert to 32bit integer
118 }
119 }
120
121 return $hash;
122 };
123
124 /**
125 * Adds a Fisher-Yates shuffle algorithm for arrays.
126 *
127 * @see http://stackoverflow.com/a/2450976
128 */
129 function shuffle(array) {
130 var currentIndex = array.length, temporaryValue, randomIndex;
131
132 // While there remain elements to shuffle...
133 while (0 !== currentIndex) {
134 // Pick a remaining element...
135 randomIndex = Math.floor(Math.random() * currentIndex);
136 currentIndex -= 1;
137
138 // And swap it with the current element.
139 temporaryValue = array[currentIndex];
140 array[currentIndex] = array[randomIndex];
141 array[randomIndex] = temporaryValue;
142 }
143
144 return this;
145 };
146
147 /**
148 * User-Agent based browser detection and touch detection.
149 */
150 (function() {
151 var ua = navigator.userAgent.toLowerCase();
152 var match = /(chrome)[ \/]([\w.]+)/.exec( ua ) ||
153 /(webkit)[ \/]([\w.]+)/.exec( ua ) ||
154 /(opera)(?:.*version|)[ \/]([\w.]+)/.exec( ua ) ||
155 /(msie) ([\w.]+)/.exec( ua ) ||
156 ua.indexOf("compatible") < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec( ua ) ||
157 [];
158
159 var matched = {
160 browser: match[ 1 ] || "",
161 version: match[ 2 ] || "0"
162 };
163 browser = {};
164
165 if ( matched.browser ) {
166 browser[ matched.browser ] = true;
167 browser.version = matched.version;
168 }
169
170 // Chrome is Webkit, but Webkit is also Safari.
171 if ( browser.chrome ) {
172 browser.webkit = true;
173 } else if ( browser.webkit ) {
174 browser.safari = true;
175 }
176
177 jQuery.browser = browser;
178 jQuery.browser.touch = (!!('ontouchstart' in window) || (!!('msMaxTouchPoints' in window.navigator) && window.navigator.msMaxTouchPoints > 0));
179
180 // detect smartphones
181 jQuery.browser.smartphone = ($('html').css('caption-side') == 'bottom');
182
183 // CKEditor support (disabled for Android & Windows Phone)
184 jQuery.browser.ckeditor = (navigator.userAgent.match(/(Android|Windows Phone)/i)) ? false : true;
185
186 // properly detect IE11
187 if (jQuery.browser.mozilla && ua.match(/trident/)) {
188 jQuery.browser.mozilla = false;
189 jQuery.browser.msie = true;
190 }
191 })();
192
193 /**
194 * jQuery.browser.mobile (http://detectmobilebrowser.com/)
195 *
196 * jQuery.browser.mobile will be true if the browser is a mobile device
197 *
198 **/
199 (function(a){(jQuery.browser=jQuery.browser||{}).mobile=/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od|ad)|iris|kindle|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))})(navigator.userAgent||navigator.vendor||window.opera);
200
201 /**
202 * Initialize WCF namespace
203 */
204 var WCF = {};
205
206 /**
207 * Extends jQuery with additional methods.
208 */
209 $.extend(true, {
210 /**
211 * Removes the given value from the given array and returns the array.
212 *
213 * @param array array
214 * @param mixed element
215 * @return array
216 */
217 removeArrayValue: function(array, value) {
218 return $.grep(array, function(element, index) {
219 return value !== element;
220 });
221 },
222
223 /**
224 * Escapes an ID to work with jQuery selectors.
225 *
226 * @see http://docs.jquery.com/Frequently_Asked_Questions#How_do_I_select_an_element_by_an_ID_that_has_characters_used_in_CSS_notation.3F
227 * @param string id
228 * @return string
229 */
230 wcfEscapeID: function(id) {
231 return id.replace(/(:|\.)/g, '\\$1');
232 },
233
234 /**
235 * Returns true if given ID exists within DOM.
236 *
237 * @param string id
238 * @return boolean
239 */
240 wcfIsset: function(id) {
241 return !!$('#' + $.wcfEscapeID(id)).length;
242 },
243
244 /**
245 * Returns the length of an object.
246 *
247 * @param object targetObject
248 * @return integer
249 */
250 getLength: function(targetObject) {
251 var $length = 0;
252
253 for (var $key in targetObject) {
254 if (targetObject.hasOwnProperty($key)) {
255 $length++;
256 }
257 }
258
259 return $length;
260 }
261 });
262
263 /**
264 * Extends jQuery's chainable methods.
265 */
266 $.fn.extend({
267 /**
268 * Returns tag name of first jQuery element.
269 *
270 * @returns string
271 */
272 getTagName: function() {
273 return (this.length) ? this.get(0).tagName.toLowerCase() : '';
274 },
275
276 /**
277 * Returns the dimensions for current element.
278 *
279 * @see http://api.jquery.com/hidden-selector/
280 * @param string type
281 * @return object
282 */
283 getDimensions: function(type) {
284 var dimensions = css = {};
285 var wasHidden = false;
286
287 // show element to retrieve dimensions and restore them later
288 if (this.is(':hidden')) {
289 css = WCF.getInlineCSS(this);
290
291 wasHidden = true;
292
293 this.css({
294 display: 'block',
295 visibility: 'hidden'
296 });
297 }
298
299 switch (type) {
300 case 'inner':
301 dimensions = {
302 height: this.innerHeight(),
303 width: this.innerWidth()
304 };
305 break;
306
307 case 'outer':
308 dimensions = {
309 height: this.outerHeight(),
310 width: this.outerWidth()
311 };
312 break;
313
314 default:
315 dimensions = {
316 height: this.height(),
317 width: this.width()
318 };
319 break;
320 }
321
322 // restore previous settings
323 if (wasHidden) {
324 WCF.revertInlineCSS(this, css, [ 'display', 'visibility' ]);
325 }
326
327 return dimensions;
328 },
329
330 /**
331 * Returns the offsets for current element, defaults to position
332 * relative to document.
333 *
334 * @see http://api.jquery.com/hidden-selector/
335 * @param string type
336 * @return object
337 */
338 getOffsets: function(type) {
339 var offsets = css = {};
340 var wasHidden = false;
341
342 // show element to retrieve dimensions and restore them later
343 if (this.is(':hidden')) {
344 css = WCF.getInlineCSS(this);
345 wasHidden = true;
346
347 this.css({
348 display: 'block',
349 visibility: 'hidden'
350 });
351 }
352
353 switch (type) {
354 case 'offset':
355 offsets = this.offset();
356 break;
357
358 case 'position':
359 default:
360 offsets = this.position();
361 break;
362 }
363
364 // restore previous settings
365 if (wasHidden) {
366 WCF.revertInlineCSS(this, css, [ 'display', 'visibility' ]);
367 }
368
369 return offsets;
370 },
371
372 /**
373 * Changes element's position to 'absolute' or 'fixed' while maintaining it's
374 * current position relative to viewport. Optionally removes element from
375 * current DOM-node and moving it into body-element (useful for drag & drop)
376 *
377 * @param boolean rebase
378 * @return object
379 */
380 makePositioned: function(position, rebase) {
381 if (position != 'absolute' && position != 'fixed') {
382 position = 'absolute';
383 }
384
385 var $currentPosition = this.getOffsets('position');
386 this.css({
387 position: position,
388 left: $currentPosition.left,
389 margin: 0,
390 top: $currentPosition.top
391 });
392
393 if (rebase) {
394 this.remove().appentTo('body');
395 }
396
397 return this;
398 },
399
400 /**
401 * Disables a form element.
402 *
403 * @return jQuery
404 */
405 disable: function() {
406 return this.attr('disabled', 'disabled');
407 },
408
409 /**
410 * Enables a form element.
411 *
412 * @return jQuery
413 */
414 enable: function() {
415 return this.removeAttr('disabled');
416 },
417
418 /**
419 * Returns the element's id. If none is set, a random unique
420 * ID will be assigned.
421 *
422 * @return string
423 */
424 wcfIdentify: function() {
425 if (!this.attr('id')) {
426 this.attr('id', WCF.getRandomID());
427 }
428
429 return this.attr('id');
430 },
431
432 /**
433 * Returns the caret position of current element. If the element
434 * does not equal input[type=text], input[type=password] or
435 * textarea, -1 is returned.
436 *
437 * @return integer
438 */
439 getCaret: function() {
440 if (this.is('input')) {
441 if (this.attr('type') != 'text' && this.attr('type') != 'password') {
442 return -1;
443 }
444 }
445 else if (!this.is('textarea')) {
446 return -1;
447 }
448
449 var $position = 0;
450 var $element = this.get(0);
451 if (document.selection) { // IE 8
452 // set focus to enable caret on this element
453 this.focus();
454
455 var $selection = document.selection.createRange();
456 $selection.moveStart('character', -this.val().length);
457 $position = $selection.text.length;
458 }
459 else if ($element.selectionStart || $element.selectionStart == '0') { // Opera, Chrome, Firefox, Safari, IE 9+
460 $position = parseInt($element.selectionStart);
461 }
462
463 return $position;
464 },
465
466 /**
467 * Sets the caret position of current element. If the element
468 * does not equal input[type=text], input[type=password] or
469 * textarea, false is returned.
470 *
471 * @param integer position
472 * @return boolean
473 */
474 setCaret: function (position) {
475 if (this.is('input')) {
476 if (this.attr('type') != 'text' && this.attr('type') != 'password') {
477 return false;
478 }
479 }
480 else if (!this.is('textarea')) {
481 return false;
482 }
483
484 var $element = this.get(0);
485
486 // set focus to enable caret on this element
487 this.focus();
488 if (document.selection) { // IE 8
489 var $selection = document.selection.createRange();
490 $selection.moveStart('character', position);
491 $selection.moveEnd('character', 0);
492 $selection.select();
493 }
494 else if ($element.selectionStart || $element.selectionStart == '0') { // Opera, Chrome, Firefox, Safari, IE 9+
495 $element.selectionStart = position;
496 $element.selectionEnd = position;
497 }
498
499 return true;
500 },
501
502 /**
503 * Shows an element by sliding and fading it into viewport.
504 *
505 * @param string direction
506 * @param object callback
507 * @param integer duration
508 * @returns jQuery
509 */
510 wcfDropIn: function(direction, callback, duration) {
511 if (!direction) direction = 'up';
512 if (!duration || !parseInt(duration)) duration = 200;
513
514 return this.show(WCF.getEffect(this, 'drop'), { direction: direction }, duration, callback);
515 },
516
517 /**
518 * Hides an element by sliding and fading it out the viewport.
519 *
520 * @param string direction
521 * @param object callback
522 * @param integer duration
523 * @returns jQuery
524 */
525 wcfDropOut: function(direction, callback, duration) {
526 if (!direction) direction = 'down';
527 if (!duration || !parseInt(duration)) duration = 200;
528
529 return this.hide(WCF.getEffect(this, 'drop'), { direction: direction }, duration, callback);
530 },
531
532 /**
533 * Shows an element by blinding it up.
534 *
535 * @param string direction
536 * @param object callback
537 * @param integer duration
538 * @returns jQuery
539 */
540 wcfBlindIn: function(direction, callback, duration) {
541 if (!direction) direction = 'vertical';
542 if (!duration || !parseInt(duration)) duration = 200;
543
544 return this.show(WCF.getEffect(this, 'blind'), { direction: direction }, duration, callback);
545 },
546
547 /**
548 * Hides an element by blinding it down.
549 *
550 * @param string direction
551 * @param object callback
552 * @param integer duration
553 * @returns jQuery
554 */
555 wcfBlindOut: function(direction, callback, duration) {
556 if (!direction) direction = 'vertical';
557 if (!duration || !parseInt(duration)) duration = 200;
558
559 return this.hide(WCF.getEffect(this, 'blind'), { direction: direction }, duration, callback);
560 },
561
562 /**
563 * Highlights an element.
564 *
565 * @param object options
566 * @param object callback
567 * @returns jQuery
568 */
569 wcfHighlight: function(options, callback) {
570 return this.effect('highlight', options, 600, callback);
571 },
572
573 /**
574 * Shows an element by fading it in.
575 *
576 * @param object callback
577 * @param integer duration
578 * @returns jQuery
579 */
580 wcfFadeIn: function(callback, duration) {
581 if (!duration || !parseInt(duration)) duration = 200;
582
583 return this.show(WCF.getEffect(this, 'fade'), { }, duration, callback);
584 },
585
586 /**
587 * Hides an element by fading it out.
588 *
589 * @param object callback
590 * @param integer duration
591 * @returns jQuery
592 */
593 wcfFadeOut: function(callback, duration) {
594 if (!duration || !parseInt(duration)) duration = 200;
595
596 return this.hide(WCF.getEffect(this, 'fade'), { }, duration, callback);
597 }
598 });
599
600 /**
601 * WoltLab Community Framework core methods
602 */
603 $.extend(WCF, {
604 /**
605 * count of active dialogs
606 * @var integer
607 */
608 activeDialogs: 0,
609
610 /**
611 * Counter for dynamic element ids
612 *
613 * @var integer
614 */
615 _idCounter: 0,
616
617 /**
618 * Returns a dynamically created id.
619 *
620 * @see https://github.com/sstephenson/prototype/blob/5e5cfff7c2c253eaf415c279f9083b4650cd4506/src/prototype/dom/dom.js#L1789
621 * @return string
622 */
623 getRandomID: function() {
624 var $elementID = '';
625
626 do {
627 $elementID = 'wcf' + this._idCounter++;
628 }
629 while ($.wcfIsset($elementID));
630
631 return $elementID;
632 },
633
634 /**
635 * Wrapper for $.inArray which returns boolean value instead of
636 * index value, similar to PHP's in_array().
637 *
638 * @param mixed needle
639 * @param array haystack
640 * @return boolean
641 */
642 inArray: function(needle, haystack) {
643 return ($.inArray(needle, haystack) != -1);
644 },
645
646 /**
647 * Adjusts effect for partially supported elements.
648 *
649 * @param jQuery object
650 * @param string effect
651 * @return string
652 */
653 getEffect: function(object, effect) {
654 // most effects are not properly supported on table rows, use highlight instead
655 if (object.is('tr')) {
656 return 'highlight';
657 }
658
659 return effect;
660 },
661
662 /**
663 * Returns inline CSS for given element.
664 *
665 * @param jQuery element
666 * @return object
667 */
668 getInlineCSS: function(element) {
669 var $inlineStyles = { };
670 var $style = element.attr('style');
671
672 // no style tag given or empty
673 if (!$style) {
674 return { };
675 }
676
677 $style = $style.split(';');
678 for (var $i = 0, $length = $style.length; $i < $length; $i++) {
679 var $fragment = $.trim($style[$i]);
680 if ($fragment == '') {
681 continue;
682 }
683
684 $fragment = $fragment.split(':');
685 $inlineStyles[$.trim($fragment[0])] = $.trim($fragment[1]);
686 }
687
688 return $inlineStyles;
689 },
690
691 /**
692 * Reverts inline CSS or negates a previously set property.
693 *
694 * @param jQuery element
695 * @param object inlineCSS
696 * @param array<string> targetProperties
697 */
698 revertInlineCSS: function(element, inlineCSS, targetProperties) {
699 for (var $i = 0, $length = targetProperties.length; $i < $length; $i++) {
700 var $property = targetProperties[$i];
701
702 // revert inline CSS
703 if (inlineCSS[$property]) {
704 element.css($property, inlineCSS[$property]);
705 }
706 else {
707 // negate inline CSS
708 element.css($property, '');
709 }
710 }
711 }
712 });
713
714 /**
715 * Browser related functions.
716 */
717 WCF.Browser = {
718 /**
719 * determines if browser is chrome
720 * @var boolean
721 */
722 _isChrome: null,
723
724 /**
725 * Returns true, if browser is Chrome, Chromium or using GoogleFrame for Internet Explorer.
726 *
727 * @return boolean
728 */
729 isChrome: function() {
730 if (this._isChrome === null) {
731 this._isChrome = false;
732 if (/chrom(e|ium)/.test(navigator.userAgent.toLowerCase())) {
733 this._isChrome = true;
734 }
735 }
736
737 return this._isChrome;
738 }
739 };
740
741 /**
742 * Dropdown API
743 */
744 WCF.Dropdown = {
745 /**
746 * list of callbacks
747 * @var object
748 */
749 _callbacks: { },
750
751 /**
752 * initialization state
753 * @var boolean
754 */
755 _didInit: false,
756
757 /**
758 * list of registered dropdowns
759 * @var object
760 */
761 _dropdowns: { },
762
763 /**
764 * container for dropdown menus
765 * @var object
766 */
767 _menuContainer: null,
768
769 /**
770 * list of registered dropdown menus
771 * @var object
772 */
773 _menus: { },
774
775 /**
776 * Initializes dropdowns.
777 */
778 init: function() {
779 if (this._menuContainer === null) {
780 this._menuContainer = $('<div id="dropdownMenuContainer" />').appendTo(document.body);
781 }
782
783 var self = this;
784 $('.dropdownToggle:not(.jsDropdownEnabled)').each(function(index, button) {
785 self.initDropdown($(button), false);
786 });
787
788 if (!this._didInit) {
789 this._didInit = true;
790
791 WCF.CloseOverlayHandler.addCallback('WCF.Dropdown', $.proxy(this._closeAll, this));
792 WCF.DOMNodeInsertedHandler.addCallback('WCF.Dropdown', $.proxy(this.init, this));
793 $(document).on('scroll', $.proxy(this._scroll, this));
794 }
795 },
796
797 /**
798 * Handles dropdown positions in overlays when scrolling in the overlay.
799 *
800 * @param object event
801 */
802 _dialogScroll: function(event) {
803 var $dialogContent = $(event.currentTarget);
804 $dialogContent.find('.dropdown.dropdownOpen').each(function(index, element) {
805 var $dropdown = $(element);
806 var $dropdownID = $dropdown.wcfIdentify();
807 var $dropdownOffset = $dropdown.offset();
808 var $dialogContentOffset = $dialogContent.offset();
809
810 var $verticalScrollTolerance = $(element).height() / 2;
811
812 // check if dropdown toggle is still (partially) visible
813 if ($dropdownOffset.top + $verticalScrollTolerance <= $dialogContentOffset.top) {
814 // top check
815 WCF.Dropdown.toggleDropdown($dropdownID);
816 }
817 else if ($dropdownOffset.top >= $dialogContentOffset.top + $dialogContent.height()) {
818 // bottom check
819 WCF.Dropdown.toggleDropdown($dropdownID);
820 }
821 else if ($dropdownOffset.left <= $dialogContentOffset.left) {
822 // left check
823 WCF.Dropdown.toggleDropdown($dropdownID);
824 }
825 else if ($dropdownOffset.left >= $dialogContentOffset.left + $dialogContent.width()) {
826 // right check
827 WCF.Dropdown.toggleDropdown($dropdownID);
828 }
829 else {
830 WCF.Dropdown.setAlignmentByID($dropdown.wcfIdentify());
831 }
832 });
833 },
834
835 /**
836 * Handles dropdown positions in overlays when scrolling in the document.
837 *
838 * @param object event
839 */
840 _scroll: function(event) {
841 for (var $containerID in this._dropdowns) {
842 var $dropdown = this._dropdowns[$containerID];
843 if ($dropdown.data('isOverlayDropdownButton') && $dropdown.hasClass('dropdownOpen')) {
844 this.setAlignmentByID($containerID);
845 }
846 }
847 },
848
849 /**
850 * Initializes a dropdown.
851 *
852 * @param jQuery button
853 * @param boolean isLazyInitialization
854 */
855 initDropdown: function(button, isLazyInitialization) {
856 if (button.hasClass('jsDropdownEnabled') || button.data('target')) {
857 return;
858 }
859
860 var $dropdown = button.parents('.dropdown');
861 if (!$dropdown.length) {
862 // broken dropdown, ignore
863 console.debug("[WCF.Dropdown] Invalid dropdown passed, button '" + button.wcfIdentify() + "' does not have a parent with .dropdown, aborting.");
864 return;
865 }
866
867 var $dropdownMenu = button.next('.dropdownMenu');
868 if (!$dropdownMenu.length) {
869 // broken dropdown, ignore
870 console.debug("[WCF.Dropdown] Invalid dropdown passed, dropdown '" + $dropdown.wcfIdentify() + "' does not have a dropdown menu, aborting.");
871 return;
872 }
873
874 $dropdownMenu.detach().appendTo(this._menuContainer);
875 var $containerID = $dropdown.wcfIdentify();
876 if (!this._dropdowns[$containerID]) {
877 button.addClass('jsDropdownEnabled').click($.proxy(this._toggle, this));
878
879 this._dropdowns[$containerID] = $dropdown;
880 this._menus[$containerID] = $dropdownMenu;
881 }
882
883 button.data('target', $containerID);
884
885 if (isLazyInitialization) {
886 button.trigger('click');
887 }
888 },
889
890 /**
891 * Removes the dropdown with the given container id.
892 *
893 * @param string containerID
894 */
895 removeDropdown: function(containerID) {
896 if (this._menus[containerID]) {
897 $(this._menus[containerID]).remove();
898 delete this._menus[containerID];
899 delete this._dropdowns[containerID];
900 }
901 },
902
903 /**
904 * Initializes a dropdown fragment which behaves like a usual dropdown
905 * but is not controlled by a trigger element.
906 *
907 * @param jQuery dropdown
908 * @param jQuery dropdownMenu
909 */
910 initDropdownFragment: function(dropdown, dropdownMenu) {
911 var $containerID = dropdown.wcfIdentify();
912 if (this._dropdowns[$containerID]) {
913 console.debug("[WCF.Dropdown] Cannot register dropdown identified by '" + $containerID + "' as a fragement.");
914 return;
915 }
916
917 this._dropdowns[$containerID] = dropdown;
918 this._menus[$containerID] = dropdownMenu.detach().appendTo(this._menuContainer);
919 },
920
921 /**
922 * Registers a callback notified upon dropdown state change.
923 *
924 * @param string identifier
925 * @var object callback
926 */
927 registerCallback: function(identifier, callback) {
928 if (!$.isFunction(callback)) {
929 console.debug("[WCF.Dropdown] Callback for '" + identifier + "' is invalid");
930 return false;
931 }
932
933 if (!this._callbacks[identifier]) {
934 this._callbacks[identifier] = [ ];
935 }
936
937 this._callbacks[identifier].push(callback);
938 },
939
940 /**
941 * Toggles a dropdown.
942 *
943 * @param object event
944 * @param string targetID
945 */
946 _toggle: function(event, targetID) {
947 var $targetID = (event === null) ? targetID : $(event.currentTarget).data('target');
948
949 // check if 'isOverlayDropdownButton' is set which indicates if
950 // the dropdown toggle is in an overlay
951 var $target = this._dropdowns[$targetID];
952 if ($target && $target.data('isOverlayDropdownButton') === undefined) {
953 var $dialogContent = $target.parents('.dialogContent');
954 $target.data('isOverlayDropdownButton', $dialogContent.length > 0);
955
956 if ($dialogContent.length) {
957 $dialogContent.on('scroll', this._dialogScroll);
958 }
959 }
960
961 // close all dropdowns
962 for (var $containerID in this._dropdowns) {
963 var $dropdown = this._dropdowns[$containerID];
964 var $dropdownMenu = this._menus[$containerID];
965
966 if ($dropdown.hasClass('dropdownOpen')) {
967 $dropdown.removeClass('dropdownOpen');
968 $dropdownMenu.removeClass('dropdownOpen');
969
970 this._notifyCallbacks($containerID, 'close');
971 }
972 else if ($containerID === $targetID) {
973 $dropdown.addClass('dropdownOpen');
974 $dropdownMenu.addClass('dropdownOpen');
975
976 this._notifyCallbacks($containerID, 'open');
977
978 this.setAlignment($dropdown, $dropdownMenu);
979 }
980 }
981
982 if (event !== null) {
983 event.stopPropagation();
984 return false;
985 }
986 },
987
988 /**
989 * Toggles a dropdown.
990 *
991 * @param string containerID
992 */
993 toggleDropdown: function(containerID) {
994 this._toggle(null, containerID);
995 },
996
997 /**
998 * Returns dropdown by container id.
999 *
1000 * @param string containerID
1001 * @return jQuery
1002 */
1003 getDropdown: function(containerID) {
1004 if (this._dropdowns[containerID]) {
1005 return this._dropdowns[containerID];
1006 }
1007
1008 return null;
1009 },
1010
1011 /**
1012 * Returns dropdown menu by container id.
1013 *
1014 * @param string containerID
1015 * @return jQuery
1016 */
1017 getDropdownMenu: function(containerID) {
1018 if (this._menus[containerID]) {
1019 return this._menus[containerID];
1020 }
1021
1022 return null;
1023 },
1024
1025 /**
1026 * Sets alignment for given container id.
1027 *
1028 * @param string containerID
1029 */
1030 setAlignmentByID: function(containerID) {
1031 var $dropdown = this.getDropdown(containerID);
1032 if ($dropdown === null) {
1033 console.debug("[WCF.Dropdown] Unable to find dropdown identified by '" + containerID + "'");
1034 }
1035
1036 var $dropdownMenu = this.getDropdownMenu(containerID);
1037 if ($dropdownMenu === null) {
1038 console.debug("[WCF.Dropdown] Unable to find dropdown menu identified by '" + containerID + "'");
1039 }
1040
1041 this.setAlignment($dropdown, $dropdownMenu);
1042 },
1043
1044 /**
1045 * Sets alignment for dropdown.
1046 *
1047 * @param jQuery dropdown
1048 * @param jQuery dropdownMenu
1049 */
1050 setAlignment: function(dropdown, dropdownMenu) {
1051 // force dropdown menu to be placed in the upper left corner, otherwise
1052 // it might cause the calculations to be a bit off if the page exceeds
1053 // the window boundaries during getDimensions() making it visible
1054 if (!dropdownMenu.data('isInitialized')) {
1055 dropdownMenu.data('isInitialized', true).css({ left: 0, top: 0 });
1056 }
1057
1058 // get dropdown position
1059 var $dropdownDimensions = dropdown.getDimensions('outer');
1060 var $dropdownOffsets = dropdown.getOffsets('offset');
1061 var $menuDimensions = dropdownMenu.getDimensions('outer');
1062 var $windowWidth = $(window).width();
1063
1064 // check if button belongs to an i18n textarea
1065 var $button = dropdown.find('.dropdownToggle');
1066 if ($button.hasClass('dropdownCaptionTextarea')) {
1067 // use button dimensions instead
1068 $dropdownDimensions = $button.getDimensions('outer');
1069 }
1070
1071 // get alignment
1072 var $align = 'left';
1073 if (($dropdownOffsets.left + $menuDimensions.width) > $windowWidth) {
1074 $align = 'right';
1075 }
1076
1077 // calculate offsets
1078 var $left = 'auto';
1079 var $right = 'auto';
1080 if ($align === 'left') {
1081 dropdownMenu.removeClass('dropdownArrowRight');
1082
1083 $left = $dropdownOffsets.left;
1084 }
1085 else {
1086 dropdownMenu.addClass('dropdownArrowRight');
1087
1088 $right = ($windowWidth - ($dropdownOffsets.left + $dropdownDimensions.width));
1089 }
1090
1091 // rtl works the same with the exception that we need to offset it with the right boundary
1092 if (WCF.Language.get('wcf.global.pageDirection') == 'rtl') {
1093 var $oldLeft = $left;
1094 var $oldRight = $right;
1095
1096 // use reverse positioning
1097 if ($left == 'auto') {
1098 dropdownMenu.removeClass('dropdownArrowRight');
1099 }
1100 else {
1101 $right = $windowWidth - ($dropdownOffsets.left + $dropdownDimensions.width);
1102 $left = 'auto';
1103
1104 if ($right + $menuDimensions.width > $windowWidth) {
1105 // exceeded window width, restore ltr values
1106 $left = $oldLeft;
1107 $right = $oldRight;
1108
1109 dropdownMenu.addClass('dropdownArrowRight');
1110 }
1111 }
1112 }
1113
1114 if ($left == 'auto') $right += 'px';
1115 else $left += 'px';
1116
1117 // calculate vertical offset
1118 var $wasHidden = true;
1119 if (dropdownMenu.hasClass('dropdownOpen')) {
1120 $wasHidden = false;
1121 dropdownMenu.removeClass('dropdownOpen');
1122 }
1123
1124 var $bottom = 'auto';
1125 var $top = $dropdownOffsets.top + $dropdownDimensions.height + 7;
1126 if ($top + $menuDimensions.height > $(window).height() + $(document).scrollTop()) {
1127 $bottom = $(window).height() - $dropdownOffsets.top + 10;
1128 $top = 'auto';
1129
1130 dropdownMenu.addClass('dropdownArrowBottom');
1131 }
1132 else {
1133 dropdownMenu.removeClass('dropdownArrowBottom');
1134 }
1135
1136 if (!$wasHidden) {
1137 dropdownMenu.addClass('dropdownOpen');
1138 }
1139
1140 dropdownMenu.css({
1141 bottom: $bottom,
1142 left: $left,
1143 right: $right,
1144 top: $top
1145 });
1146 },
1147
1148 /**
1149 * Closes all dropdowns.
1150 */
1151 _closeAll: function() {
1152 for (var $containerID in this._dropdowns) {
1153 var $dropdown = this._dropdowns[$containerID];
1154 if ($dropdown.hasClass('dropdownOpen')) {
1155 $dropdown.removeClass('dropdownOpen');
1156 this._menus[$containerID].removeClass('dropdownOpen');
1157
1158 this._notifyCallbacks($containerID, 'close');
1159 }
1160 }
1161 },
1162
1163 /**
1164 * Closes a dropdown without notifying callbacks.
1165 *
1166 * @param string containerID
1167 */
1168 close: function(containerID) {
1169 if (!this._dropdowns[containerID]) {
1170 return;
1171 }
1172
1173 this._dropdowns[containerID].removeClass('dropdownMenu');
1174 this._menus[containerID].removeClass('dropdownMenu');
1175 },
1176
1177 /**
1178 * Notifies callbacks.
1179 *
1180 * @param string containerID
1181 * @param string action
1182 */
1183 _notifyCallbacks: function(containerID, action) {
1184 if (!this._callbacks[containerID]) {
1185 return;
1186 }
1187
1188 for (var $i = 0, $length = this._callbacks[containerID].length; $i < $length; $i++) {
1189 this._callbacks[containerID][$i](containerID, action);
1190 }
1191 }
1192 };
1193
1194 /**
1195 * Clipboard API
1196 */
1197 WCF.Clipboard = {
1198 /**
1199 * action proxy object
1200 * @var WCF.Action.Proxy
1201 */
1202 _actionProxy: null,
1203
1204 /**
1205 * action objects
1206 * @var object
1207 */
1208 _actionObjects: {},
1209
1210 /**
1211 * list of clipboard containers
1212 * @var jQuery
1213 */
1214 _containers: null,
1215
1216 /**
1217 * container meta data
1218 * @var object
1219 */
1220 _containerData: { },
1221
1222 /**
1223 * user has marked items
1224 * @var boolean
1225 */
1226 _hasMarkedItems: false,
1227
1228 /**
1229 * list of ids of marked objects grouped by object type
1230 * @var object
1231 */
1232 _markedObjectIDs: { },
1233
1234 /**
1235 * current page
1236 * @var string
1237 */
1238 _page: '',
1239
1240 /**
1241 * current page's object id
1242 * @var integer
1243 */
1244 _pageObjectID: 0,
1245
1246 /**
1247 * proxy object
1248 * @var WCF.Action.Proxy
1249 */
1250 _proxy: null,
1251
1252 /**
1253 * list of elements already tracked for clipboard actions
1254 * @var object
1255 */
1256 _trackedElements: { },
1257
1258 /**
1259 * Initializes the clipboard API.
1260 *
1261 * @param string page
1262 * @param integer hasMarkedItems
1263 * @param object actionObjects
1264 * @param integer pageObjectID
1265 */
1266 init: function(page, hasMarkedItems, actionObjects, pageObjectID) {
1267 this._page = page;
1268 this._actionObjects = actionObjects || { };
1269 this._hasMarkedItems = (hasMarkedItems > 0);
1270 this._pageObjectID = parseInt(pageObjectID) || 0;
1271
1272 this._actionProxy = new WCF.Action.Proxy({
1273 success: $.proxy(this._actionSuccess, this),
1274 url: 'index.php/ClipboardProxy/?t=' + SECURITY_TOKEN + SID_ARG_2ND
1275 });
1276
1277 this._proxy = new WCF.Action.Proxy({
1278 success: $.proxy(this._success, this),
1279 url: 'index.php/Clipboard/?t=' + SECURITY_TOKEN + SID_ARG_2ND
1280 });
1281
1282 // init containers first
1283 this._containers = $('.jsClipboardContainer').each($.proxy(function(index, container) {
1284 this._initContainer(container);
1285 }, this));
1286
1287 // loads marked items
1288 if (this._hasMarkedItems && this._containers.length) {
1289 this._loadMarkedItems();
1290 }
1291
1292 var self = this;
1293 WCF.DOMNodeInsertedHandler.addCallback('WCF.Clipboard', function() {
1294 self._containers = $('.jsClipboardContainer').each($.proxy(function(index, container) {
1295 self._initContainer(container);
1296 }, self));
1297 });
1298 },
1299
1300 /**
1301 * Loads marked items on init.
1302 */
1303 _loadMarkedItems: function() {
1304 new WCF.Action.Proxy({
1305 autoSend: true,
1306 data: {
1307 containerData: this._containerData,
1308 pageClassName: this._page,
1309 pageObjectID: this._pageObjectID
1310 },
1311 success: $.proxy(this._loadMarkedItemsSuccess, this),
1312 url: 'index.php/ClipboardLoadMarkedItems/?t=' + SECURITY_TOKEN + SID_ARG_2ND
1313 });
1314 },
1315
1316 /**
1317 * Reloads the list of marked items.
1318 */
1319 reload: function() {
1320 if (this._containers === null) {
1321 return;
1322 }
1323
1324 this._loadMarkedItems();
1325 },
1326
1327 /**
1328 * Marks all returned items as marked
1329 *
1330 * @param object data
1331 * @param string textStatus
1332 * @param jQuery jqXHR
1333 */
1334 _loadMarkedItemsSuccess: function(data, textStatus, jqXHR) {
1335 this._resetMarkings();
1336
1337 for (var $typeName in data.markedItems) {
1338 if (!this._markedObjectIDs[$typeName]) {
1339 this._markedObjectIDs[$typeName] = [ ];
1340 }
1341
1342 var $objectData = data.markedItems[$typeName];
1343 for (var $i in $objectData) {
1344 this._markedObjectIDs[$typeName].push($objectData[$i]);
1345 }
1346
1347 // loop through all containers
1348 this._containers.each($.proxy(function(index, container) {
1349 var $container = $(container);
1350
1351 // typeName does not match, continue
1352 if ($container.data('type') != $typeName) {
1353 return true;
1354 }
1355
1356 // mark items as marked
1357 $container.find('input.jsClipboardItem').each($.proxy(function(innerIndex, item) {
1358 var $item = $(item);
1359 if (WCF.inArray($item.data('objectID'), this._markedObjectIDs[$typeName])) {
1360 $item.prop('checked', true);
1361
1362 // add marked class for element container
1363 $item.parents('.jsClipboardObject').addClass('jsMarked');
1364 }
1365 }, this));
1366
1367 // check if there is a markAll-checkbox
1368 $container.find('input.jsClipboardMarkAll').each(function(innerIndex, markAll) {
1369 var $allItemsMarked = true;
1370
1371 $container.find('input.jsClipboardItem').each(function(itemIndex, item) {
1372 var $item = $(item);
1373 if (!$item.prop('checked')) {
1374 $allItemsMarked = false;
1375 }
1376 });
1377
1378 if ($allItemsMarked) {
1379 $(markAll).prop('checked', true);
1380 }
1381 });
1382 }, this));
1383 }
1384
1385 // call success method to build item list editors
1386 this._success(data, textStatus, jqXHR);
1387 },
1388
1389 /**
1390 * Resets all checkboxes.
1391 */
1392 _resetMarkings: function() {
1393 this._containers.each($.proxy(function(index, container) {
1394 var $container = $(container);
1395
1396 this._markedObjectIDs[$container.data('type')] = [ ];
1397 $container.find('input.jsClipboardItem, input.jsClipboardMarkAll').prop('checked', false);
1398 $container.find('.jsClipboardObject').removeClass('jsMarked');
1399 }, this));
1400 },
1401
1402 /**
1403 * Initializes a clipboard container.
1404 *
1405 * @param object container
1406 */
1407 _initContainer: function(container) {
1408 var $container = $(container);
1409 var $containerID = $container.wcfIdentify();
1410
1411 if (!this._trackedElements[$containerID]) {
1412 $container.find('.jsClipboardMarkAll').data('hasContainer', $containerID).click($.proxy(this._markAll, this));
1413
1414 this._markedObjectIDs[$container.data('type')] = [ ];
1415 this._containerData[$container.data('type')] = {};
1416 $.each($container.data(), $.proxy(function(index, element) {
1417 if (index.match(/^type(.+)/)) {
1418 this._containerData[$container.data('type')][WCF.String.lcfirst(index.replace(/^type/, ''))] = element;
1419 }
1420 }, this));
1421
1422 this._trackedElements[$containerID] = [ ];
1423 }
1424
1425 // track individual checkboxes
1426 $container.find('input.jsClipboardItem').each($.proxy(function(index, input) {
1427 var $input = $(input);
1428 var $inputID = $input.wcfIdentify();
1429
1430 if (!WCF.inArray($inputID, this._trackedElements[$containerID])) {
1431 this._trackedElements[$containerID].push($inputID);
1432
1433 $input.data('hasContainer', $containerID).click($.proxy(this._click, this));
1434 }
1435 }, this));
1436 },
1437
1438 /**
1439 * Processes change checkbox state.
1440 *
1441 * @param object event
1442 */
1443 _click: function(event) {
1444 var $item = $(event.target);
1445 var $objectID = $item.data('objectID');
1446 var $isMarked = ($item.prop('checked')) ? true : false;
1447 var $objectIDs = [ $objectID ];
1448
1449 if ($item.data('hasContainer')) {
1450 var $container = $('#' + $item.data('hasContainer'));
1451 var $type = $container.data('type');
1452 }
1453 else {
1454 var $type = $item.data('type');
1455 }
1456
1457 if ($isMarked) {
1458 this._markedObjectIDs[$type].push($objectID);
1459 $item.parents('.jsClipboardObject').addClass('jsMarked');
1460 }
1461 else {
1462 this._markedObjectIDs[$type] = $.removeArrayValue(this._markedObjectIDs[$type], $objectID);
1463 $item.parents('.jsClipboardObject').removeClass('jsMarked');
1464 }
1465
1466 // item is part of a container
1467 if ($item.data('hasContainer')) {
1468 // check if all items are marked
1469 var $markedAll = true;
1470 $container.find('input.jsClipboardItem').each(function(index, containerItem) {
1471 var $containerItem = $(containerItem);
1472 if (!$containerItem.prop('checked')) {
1473 $markedAll = false;
1474 }
1475 });
1476
1477 // simulate a ticked 'markAll' checkbox
1478 $container.find('.jsClipboardMarkAll').each(function(index, markAll) {
1479 if ($markedAll) {
1480 $(markAll).prop('checked', true);
1481 }
1482 else {
1483 $(markAll).prop('checked', false);
1484 }
1485 });
1486 }
1487
1488 this._saveState($type, $objectIDs, $isMarked);
1489 },
1490
1491 /**
1492 * Marks all associated clipboard items as checked.
1493 *
1494 * @param object event
1495 */
1496 _markAll: function(event) {
1497 var $item = $(event.target);
1498 var $objectIDs = [ ];
1499 var $isMarked = true;
1500
1501 // if markAll object is a checkbox, allow toggling
1502 if ($item.is('input')) {
1503 $isMarked = $item.prop('checked');
1504 }
1505
1506 if ($item.data('hasContainer')) {
1507 var $container = $('#' + $item.data('hasContainer'));
1508 var $type = $container.data('type');
1509 }
1510 else {
1511 var $type = $item.data('type');
1512 }
1513
1514 // handle item containers
1515 if ($item.data('hasContainer')) {
1516 // toggle state for all associated items
1517 $container.find('input.jsClipboardItem').each($.proxy(function(index, containerItem) {
1518 var $containerItem = $(containerItem);
1519 var $objectID = $containerItem.data('objectID');
1520 if ($isMarked) {
1521 if (!$containerItem.prop('checked')) {
1522 $containerItem.prop('checked', true);
1523 this._markedObjectIDs[$type].push($objectID);
1524 $objectIDs.push($objectID);
1525 }
1526 }
1527 else {
1528 if ($containerItem.prop('checked')) {
1529 $containerItem.prop('checked', false);
1530 this._markedObjectIDs[$type] = $.removeArrayValue(this._markedObjectIDs[$type], $objectID);
1531 $objectIDs.push($objectID);
1532 }
1533 }
1534 }, this));
1535
1536 if ($isMarked) {
1537 $container.find('.jsClipboardObject').addClass('jsMarked');
1538 }
1539 else {
1540 $container.find('.jsClipboardObject').removeClass('jsMarked');
1541 }
1542 }
1543
1544 // save new status
1545 this._saveState($type, $objectIDs, $isMarked);
1546 },
1547
1548 /**
1549 * Saves clipboard item state.
1550 *
1551 * @param string type
1552 * @param array objectIDs
1553 * @param boolean isMarked
1554 */
1555 _saveState: function(type, objectIDs, isMarked) {
1556 this._proxy.setOption('data', {
1557 action: (isMarked) ? 'mark' : 'unmark',
1558 containerData: this._containerData,
1559 objectIDs: objectIDs,
1560 pageClassName: this._page,
1561 pageObjectID: this._pageObjectID,
1562 type: type
1563 });
1564 this._proxy.sendRequest();
1565 },
1566
1567 /**
1568 * Updates editor options.
1569 *
1570 * @param object data
1571 * @param string textStatus
1572 * @param jQuery jqXHR
1573 */
1574 _success: function(data, textStatus, jqXHR) {
1575 // clear all editors first
1576 var $containers = {};
1577 $('.jsClipboardEditor').each(function(index, container) {
1578 var $container = $(container);
1579 var $types = eval($container.data('types'));
1580 for (var $i = 0, $length = $types.length; $i < $length; $i++) {
1581 var $typeName = $types[$i];
1582 $containers[$typeName] = $container;
1583 }
1584
1585 var $containerID = $container.wcfIdentify();
1586 WCF.CloseOverlayHandler.removeCallback($containerID);
1587
1588 $container.empty();
1589 });
1590
1591 // do not build new editors
1592 if (!data.items) return;
1593
1594 // rebuild editors
1595 for (var $typeName in data.items) {
1596 if (!$containers[$typeName]) {
1597 continue;
1598 }
1599
1600 // create container
1601 var $container = $containers[$typeName];
1602 var $list = $container.children('ul');
1603 if ($list.length == 0) {
1604 $list = $('<ul />').appendTo($container);
1605 }
1606
1607 var $editor = data.items[$typeName];
1608 var $label = $('<li class="dropdown"><span class="dropdownToggle button">' + $editor.label + '</span></li>').appendTo($list);
1609 var $itemList = $('<ol class="dropdownMenu"></ol>').appendTo($label);
1610
1611 // create editor items
1612 for (var $itemIndex in $editor.items) {
1613 var $item = $editor.items[$itemIndex];
1614
1615 var $listItem = $('<li><span>' + $item.label + '</span></li>').appendTo($itemList);
1616 $listItem.data('container', $container);
1617 $listItem.data('objectType', $typeName);
1618 $listItem.data('actionName', $item.actionName).data('parameters', $item.parameters);
1619 $listItem.data('internalData', $item.internalData).data('url', $item.url).data('type', $typeName);
1620
1621 // bind event
1622 $listItem.click($.proxy(this._executeAction, this));
1623 }
1624
1625 // add 'unmark all'
1626 $('<li class="dropdownDivider" />').appendTo($itemList);
1627 var $foo = $typeName;
1628 $('<li><span>' + WCF.Language.get('wcf.clipboard.item.unmarkAll') + '</span></li>').data('typeName', $typeName).appendTo($itemList).click($.proxy(function(event) {
1629 var $typeName = $(event.currentTarget).data('typeName');
1630
1631 this._proxy.setOption('data', {
1632 action: 'unmarkAll',
1633 type: $typeName
1634 });
1635 this._proxy.setOption('success', $.proxy(function(data, textStatus, jqXHR) {
1636 this._containers.each($.proxy(function(index, container) {
1637 var $container = $(container);
1638 if ($container.data('type') == $typeName) {
1639 $container.find('.jsClipboardMarkAll, .jsClipboardItem').prop('checked', false);
1640 $container.find('.jsClipboardObject').removeClass('jsMarked');
1641
1642 return false;
1643 }
1644 }, this));
1645
1646 // call and restore success method
1647 this._success(data, textStatus, jqXHR);
1648 this._proxy.setOption('success', $.proxy(this._success, this));
1649 this._loadMarkedItems();
1650 }, this));
1651 this._proxy.sendRequest();
1652 }, this));
1653
1654 WCF.Dropdown.initDropdown($label.children('.dropdownToggle'), false);
1655 }
1656 },
1657
1658 /**
1659 * Closes the clipboard editor item list.
1660 */
1661 _closeLists: function() {
1662 $('.jsClipboardEditor ul').removeClass('dropdownOpen');
1663 },
1664
1665 /**
1666 * Executes a clipboard editor item action.
1667 *
1668 * @param object event
1669 */
1670 _executeAction: function(event) {
1671 var $listItem = $(event.currentTarget);
1672 var $url = $listItem.data('url');
1673 if ($url) {
1674 window.location.href = $url;
1675 }
1676
1677 if ($listItem.data('parameters').className && $listItem.data('parameters').actionName) {
1678 if ($listItem.data('parameters').actionName === 'unmarkAll' || $listItem.data('parameters').objectIDs) {
1679 var $confirmMessage = $listItem.data('internalData')['confirmMessage'];
1680 if ($confirmMessage) {
1681 var $template = $listItem.data('internalData')['template'];
1682 if ($template) $template = $($template);
1683
1684 WCF.System.Confirmation.show($confirmMessage, $.proxy(function(action) {
1685 if (action === 'confirm') {
1686 var $data = { };
1687
1688 if ($template && $template.length) {
1689 $('#wcfSystemConfirmationContent').find('input, select, textarea').each(function(index, item) {
1690 var $item = $(item);
1691 $data[$item.prop('name')] = $item.val();
1692 });
1693 }
1694
1695 this._executeAJAXActions($listItem, $data);
1696 }
1697 }, this), '', $template);
1698 }
1699 else {
1700 this._executeAJAXActions($listItem, { });
1701 }
1702 }
1703 }
1704
1705 // fire event
1706 $listItem.data('container').trigger('clipboardAction', [ $listItem.data('type'), $listItem.data('actionName'), $listItem.data('parameters') ]);
1707 },
1708
1709 /**
1710 * Executes the AJAX actions for the given editor list item.
1711 *
1712 * @param jQuery listItem
1713 * @param object data
1714 */
1715 _executeAJAXActions: function(listItem, data) {
1716 data = data || { };
1717 var $objectIDs = [];
1718 if (listItem.data('parameters').actionName !== 'unmarkAll') {
1719 $.each(listItem.data('parameters').objectIDs, function(index, objectID) {
1720 $objectIDs.push(parseInt(objectID));
1721 });
1722 }
1723
1724 var $parameters = {
1725 data: data,
1726 containerData: this._containerData[listItem.data('type')]
1727 };
1728 var $__parameters = listItem.data('internalData')['parameters'];
1729 if ($__parameters !== undefined) {
1730 for (var $key in $__parameters) {
1731 $parameters[$key] = $__parameters[$key];
1732 }
1733 }
1734
1735 new WCF.Action.Proxy({
1736 autoSend: true,
1737 data: {
1738 actionName: listItem.data('parameters').actionName,
1739 className: listItem.data('parameters').className,
1740 objectIDs: $objectIDs,
1741 parameters: $parameters
1742 },
1743 success: $.proxy(function(data) {
1744 if (listItem.data('parameters').actionName !== 'unmarkAll') {
1745 listItem.data('container').trigger('clipboardActionResponse', [ data, listItem.data('type'), listItem.data('actionName'), listItem.data('parameters') ]);
1746 }
1747
1748 this._loadMarkedItems();
1749 }, this)
1750 });
1751
1752 if (this._actionObjects[listItem.data('objectType')] && this._actionObjects[listItem.data('objectType')][listItem.data('parameters').actionName]) {
1753 this._actionObjects[listItem.data('objectType')][listItem.data('parameters').actionName].triggerEffect($objectIDs);
1754 }
1755 },
1756
1757 /**
1758 * Sends a clipboard proxy request.
1759 *
1760 * @param object item
1761 */
1762 sendRequest: function(item) {
1763 var $item = $(item);
1764
1765 this._actionProxy.setOption('data', {
1766 parameters: $item.data('parameters'),
1767 typeName: $item.data('type')
1768 });
1769 this._actionProxy.sendRequest();
1770 }
1771 };
1772
1773 /**
1774 * Provides a simple call for periodical executed functions. Based upon
1775 * ideas by Prototype's PeriodicalExecuter.
1776 *
1777 * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/periodical_executer.js
1778 * @param function callback
1779 * @param integer delay
1780 */
1781 WCF.PeriodicalExecuter = Class.extend({
1782 /**
1783 * callback for each execution cycle
1784 * @var object
1785 */
1786 _callback: null,
1787
1788 /**
1789 * interval
1790 * @var integer
1791 */
1792 _delay: 0,
1793
1794 /**
1795 * interval id
1796 * @var integer
1797 */
1798 _intervalID: null,
1799
1800 /**
1801 * execution state
1802 * @var boolean
1803 */
1804 _isExecuting: false,
1805
1806 /**
1807 * Initializes a periodical executer.
1808 *
1809 * @param function callback
1810 * @param integer delay
1811 */
1812 init: function(callback, delay) {
1813 if (!$.isFunction(callback)) {
1814 console.debug('[WCF.PeriodicalExecuter] Given callback is invalid, aborting.');
1815 return;
1816 }
1817
1818 this._callback = callback;
1819 this._interval = delay;
1820 this.resume();
1821 },
1822
1823 /**
1824 * Executes callback.
1825 */
1826 _execute: function() {
1827 if (!this._isExecuting) {
1828 try {
1829 this._isExecuting = true;
1830 this._callback(this);
1831 this._isExecuting = false;
1832 }
1833 catch (e) {
1834 this._isExecuting = false;
1835 throw e;
1836 }
1837 }
1838 },
1839
1840 /**
1841 * Terminates loop.
1842 */
1843 stop: function() {
1844 if (!this._intervalID) {
1845 return;
1846 }
1847
1848 clearInterval(this._intervalID);
1849 },
1850
1851 /**
1852 * Resumes the interval-based callback execution.
1853 */
1854 resume: function() {
1855 if (this._intervalID) {
1856 this.stop();
1857 }
1858
1859 this._intervalID = setInterval($.proxy(this._execute, this), this._interval);
1860 }
1861 });
1862
1863 /**
1864 * Handler for loading overlays
1865 */
1866 WCF.LoadingOverlayHandler = {
1867 /**
1868 * count of active loading-requests
1869 * @var integer
1870 */
1871 _activeRequests: 0,
1872
1873 /**
1874 * loading overlay
1875 * @var jQuery
1876 */
1877 _loadingOverlay: null,
1878
1879 /**
1880 * WCF.PeriodicalExecuter instance
1881 * @var WCF.PeriodicalExecuter
1882 */
1883 _pending: null,
1884
1885 /**
1886 * Adds one loading-request and shows the loading overlay if nessercery
1887 */
1888 show: function() {
1889 if (this._loadingOverlay === null) { // create loading overlay on first run
1890 this._loadingOverlay = $('<div class="spinner"><span class="icon icon48 icon-spinner" /> <span>' + WCF.Language.get('wcf.global.loading') + '</span></div>').appendTo($('body'));
1891
1892 // fix position
1893 var $width = this._loadingOverlay.outerWidth();
1894 if ($width < 70) $width = 70;
1895 this._loadingOverlay.css({
1896 marginLeft: Math.ceil(-1 * $width / 2),
1897 width: $width
1898 }).hide();
1899 }
1900
1901 this._activeRequests++;
1902 if (this._activeRequests == 1) {
1903 if (this._pending === null) {
1904 var self = this;
1905 this._pending = new WCF.PeriodicalExecuter(function(pe) {
1906 if (self._activeRequests) {
1907 self._loadingOverlay.stop(true, true).fadeIn(100);
1908 }
1909
1910 pe.stop();
1911 self._pending = null;
1912 }, 250);
1913 }
1914
1915 }
1916 },
1917
1918 /**
1919 * Removes one loading-request and hides loading overlay if there're no more pending requests
1920 */
1921 hide: function() {
1922 this._activeRequests--;
1923 if (this._activeRequests == 0) {
1924 if (this._pending !== null) {
1925 this._pending.stop();
1926 this._pending = null;
1927 }
1928
1929 this._loadingOverlay.stop(true, true).fadeOut(100);
1930 }
1931 },
1932
1933 /**
1934 * Updates a icon to/from spinner
1935 *
1936 * @param jQuery target
1937 * @pram boolean loading
1938 */
1939 updateIcon: function(target, loading) {
1940 var $method = (loading === undefined || loading ? 'addClass' : 'removeClass');
1941
1942 target.find('.icon')[$method]('icon-spinner');
1943 if (target.hasClass('icon')) {
1944 target[$method]('icon-spinner');
1945 }
1946 }
1947 };
1948
1949 /**
1950 * Namespace for AJAXProxies
1951 */
1952 WCF.Action = {};
1953
1954 /**
1955 * Basic implementation for AJAX-based proxyies
1956 *
1957 * @param object options
1958 */
1959 WCF.Action.Proxy = Class.extend({
1960 /**
1961 * shows loading overlay for a single request
1962 * @var boolean
1963 */
1964 _showLoadingOverlayOnce: false,
1965
1966 /**
1967 * suppresses errors
1968 * @var boolean
1969 */
1970 _suppressErrors: false,
1971
1972 /**
1973 * last request
1974 * @var jqXHR
1975 */
1976 _lastRequest: null,
1977
1978 /**
1979 * Initializes AJAXProxy.
1980 *
1981 * @param object options
1982 */
1983 init: function(options) {
1984 // initialize default values
1985 this.options = $.extend(true, {
1986 autoSend: false,
1987 data: { },
1988 dataType: 'json',
1989 after: null,
1990 init: null,
1991 jsonp: 'callback',
1992 async: true,
1993 failure: null,
1994 showLoadingOverlay: true,
1995 success: null,
1996 suppressErrors: false,
1997 type: 'POST',
1998 url: 'index.php/AJAXProxy/?t=' + SECURITY_TOKEN + SID_ARG_2ND,
1999 aborted: null,
2000 autoAbortPrevious: false
2001 }, options);
2002
2003 this.confirmationDialog = null;
2004 this.loading = null;
2005 this._showLoadingOverlayOnce = false;
2006 this._suppressErrors = (this.options.suppressErrors === true);
2007
2008 // send request immediately after initialization
2009 if (this.options.autoSend) {
2010 this.sendRequest();
2011 }
2012
2013 var self = this;
2014 $(window).on('beforeunload', function() { self._suppressErrors = true; });
2015 },
2016
2017 /**
2018 * Sends an AJAX request.
2019 *
2020 * @param abortPrevious boolean
2021 * @return jqXHR
2022 */
2023 sendRequest: function(abortPrevious) {
2024 this._init();
2025
2026 if (abortPrevious || this.options.autoAbortPrevious) {
2027 this.abortPrevious();
2028 }
2029
2030 this._lastRequest = $.ajax({
2031 data: this.options.data,
2032 dataType: this.options.dataType,
2033 jsonp: this.options.jsonp,
2034 async: this.options.async,
2035 type: this.options.type,
2036 url: this.options.url,
2037 success: $.proxy(this._success, this),
2038 error: $.proxy(this._failure, this)
2039 });
2040 return this._lastRequest;
2041 },
2042
2043 /**
2044 * Aborts the previous request
2045 */
2046 abortPrevious: function() {
2047 if (this._lastRequest !== null) {
2048 this._lastRequest.abort();
2049 this._lastRequest = null;
2050 }
2051 },
2052
2053 /**
2054 * Shows loading overlay for a single request.
2055 */
2056 showLoadingOverlayOnce: function() {
2057 this._showLoadingOverlayOnce = true;
2058 },
2059
2060 /**
2061 * Suppressed errors for this action proxy.
2062 */
2063 suppressErrors: function() {
2064 this._suppressErrors = true;
2065 },
2066
2067 /**
2068 * Fires before request is send, displays global loading status.
2069 */
2070 _init: function() {
2071 if ($.isFunction(this.options.init)) {
2072 this.options.init(this);
2073 }
2074
2075 if (this.options.showLoadingOverlay || this._showLoadingOverlayOnce) {
2076 WCF.LoadingOverlayHandler.show();
2077 }
2078 },
2079
2080 /**
2081 * Handles AJAX errors.
2082 *
2083 * @param object jqXHR
2084 * @param string textStatus
2085 * @param string errorThrown
2086 */
2087 _failure: function(jqXHR, textStatus, errorThrown) {
2088 if (textStatus == 'abort') {
2089 // call child method if applicable
2090 if ($.isFunction(this.options.aborted)) {
2091 this.options.aborted(jqXHR);
2092 }
2093
2094 return;
2095 }
2096
2097 try {
2098 var $data = $.parseJSON(jqXHR.responseText);
2099
2100 // call child method if applicable
2101 var $showError = true;
2102 if ($.isFunction(this.options.failure)) {
2103 $showError = this.options.failure($data, jqXHR, textStatus, errorThrown);
2104 }
2105
2106 if (!this._suppressErrors && $showError !== false) {
2107 var $details = '';
2108 if ($data.stacktrace) $details = '<br /><p>Stacktrace:</p><p>' + $data.stacktrace + '</p>';
2109 else if ($data.exceptionID) $details = '<br /><p>Exception ID: <code>' + $data.exceptionID + '</code></p>';
2110
2111 $('<div class="ajaxDebugMessage"><p>' + $data.message + '</p>' + $details + '</div>').wcfDialog({ title: WCF.Language.get('wcf.global.error.title') });
2112 }
2113 }
2114 // failed to parse JSON
2115 catch (e) {
2116 // call child method if applicable
2117 var $showError = true;
2118 if ($.isFunction(this.options.failure)) {
2119 $showError = this.options.failure(null, jqXHR, textStatus, errorThrown);
2120 }
2121
2122 if (!this._suppressErrors && $showError !== false) {
2123 var $message = (textStatus === 'timeout') ? WCF.Language.get('wcf.global.error.timeout') : jqXHR.responseText;
2124
2125 // validate if $message is neither empty nor 'undefined'
2126 if ($message && $message != 'undefined') {
2127 $('<div class="ajaxDebugMessage"><p>' + $message + '</p></div>').wcfDialog({ title: WCF.Language.get('wcf.global.error.title') });
2128 }
2129 }
2130 }
2131
2132 this._after();
2133 },
2134
2135 /**
2136 * Handles successful AJAX requests.
2137 *
2138 * @param object data
2139 * @param string textStatus
2140 * @param object jqXHR
2141 */
2142 _success: function(data, textStatus, jqXHR) {
2143 // call child method if applicable
2144 if ($.isFunction(this.options.success)) {
2145 // trim HTML before processing, see http://jquery.com/upgrade-guide/1.9/#jquery-htmlstring-versus-jquery-selectorstring
2146 if (data && data.returnValues && data.returnValues.template !== undefined) {
2147 data.returnValues.template = $.trim(data.returnValues.template);
2148 }
2149
2150 this.options.success(data, textStatus, jqXHR);
2151 }
2152
2153 this._after();
2154 },
2155
2156 /**
2157 * Fires after an AJAX request, hides global loading status.
2158 */
2159 _after: function() {
2160 this._lastRequest = null;
2161 if ($.isFunction(this.options.after)) {
2162 this.options.after();
2163 }
2164
2165 if (this.options.showLoadingOverlay || this._showLoadingOverlayOnce) {
2166 WCF.LoadingOverlayHandler.hide();
2167
2168 if (this._showLoadingOverlayOnce) {
2169 this._showLoadingOverlayOnce = false;
2170 }
2171 }
2172
2173 WCF.DOMNodeInsertedHandler.execute();
2174
2175 // fix anchor tags generated through WCF::getAnchor()
2176 $('a[href*=#]').each(function(index, link) {
2177 var $link = $(link);
2178 if ($link.prop('href').indexOf('AJAXProxy') != -1) {
2179 var $anchor = $link.prop('href').substr($link.prop('href').indexOf('#'));
2180 var $pageLink = document.location.toString().replace(/#.*/, '');
2181 $link.prop('href', $pageLink + $anchor);
2182 }
2183 });
2184 },
2185
2186 /**
2187 * Sets options, MUST be used to set parameters before sending request
2188 * if calling from child classes.
2189 *
2190 * @param string optionName
2191 * @param mixed optionData
2192 */
2193 setOption: function(optionName, optionData) {
2194 this.options[optionName] = optionData;
2195 }
2196 });
2197
2198 /**
2199 * Basic implementation for simple proxy access using bound elements.
2200 *
2201 * @param object options
2202 * @param object callbacks
2203 */
2204 WCF.Action.SimpleProxy = Class.extend({
2205 /**
2206 * Initializes SimpleProxy.
2207 *
2208 * @param object options
2209 * @param object callbacks
2210 */
2211 init: function(options, callbacks) {
2212 /**
2213 * action-specific options
2214 */
2215 this.options = $.extend(true, {
2216 action: '',
2217 className: '',
2218 elements: null,
2219 eventName: 'click'
2220 }, options);
2221
2222 /**
2223 * proxy-specific options
2224 */
2225 this.callbacks = $.extend(true, {
2226 after: null,
2227 failure: null,
2228 init: null,
2229 success: null
2230 }, callbacks);
2231
2232 if (!this.options.elements) return;
2233
2234 // initialize proxy
2235 this.proxy = new WCF.Action.Proxy(this.callbacks);
2236
2237 // bind event listener
2238 this.options.elements.each($.proxy(function(index, element) {
2239 $(element).bind(this.options.eventName, $.proxy(this._handleEvent, this));
2240 }, this));
2241 },
2242
2243 /**
2244 * Handles event actions.
2245 *
2246 * @param object event
2247 */
2248 _handleEvent: function(event) {
2249 this.proxy.setOption('data', {
2250 actionName: this.options.action,
2251 className: this.options.className,
2252 objectIDs: [ $(event.target).data('objectID') ]
2253 });
2254
2255 this.proxy.sendRequest();
2256 }
2257 });
2258
2259 /**
2260 * Basic implementation for AJAXProxy-based deletion.
2261 *
2262 * @param string className
2263 * @param string containerSelector
2264 * @param string buttonSelector
2265 */
2266 WCF.Action.Delete = Class.extend({
2267 /**
2268 * delete button selector
2269 * @var string
2270 */
2271 _buttonSelector: '',
2272
2273 /**
2274 * action class name
2275 * @var string
2276 */
2277 _className: '',
2278
2279 /**
2280 * container selector
2281 * @var string
2282 */
2283 _containerSelector: '',
2284
2285 /**
2286 * list of known container ids
2287 * @var array<string>
2288 */
2289 _containers: [ ],
2290
2291 /**
2292 * Initializes 'delete'-Proxy.
2293 *
2294 * @param string className
2295 * @param string containerSelector
2296 * @param string buttonSelector
2297 */
2298 init: function(className, containerSelector, buttonSelector) {
2299 this._containerSelector = containerSelector;
2300 this._className = className;
2301 this._buttonSelector = (buttonSelector) ? buttonSelector : '.jsDeleteButton';
2302
2303 this.proxy = new WCF.Action.Proxy({
2304 success: $.proxy(this._success, this)
2305 });
2306
2307 this._initElements();
2308
2309 WCF.DOMNodeInsertedHandler.addCallback('WCF.Action.Delete' + this._className.hashCode(), $.proxy(this._initElements, this));
2310 },
2311
2312 /**
2313 * Initializes available element containers.
2314 */
2315 _initElements: function() {
2316 var self = this;
2317 $(this._containerSelector).each(function(index, container) {
2318 var $container = $(container);
2319 var $containerID = $container.wcfIdentify();
2320
2321 if (!WCF.inArray($containerID, self._containers)) {
2322 self._containers.push($containerID);
2323 $container.find(self._buttonSelector).click($.proxy(self._click, self));
2324 }
2325 });
2326 },
2327
2328 /**
2329 * Sends AJAX request.
2330 *
2331 * @param object event
2332 */
2333 _click: function(event) {
2334 var $target = $(event.currentTarget);
2335 event.preventDefault();
2336
2337 if ($target.data('confirmMessage')) {
2338 WCF.System.Confirmation.show($target.data('confirmMessage'), $.proxy(this._execute, this), { target: $target });
2339 }
2340 else {
2341 WCF.LoadingOverlayHandler.updateIcon($target);
2342 this._sendRequest($target);
2343 }
2344 },
2345
2346 /**
2347 * Is called if the delete effect has been triggered on the given element.
2348 *
2349 * @param jQuery element
2350 */
2351 _didTriggerEffect: function(element) {
2352 // does nothing
2353 },
2354
2355 /**
2356 * Executes deletion.
2357 *
2358 * @param string action
2359 * @param object parameters
2360 */
2361 _execute: function(action, parameters) {
2362 if (action === 'cancel') {
2363 return;
2364 }
2365
2366 WCF.LoadingOverlayHandler.updateIcon(parameters.target);
2367 this._sendRequest(parameters.target);
2368 },
2369
2370 /**
2371 * Sends the request
2372 *
2373 * @param jQuery object
2374 */
2375 _sendRequest: function(object) {
2376 this.proxy.setOption('data', {
2377 actionName: 'delete',
2378 className: this._className,
2379 interfaceName: 'wcf\\data\\IDeleteAction',
2380 objectIDs: [ $(object).data('objectID') ]
2381 });
2382
2383 this.proxy.sendRequest();
2384 },
2385
2386 /**
2387 * Deletes items from containers.
2388 *
2389 * @param object data
2390 * @param string textStatus
2391 * @param object jqXHR
2392 */
2393 _success: function(data, textStatus, jqXHR) {
2394 this.triggerEffect(data.objectIDs);
2395 },
2396
2397 /**
2398 * Triggers the delete effect for the objects with the given ids.
2399 *
2400 * @param array objectIDs
2401 */
2402 triggerEffect: function(objectIDs) {
2403 for (var $index in this._containers) {
2404 var $container = $('#' + this._containers[$index]);
2405 if (WCF.inArray($container.find(this._buttonSelector).data('objectID'), objectIDs)) {
2406 var self = this;
2407 $container.wcfBlindOut('up',function() {
2408 $(this).remove();
2409 self._containers.splice(self._containers.indexOf($(this).wcfIdentify()), 1);
2410 self._didTriggerEffect($(this));
2411 });
2412 }
2413 }
2414 }
2415 });
2416
2417 /**
2418 * Basic implementation for deletion of nested elements.
2419 *
2420 * The implementation requires the nested elements to be grouped as numbered lists
2421 * (ol lists). The child elements of the deleted elements are moved to the parent
2422 * element of the deleted element.
2423 *
2424 * @see WCF.Action.Delete
2425 */
2426 WCF.Action.NestedDelete = WCF.Action.Delete.extend({
2427 /**
2428 * @see WCF.Action.Delete.triggerEffect()
2429 */
2430 triggerEffect: function(objectIDs) {
2431 for (var $index in this._containers) {
2432 var $container = $('#' + this._containers[$index]);
2433 if (WCF.inArray($container.find(this._buttonSelector).data('objectID'), objectIDs)) {
2434 // move children up
2435 if ($container.has('ol').has('li').length) {
2436 if ($container.is(':only-child')) {
2437 $container.parent().replaceWith($container.find('> ol'));
2438 }
2439 else {
2440 $container.replaceWith($container.find('> ol > li'));
2441 }
2442
2443 this._containers.splice(this._containers.indexOf($container.wcfIdentify()), 1);
2444 this._didTriggerEffect($container);
2445 }
2446 else {
2447 var self = this;
2448 $container.wcfBlindOut('up', function() {
2449 $(this).remove();
2450 self._containers.splice(self._containers.indexOf($(this).wcfIdentify()), 1);
2451 self._didTriggerEffect($(this));
2452 });
2453 }
2454 }
2455 }
2456 }
2457 });
2458
2459 /**
2460 * Basic implementation for AJAXProxy-based toggle actions.
2461 *
2462 * @param string className
2463 * @param jQuery containerList
2464 * @param string buttonSelector
2465 */
2466 WCF.Action.Toggle = Class.extend({
2467 /**
2468 * toogle button selector
2469 * @var string
2470 */
2471 _buttonSelector: '.jsToggleButton',
2472
2473 /**
2474 * action class name
2475 * @var string
2476 */
2477 _className: '',
2478
2479 /**
2480 * container selector
2481 * @var string
2482 */
2483 _containerSelector: '',
2484
2485 /**
2486 * list of known container ids
2487 * @var array<string>
2488 */
2489 _containers: [ ],
2490
2491 /**
2492 * Initializes 'toggle'-Proxy
2493 *
2494 * @param string className
2495 * @param string containerSelector
2496 * @param string buttonSelector
2497 */
2498 init: function(className, containerSelector, buttonSelector) {
2499 this._containerSelector = containerSelector;
2500 this._className = className;
2501 this._buttonSelector = (buttonSelector) ? buttonSelector : '.jsToggleButton';
2502 this._containers = [ ];
2503
2504 // initialize proxy
2505 var options = {
2506 success: $.proxy(this._success, this)
2507 };
2508 this.proxy = new WCF.Action.Proxy(options);
2509
2510 // bind event listener
2511 this._initElements();
2512 WCF.DOMNodeInsertedHandler.addCallback('WCF.Action.Toggle' + this._className.hashCode(), $.proxy(this._initElements, this));
2513 },
2514
2515 /**
2516 * Initializes available element containers.
2517 */
2518 _initElements: function() {
2519 $(this._containerSelector).each($.proxy(function(index, container) {
2520 var $container = $(container);
2521 var $containerID = $container.wcfIdentify();
2522
2523 if (!WCF.inArray($containerID, this._containers)) {
2524 this._containers.push($containerID);
2525 $container.find(this._buttonSelector).click($.proxy(this._click, this));
2526 }
2527 }, this));
2528 },
2529
2530 /**
2531 * Sends AJAX request.
2532 *
2533 * @param object event
2534 */
2535 _click: function(event) {
2536 var $target = $(event.currentTarget);
2537 event.preventDefault();
2538
2539 if ($target.data('confirmMessage')) {
2540 WCF.System.Confirmation.show($target.data('confirmMessage'), $.proxy(this._execute, this), { target: $target });
2541 }
2542 else {
2543 WCF.LoadingOverlayHandler.updateIcon($target);
2544 this._sendRequest($target);
2545 }
2546 },
2547
2548 /**
2549 * Executes toggeling.
2550 *
2551 * @param string action
2552 * @param object parameters
2553 */
2554 _execute: function(action, parameters) {
2555 if (action === 'cancel') {
2556 return;
2557 }
2558
2559 WCF.LoadingOverlayHandler.updateIcon(parameters.target);
2560 this._sendRequest(parameters.target);
2561 },
2562
2563 _sendRequest: function(object) {
2564 this.proxy.setOption('data', {
2565 actionName: 'toggle',
2566 className: this._className,
2567 interfaceName: 'wcf\\data\\IToggleAction',
2568 objectIDs: [ $(object).data('objectID') ]
2569 });
2570
2571 this.proxy.sendRequest();
2572 },
2573
2574 /**
2575 * Toggles status icons.
2576 *
2577 * @param object data
2578 * @param string textStatus
2579 * @param object jqXHR
2580 */
2581 _success: function(data, textStatus, jqXHR) {
2582 this.triggerEffect(data.objectIDs);
2583 },
2584
2585 /**
2586 * Triggers the toggle effect for the objects with the given ids.
2587 *
2588 * @param array objectIDs
2589 */
2590 triggerEffect: function(objectIDs) {
2591 for (var $index in this._containers) {
2592 var $container = $('#' + this._containers[$index]);
2593 var $toggleButton = $container.find(this._buttonSelector);
2594 if (WCF.inArray($toggleButton.data('objectID'), objectIDs)) {
2595 $container.wcfHighlight();
2596 this._toggleButton($container, $toggleButton);
2597 }
2598 }
2599 },
2600
2601 /**
2602 * Tiggers the toggle effect on a button
2603 *
2604 * @param jQuery $container
2605 * @param jQuery $toggleButton
2606 */
2607 _toggleButton: function($container, $toggleButton) {
2608 // toggle icon source
2609 WCF.LoadingOverlayHandler.updateIcon($toggleButton, false);
2610 if ($toggleButton.hasClass('icon-check-empty')) {
2611 $toggleButton.removeClass('icon-check-empty').addClass('icon-check');
2612 $newTitle = ($toggleButton.data('disableTitle') ? $toggleButton.data('disableTitle') : WCF.Language.get('wcf.global.button.disable'));
2613 $toggleButton.attr('title', $newTitle);
2614 }
2615 else {
2616 $toggleButton.removeClass('icon-check').addClass('icon-check-empty');
2617 $newTitle = ($toggleButton.data('enableTitle') ? $toggleButton.data('enableTitle') : WCF.Language.get('wcf.global.button.enable'));
2618 $toggleButton.attr('title', $newTitle);
2619 }
2620
2621 // toggle css class
2622 $container.toggleClass('disabled');
2623 }
2624 });
2625
2626 /**
2627 * Executes provided callback if scroll threshold is reached. Usuable to determine
2628 * if user reached the bottom of an element to load new elements on the fly.
2629 *
2630 * If you do not provide a value for 'reference' and 'target' it will assume you're
2631 * monitoring page scrolls, otherwise a valid jQuery selector must be provided for both.
2632 *
2633 * @param integer threshold
2634 * @param object callback
2635 * @param string reference
2636 * @param string target
2637 */
2638 WCF.Action.Scroll = Class.extend({
2639 /**
2640 * callback used once threshold is reached
2641 * @var object
2642 */
2643 _callback: null,
2644
2645 /**
2646 * reference object
2647 * @var jQuery
2648 */
2649 _reference: null,
2650
2651 /**
2652 * target object
2653 * @var jQuery
2654 */
2655 _target: null,
2656
2657 /**
2658 * threshold value
2659 * @var integer
2660 */
2661 _threshold: 0,
2662
2663 /**
2664 * Initializes a new WCF.Action.Scroll object.
2665 *
2666 * @param integer threshold
2667 * @param object callback
2668 * @param string reference
2669 * @param string target
2670 */
2671 init: function(threshold, callback, reference, target) {
2672 this._threshold = parseInt(threshold);
2673 if (this._threshold === 0) {
2674 console.debug("[WCF.Action.Scroll] Given threshold is invalid, aborting.");
2675 return;
2676 }
2677
2678 if ($.isFunction(callback)) this._callback = callback;
2679 if (this._callback === null) {
2680 console.debug("[WCF.Action.Scroll] Given callback is invalid, aborting.");
2681 return;
2682 }
2683
2684 // bind element references
2685 this._reference = $((reference) ? reference : window);
2686 this._target = $((target) ? target : document);
2687
2688 // watch for scroll event
2689 this.start();
2690
2691 // check if browser navigated back and jumped to offset before JavaScript was loaded
2692 this._scroll();
2693 },
2694
2695 /**
2696 * Calculates if threshold is reached and notifies callback.
2697 */
2698 _scroll: function() {
2699 var $targetHeight = this._target.height();
2700 var $topOffset = this._reference.scrollTop();
2701 var $referenceHeight = this._reference.height();
2702
2703 // calculate if defined threshold is visible
2704 if (($targetHeight - ($referenceHeight + $topOffset)) < this._threshold) {
2705 this._callback(this);
2706 }
2707 },
2708
2709 /**
2710 * Enables scroll monitoring, may be used to resume.
2711 */
2712 start: function() {
2713 this._reference.on('scroll', $.proxy(this._scroll, this));
2714 },
2715
2716 /**
2717 * Disables scroll monitoring, e.g. no more elements loadable.
2718 */
2719 stop: function() {
2720 this._reference.off('scroll');
2721 }
2722 });
2723
2724 /**
2725 * Namespace for date-related functions.
2726 */
2727 WCF.Date = {};
2728
2729 /**
2730 * Provides a date picker for date input fields.
2731 */
2732 WCF.Date.Picker = {
2733 /**
2734 * date format
2735 * @var string
2736 */
2737 _dateFormat: 'yy-mm-dd',
2738
2739 /**
2740 * time format
2741 * @var string
2742 */
2743 _timeFormat: 'g:ia',
2744
2745 /**
2746 * Initializes the jQuery UI based date picker.
2747 */
2748 init: function() {
2749 // ignore error 'unexpected literal' error; this might be not the best approach
2750 // to fix this problem, but since the date is properly processed anyway, we can
2751 // simply continue :) - Alex
2752 var $__log = $.timepicker.log;
2753 $.timepicker.log = function(error) {
2754 if (error.indexOf('Error parsing the date/time string: Unexpected literal at position') == -1 && error.indexOf('Error parsing the date/time string: Unknown name at position') == -1) {
2755 $__log(error);
2756 }
2757 };
2758
2759 this._convertDateFormat();
2760 this._initDatePicker();
2761 WCF.DOMNodeInsertedHandler.addCallback('WCF.Date.Picker', $.proxy(this._initDatePicker, this));
2762 },
2763
2764 /**
2765 * Convert PHPs date() format to jQuery UIs date picker format.
2766 */
2767 _convertDateFormat: function() {
2768 // replacement table
2769 // format of PHP date() => format of jQuery UI date picker
2770 //
2771 // No equivalence in PHP date():
2772 // oo day of the year (three digit)
2773 // ! Windows ticks (100ns since 01/01/0001)
2774 //
2775 // No equivalence in jQuery UI date picker:
2776 // N ISO-8601 numeric representation of the day of the week
2777 // w Numeric representation of the day of the week
2778 // W ISO-8601 week number of year, weeks starting on Monday
2779 // t Number of days in the given month
2780 // L Whether it's a leap year
2781 var $replacementTable = {
2782 // time
2783 'a': 'tt',
2784 'A': 'TT',
2785 'g': 'h',
2786 'G': 'H',
2787 'h': 'hh',
2788 'H': 'HH',
2789 'i': 'mm',
2790 's': 'ss',
2791 'u': 'l',
2792
2793 // day
2794 'd': 'dd',
2795 'D': 'D',
2796 'j': 'd',
2797 'l': 'DD',
2798 'z': 'o',
2799 'S': '', // English ordinal suffix for the day of the month, 2 characters, will be discarded
2800
2801 // month
2802 'F': 'MM',
2803 'm': 'mm',
2804 'M': 'M',
2805 'n': 'm',
2806
2807 // year
2808 'o': 'yy',
2809 'Y': 'yy',
2810 'y': 'y',
2811
2812 // timestamp
2813 'U': '@'
2814 };
2815
2816 // do the actual replacement
2817 // this is not perfect, but a basic implementation and should work in 99% of the cases
2818 this._dateFormat = WCF.Language.get('wcf.date.dateFormat').replace(/([^dDjlzSFmMnoYyU\\]*(?:\\.[^dDjlzSFmMnoYyU\\]*)*)([dDjlzSFmMnoYyU])/g, function(match, part1, part2, offset, string) {
2819 for (var $key in $replacementTable) {
2820 if (part2 == $key) {
2821 part2 = $replacementTable[$key];
2822 }
2823 }
2824
2825 return part1 + part2;
2826 });
2827
2828 this._timeFormat = WCF.Language.get('wcf.date.timeFormat').replace(/([^aAgGhHisu\\]*(?:\\.[^aAgGhHisu\\]*)*)([aAgGhHisu])/g, function(match, part1, part2, offset, string) {
2829 for (var $key in $replacementTable) {
2830 if (part2 == $key) {
2831 part2 = $replacementTable[$key];
2832 }
2833 }
2834
2835 return part1 + part2;
2836 });
2837 },
2838
2839 /**
2840 * Initializes the date picker for valid fields.
2841 */
2842 _initDatePicker: function() {
2843 $('input[type=date]:not(.jsDatePicker), input[type=datetime]:not(.jsDatePicker)').each($.proxy(function(index, input) {
2844 var $input = $(input);
2845 var $inputName = $input.prop('name');
2846 var $inputValue = $input.val(); // should be Y-m-d (H:i:s), must be interpretable by Date
2847
2848 var $hasTime = $input.attr('type') == 'datetime';
2849
2850 // update $input
2851 $input.prop('type', 'text').addClass('jsDatePicker');
2852
2853 // set placeholder
2854 if ($input.data('placeholder')) $input.attr('placeholder', $input.data('placeholder'));
2855
2856 // insert a hidden element representing the actual date
2857 $input.removeAttr('name');
2858 $input.before('<input type="hidden" id="' + $input.wcfIdentify() + 'DatePicker" name="' + $inputName + '" value="' + $inputValue + '" />');
2859
2860 // max- and mindate
2861 var $maxDate = $input.attr('max') ? new Date($input.attr('max').replace(' ', 'T')) : null;
2862 var $minDate = $input.attr('min') ? new Date($input.attr('min').replace(' ', 'T')) : null;
2863
2864 // init date picker
2865 $options = {
2866 altField: '#' + $input.wcfIdentify() + 'DatePicker',
2867 altFormat: 'yy-mm-dd', // PHPs strtotime() understands this best
2868 beforeShow: function(input, instance) {
2869 // dirty hack to force opening below the input
2870 setTimeout(function() {
2871 instance.dpDiv.position({
2872 my: 'left top',
2873 at: 'left bottom',
2874 collision: 'none',
2875 of: input
2876 });
2877 }, 1);
2878 },
2879 changeMonth: true,
2880 changeYear: true,
2881 dateFormat: this._dateFormat,
2882 dayNames: WCF.Language.get('__days'),
2883 dayNamesMin: WCF.Language.get('__daysShort'),
2884 dayNamesShort: WCF.Language.get('__daysShort'),
2885 firstDay: parseInt(WCF.Language.get('wcf.date.firstDayOfTheWeek')) || 0,
2886 isRTL: WCF.Language.get('wcf.global.pageDirection') == 'rtl',
2887 maxDate: $maxDate,
2888 minDate: $minDate,
2889 monthNames: WCF.Language.get('__months'),
2890 monthNamesShort: WCF.Language.get('__monthsShort'),
2891 showButtonPanel: false,
2892 onClose: function(dateText, datePicker) {
2893 // clear altField when datepicker is cleared
2894 if (dateText == '') {
2895 $(datePicker.settings["altField"]).val(dateText);
2896 }
2897 },
2898 showOtherMonths: true,
2899 yearRange: ($input.hasClass('birthday') ? '-100:+0' : '1900:2038')
2900 };
2901
2902 if ($hasTime) {
2903 // drop the seconds
2904 if (/[0-9]{2}:[0-9]{2}:[0-9]{2}$/.test($inputValue)) {
2905 $inputValue = $inputValue.replace(/:[0-9]{2}$/, '');
2906 $input.val($inputValue);
2907 }
2908 $inputValue = $inputValue.replace(' ', 'T');
2909
2910 if ($input.data('ignoreTimezone')) {
2911 var $timezoneOffset = new Date($inputValue).getTimezoneOffset();
2912 var $timezone = ($timezoneOffset > 0) ? '-' : '+'; // -120 equals GMT+0200
2913 $timezoneOffset = Math.abs($timezoneOffset);
2914 var $hours = (Math.floor($timezoneOffset / 60)).toString();
2915 var $minutes = ($timezoneOffset % 60).toString();
2916 $timezone += ($hours.length == 2) ? $hours : '0' + $hours;
2917 $timezone += ':';
2918 $timezone += ($minutes.length == 2) ? $minutes : '0' + $minutes;
2919
2920 $inputValue = $inputValue.replace(/[+-][0-9]{2}:[0-9]{2}$/, $timezone);
2921 }
2922
2923 $options = $.extend($options, {
2924 altFieldTimeOnly: false,
2925 altTimeFormat: 'HH:mm',
2926 controlType: 'select',
2927 hourText: WCF.Language.get('wcf.date.hour'),
2928 minuteText: WCF.Language.get('wcf.date.minute'),
2929 showTime: false,
2930 timeFormat: this._timeFormat,
2931 yearRange: ($input.hasClass('birthday') ? '-100:+0' : '1900:2038')
2932 });
2933 }
2934
2935 if ($hasTime) {
2936 $input.datetimepicker($options);
2937 }
2938 else {
2939 $input.datepicker($options);
2940 }
2941
2942 // format default date
2943 if ($inputValue) {
2944 if (!$hasTime) {
2945 // drop timezone for date-only input
2946 $inputValue = new Date($inputValue);
2947 $inputValue.setMinutes($inputValue.getMinutes() + $inputValue.getTimezoneOffset());
2948 }
2949
2950 $input.datepicker('setDate', $inputValue);
2951 }
2952
2953 // bug workaround: setDate creates the widget but unfortunately doesn't hide it...
2954 $input.datepicker('widget').hide();
2955 }, this));
2956 }
2957 };
2958
2959 /**
2960 * Provides utility functions for date operations.
2961 */
2962 WCF.Date.Util = {
2963 /**
2964 * Returns UTC timestamp, if date is not given, current time will be used.
2965 *
2966 * @param Date date
2967 * @return integer
2968 */
2969 gmdate: function(date) {
2970 var $date = (date) ? date : new Date();
2971
2972 return Math.round(Date.UTC(
2973 $date.getUTCFullYear(),
2974 $date.getUTCMonth(),
2975 $date.getUTCDay(),
2976 $date.getUTCHours(),
2977 $date.getUTCMinutes(),
2978 $date.getUTCSeconds()
2979 ) / 1000);
2980 },
2981
2982 /**
2983 * Returns a Date object with precise offset (including timezone and local timezone).
2984 * Parameters timestamp and offset must be in miliseconds!
2985 *
2986 * @param integer timestamp
2987 * @param integer offset
2988 * @return Date
2989 */
2990 getTimezoneDate: function(timestamp, offset) {
2991 var $date = new Date(timestamp);
2992 var $localOffset = $date.getTimezoneOffset() * 60000;
2993
2994 return new Date((timestamp + $localOffset + offset));
2995 }
2996 };
2997
2998 /**
2999 * Handles relative time designations.
3000 */
3001 WCF.Date.Time = Class.extend({
3002 /**
3003 * Date of current timestamp
3004 * @var Date
3005 */
3006 _date: 0,
3007
3008 /**
3009 * list of time elements
3010 * @var jQuery
3011 */
3012 _elements: null,
3013
3014 /**
3015 * difference between server and local time
3016 * @var integer
3017 */
3018 _offset: null,
3019
3020 /**
3021 * current timestamp
3022 * @var integer
3023 */
3024 _timestamp: 0,
3025
3026 /**
3027 * Initializes relative datetimes.
3028 */
3029 init: function() {
3030 this._elements = $('time.datetime');
3031 this._offset = null;
3032 this._timestamp = 0;
3033
3034 // calculate relative datetime on init
3035 this._refresh();
3036
3037 // re-calculate relative datetime every minute
3038 new WCF.PeriodicalExecuter($.proxy(this._refresh, this), 60000);
3039
3040 // bind dom node inserted listener
3041 WCF.DOMNodeInsertedHandler.addCallback('WCF.Date.Time', $.proxy(this._domNodeInserted, this));
3042 },
3043
3044 /**
3045 * Updates element collection once a DOM node was inserted.
3046 */
3047 _domNodeInserted: function() {
3048 this._elements = $('time.datetime');
3049 this._refresh();
3050 },
3051
3052 /**
3053 * Refreshes relative datetime for each element.
3054 */
3055 _refresh: function() {
3056 this._date = new Date();
3057 this._timestamp = (this._date.getTime() - this._date.getMilliseconds()) / 1000;
3058 if (this._offset === null) {
3059 this._offset = this._timestamp - TIME_NOW;
3060 }
3061
3062 this._elements.each($.proxy(this._refreshElement, this));
3063 },
3064
3065 /**
3066 * Refreshes relative datetime for current element.
3067 *
3068 * @param integer index
3069 * @param object element
3070 */
3071 _refreshElement: function(index, element) {
3072 var $element = $(element);
3073
3074 if (!$element.attr('title')) {
3075 $element.attr('title', $element.text());
3076 }
3077
3078 var $timestamp = $element.data('timestamp') + this._offset;
3079 var $date = $element.data('date');
3080 var $time = $element.data('time');
3081 var $offset = $element.data('offset');
3082
3083 // skip for future dates
3084 if ($element.data('isFutureDate')) return;
3085
3086 // timestamp is less than 60 seconds ago
3087 if ($timestamp >= this._timestamp || this._timestamp < ($timestamp + 60)) {
3088 $element.text(WCF.Language.get('wcf.date.relative.now'));
3089 }
3090 // timestamp is less than 60 minutes ago (display 1 hour ago rather than 60 minutes ago)
3091 else if (this._timestamp < ($timestamp + 3540)) {
3092 var $minutes = Math.max(Math.round((this._timestamp - $timestamp) / 60), 1);
3093 $element.text(WCF.Language.get('wcf.date.relative.minutes', { minutes: $minutes }));
3094 }
3095 // timestamp is less than 24 hours ago
3096 else if (this._timestamp < ($timestamp + 86400)) {
3097 var $hours = Math.round((this._timestamp - $timestamp) / 3600);
3098 $element.text(WCF.Language.get('wcf.date.relative.hours', { hours: $hours }));
3099 }
3100 // timestamp is less than 6 days ago
3101 else if (this._timestamp < ($timestamp + 518400)) {
3102 var $midnight = new Date(this._date.getFullYear(), this._date.getMonth(), this._date.getDate());
3103 var $days = Math.ceil(($midnight / 1000 - $timestamp) / 86400);
3104
3105 // get day of week
3106 var $dateObj = WCF.Date.Util.getTimezoneDate(($timestamp * 1000), $offset * 1000);
3107 var $dow = $dateObj.getDay();
3108 var $day = WCF.Language.get('__days')[$dow];
3109
3110 $element.text(WCF.Language.get('wcf.date.relative.pastDays', { days: $days, day: $day, time: $time }));
3111 }
3112 // timestamp is between ~700 million years BC and last week
3113 else {
3114 var $string = WCF.Language.get('wcf.date.shortDateTimeFormat');
3115 $element.text($string.replace(/\%date\%/, $date).replace(/\%time\%/, $time));
3116 }
3117 }
3118 });
3119
3120 /**
3121 * Hash-like dictionary. Based upon idead from Prototype's hash
3122 *
3123 * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/hash.js
3124 */
3125 WCF.Dictionary = Class.extend({
3126 /**
3127 * list of variables
3128 * @var object
3129 */
3130 _variables: { },
3131
3132 /**
3133 * Initializes a new dictionary.
3134 */
3135 init: function() {
3136 this._variables = { };
3137 },
3138
3139 /**
3140 * Adds an entry.
3141 *
3142 * @param string key
3143 * @param mixed value
3144 */
3145 add: function(key, value) {
3146 this._variables[key] = value;
3147 },
3148
3149 /**
3150 * Adds a traditional object to current dataset.
3151 *
3152 * @param object object
3153 */
3154 addObject: function(object) {
3155 for (var $key in object) {
3156 this.add($key, object[$key]);
3157 }
3158 },
3159
3160 /**
3161 * Adds a dictionary to current dataset.
3162 *
3163 * @param object dictionary
3164 */
3165 addDictionary: function(dictionary) {
3166 dictionary.each($.proxy(function(pair) {
3167 this.add(pair.key, pair.value);
3168 }, this));
3169 },
3170
3171 /**
3172 * Retrieves the value of an entry or returns null if key is not found.
3173 *
3174 * @param string key
3175 * @returns mixed
3176 */
3177 get: function(key) {
3178 if (this.isset(key)) {
3179 return this._variables[key];
3180 }
3181
3182 return null;
3183 },
3184
3185 /**
3186 * Returns true if given key is a valid entry.
3187 *
3188 * @param string key
3189 */
3190 isset: function(key) {
3191 return this._variables.hasOwnProperty(key);
3192 },
3193
3194 /**
3195 * Removes an entry.
3196 *
3197 * @param string key
3198 */
3199 remove: function(key) {
3200 delete this._variables[key];
3201 },
3202
3203 /**
3204 * Iterates through dictionary.
3205 *
3206 * Usage:
3207 * var $hash = new WCF.Dictionary();
3208 * $hash.add('foo', 'bar');
3209 * $hash.each(function(pair) {
3210 * // alerts: foo = bar
3211 * alert(pair.key + ' = ' + pair.value);
3212 * });
3213 *
3214 * @param function callback
3215 */
3216 each: function(callback) {
3217 if (!$.isFunction(callback)) {
3218 return;
3219 }
3220
3221 for (var $key in this._variables) {
3222 var $value = this._variables[$key];
3223 var $pair = {
3224 key: $key,
3225 value: $value
3226 };
3227
3228 callback($pair);
3229 }
3230 },
3231
3232 /**
3233 * Returns the amount of items.
3234 *
3235 * @return integer
3236 */
3237 count: function() {
3238 return $.getLength(this._variables);
3239 },
3240
3241 /**
3242 * Returns true if dictionary is empty.
3243 *
3244 * @return integer
3245 */
3246 isEmpty: function() {
3247 return !this.count();
3248 }
3249 });
3250
3251 /**
3252 * Global language storage.
3253 *
3254 * @see WCF.Dictionary
3255 */
3256 WCF.Language = {
3257 _variables: new WCF.Dictionary(),
3258
3259 /**
3260 * @see WCF.Dictionary.add()
3261 */
3262 add: function(key, value) {
3263 this._variables.add(key, value);
3264 },
3265
3266 /**
3267 * @see WCF.Dictionary.addObject()
3268 */
3269 addObject: function(object) {
3270 this._variables.addObject(object);
3271 },
3272
3273 /**
3274 * Retrieves a variable.
3275 *
3276 * @param string key
3277 * @return mixed
3278 */
3279 get: function(key, parameters) {
3280 // initialize parameters with an empty object
3281 if (parameters == null) var parameters = { };
3282
3283 var value = this._variables.get(key);
3284
3285 if (value === null) {
3286 // return key again
3287 return key;
3288 }
3289 else if (typeof value === 'string') {
3290 // transform strings into template and try to refetch
3291 this.add(key, new WCF.Template(value));
3292 return this.get(key, parameters);
3293 }
3294 else if (typeof value.fetch === 'function') {
3295 // evaluate templates
3296 value = value.fetch(parameters);
3297 }
3298
3299 return value;
3300 }
3301 };
3302
3303 /**
3304 * Handles multiple language input fields.
3305 *
3306 * @param string elementID
3307 * @param boolean forceSelection
3308 * @param object values
3309 * @param object availableLanguages
3310 */
3311 WCF.MultipleLanguageInput = Class.extend({
3312 /**
3313 * list of available languages
3314 * @var object
3315 */
3316 _availableLanguages: {},
3317
3318 /**
3319 * button element
3320 * @var jQuery
3321 */
3322 _button: null,
3323
3324 /**
3325 * initialization state
3326 * @var boolean
3327 */
3328 _didInit: false,
3329
3330 /**
3331 * target input element
3332 * @var jQuery
3333 */
3334 _element: null,
3335
3336 /**
3337 * true, if data was entered after initialization
3338 * @var boolean
3339 */
3340 _insertedDataAfterInit: false,
3341
3342 /**
3343 * enables multiple language ability
3344 * @var boolean
3345 */
3346 _isEnabled: false,
3347
3348 /**
3349 * enforce multiple language ability
3350 * @var boolean
3351 */
3352 _forceSelection: false,
3353
3354 /**
3355 * currently active language id
3356 * @var integer
3357 */
3358 _languageID: 0,
3359
3360 /**
3361 * language selection list
3362 * @var jQuery
3363 */
3364 _list: null,
3365
3366 /**
3367 * list of language values on init
3368 * @var object
3369 */
3370 _values: null,
3371
3372 /**
3373 * Initializes multiple language ability for given element id.
3374 *
3375 * @param integer elementID
3376 * @param boolean forceSelection
3377 * @param boolean isEnabled
3378 * @param object values
3379 * @param object availableLanguages
3380 */
3381 init: function(elementID, forceSelection, values, availableLanguages) {
3382 this._button = null;
3383 this._element = $('#' + $.wcfEscapeID(elementID));
3384 this._forceSelection = forceSelection;
3385 this._values = values;
3386 this._availableLanguages = availableLanguages;
3387
3388 // unescape values
3389 if ($.getLength(this._values)) {
3390 for (var $key in this._values) {
3391 this._values[$key] = WCF.String.unescapeHTML(this._values[$key]);
3392 }
3393 }
3394
3395 // default to current user language
3396 this._languageID = LANGUAGE_ID;
3397 if (this._element.length == 0) {
3398 console.debug("[WCF.MultipleLanguageInput] element id '" + elementID + "' is unknown");
3399 return;
3400 }
3401
3402 // build selection handler
3403 var $enableOnInit = ($.getLength(this._values) > 0) ? true : false;
3404 this._insertedDataAfterInit = $enableOnInit;
3405 this._prepareElement($enableOnInit);
3406
3407 // listen for submit event
3408 this._element.parents('form').submit($.proxy(this._submit, this));
3409
3410 this._didInit = true;
3411 },
3412
3413 /**
3414 * Builds language handler.
3415 *
3416 * @param boolean enableOnInit
3417 */
3418 _prepareElement: function(enableOnInit) {
3419 this._element.wrap('<div class="dropdown preInput" />');
3420 var $wrapper = this._element.parent();
3421 this._button = $('<p class="button dropdownToggle"><span>' + WCF.Language.get('wcf.global.button.disabledI18n') + '</span></p>').prependTo($wrapper);
3422
3423 // insert list
3424 this._list = $('<ul class="dropdownMenu"></ul>').insertAfter(this._button);
3425
3426 // add a special class if next item is a textarea
3427 if (this._button.nextAll('textarea').length) {
3428 this._button.addClass('dropdownCaptionTextarea');
3429 }
3430 else {
3431 this._button.addClass('dropdownCaption');
3432 }
3433
3434 // insert available languages
3435 for (var $languageID in this._availableLanguages) {
3436 $('<li><span>' + this._availableLanguages[$languageID] + '</span></li>').data('languageID', $languageID).click($.proxy(this._changeLanguage, this)).appendTo(this._list);
3437 }
3438
3439 // disable language input
3440 if (!this._forceSelection) {
3441 $('<li class="dropdownDivider" />').appendTo(this._list);
3442 $('<li><span>' + WCF.Language.get('wcf.global.button.disabledI18n') + '</span></li>').click($.proxy(this._disable, this)).appendTo(this._list);
3443 }
3444
3445 WCF.Dropdown.initDropdown(this._button, enableOnInit);
3446
3447 if (enableOnInit || this._forceSelection) {
3448 this._isEnabled = true;
3449
3450 // pre-select current language
3451 this._list.children('li').each($.proxy(function(index, listItem) {
3452 var $listItem = $(listItem);
3453 if ($listItem.data('languageID') == this._languageID) {
3454 $listItem.trigger('click');
3455 }
3456 }, this));
3457 }
3458
3459 WCF.Dropdown.registerCallback($wrapper.wcfIdentify(), $.proxy(this._handleAction, this));
3460 },
3461
3462 /**
3463 * Handles dropdown actions.
3464 *
3465 * @param string containerID
3466 * @param string action
3467 */
3468 _handleAction: function(containerID, action) {
3469 if (action === 'open') {
3470 this._enable();
3471 }
3472 else {
3473 this._closeSelection();
3474 }
3475 },
3476
3477 /**
3478 * Enables the language selection or shows the selection if already enabled.
3479 *
3480 * @param object event
3481 */
3482 _enable: function(event) {
3483 if (!this._isEnabled) {
3484 var $button = (this._button.is('p')) ? this._button.children('span:eq(0)') : this._button;
3485 $button.addClass('active');
3486
3487 this._isEnabled = true;
3488 }
3489
3490 // toggle list
3491 if (this._list.is(':visible')) {
3492 this._showSelection();
3493 }
3494 },
3495
3496 /**
3497 * Shows the language selection.
3498 */
3499 _showSelection: function() {
3500 if (this._isEnabled) {
3501 // display status for each language
3502 this._list.children('li').each($.proxy(function(index, listItem) {
3503 var $listItem = $(listItem);
3504 var $languageID = $listItem.data('languageID');
3505
3506 if ($languageID) {
3507 if (this._values[$languageID] && this._values[$languageID] != '') {
3508 $listItem.removeClass('missingValue');
3509 }
3510 else {
3511 $listItem.addClass('missingValue');
3512 }
3513 }
3514 }, this));
3515 }
3516 },
3517
3518 /**
3519 * Closes the language selection.
3520 */
3521 _closeSelection: function() {
3522 this._disable();
3523 },
3524
3525 /**
3526 * Changes the currently active language.
3527 *
3528 * @param object event
3529 */
3530 _changeLanguage: function(event) {
3531 var $button = $(event.currentTarget);
3532 this._insertedDataAfterInit = true;
3533
3534 // save current value
3535 if (this._didInit) {
3536 this._values[this._languageID] = this._element.val();
3537 }
3538
3539 // set new language
3540 this._languageID = $button.data('languageID');
3541 if (this._values[this._languageID]) {
3542 this._element.val(this._values[this._languageID]);
3543 }
3544 else {
3545 this._element.val('');
3546 }
3547
3548 // update marking
3549 this._list.children('li').removeClass('active');
3550 $button.addClass('active');
3551
3552 // update label
3553 this._button.children('span').addClass('active').text(this._availableLanguages[this._languageID]);
3554
3555 // close selection and set focus on input element
3556 if (this._didInit) {
3557 this._element.blur().focus();
3558 }
3559 },
3560
3561 /**
3562 * Disables language selection for current element.
3563 *
3564 * @param object event
3565 */
3566 _disable: function(event) {
3567 if (event === undefined && this._insertedDataAfterInit) {
3568 event = null;
3569 }
3570
3571 if (this._forceSelection || !this._list || event === null) {
3572 return;
3573 }
3574
3575 // remove active marking
3576 this._button.children('span').removeClass('active').text(WCF.Language.get('wcf.global.button.disabledI18n'));
3577
3578 // update element value
3579 if (this._values[LANGUAGE_ID]) {
3580 this._element.val(this._values[LANGUAGE_ID]);
3581 }
3582 else {
3583 // no value for current language found, proceed with empty input
3584 this._element.val();
3585 }
3586
3587 if (event) {
3588 this._list.children('li').removeClass('active');
3589 $(event.currentTarget).addClass('active');
3590 }
3591
3592 this._element.blur().focus();
3593 this._insertedDataAfterInit = false;
3594 this._isEnabled = false;
3595 this._values = { };
3596 },
3597
3598 /**
3599 * Prepares language variables on before submit.
3600 */
3601 _submit: function() {
3602 // insert hidden form elements on before submit
3603 if (!this._isEnabled) {
3604 return 0xDEADBEEF;
3605 }
3606
3607 // fetch active value
3608 if (this._languageID) {
3609 this._values[this._languageID] = this._element.val();
3610 }
3611
3612 var $form = $(this._element.parents('form')[0]);
3613 var $elementID = this._element.wcfIdentify();
3614
3615 for (var $languageID in this._availableLanguages) {
3616 if (this._values[$languageID] === undefined) {
3617 this._values[$languageID] = '';
3618 }
3619
3620 $('<input type="hidden" name="' + $elementID + '_i18n[' + $languageID + ']" value="' + WCF.String.escapeHTML(this._values[$languageID]) + '" />').appendTo($form);
3621 }
3622
3623 // remove name attribute to prevent conflict with i18n values
3624 this._element.removeAttr('name');
3625 }
3626 });
3627
3628 /**
3629 * Number utilities.
3630 */
3631 WCF.Number = {
3632 /**
3633 * Rounds a number to a given number of decimal places. Defaults to 0.
3634 *
3635 * @param number number
3636 * @param decimalPlaces number of decimal places
3637 * @return number
3638 */
3639 round: function (number, decimalPlaces) {
3640 decimalPlaces = Math.pow(10, (decimalPlaces || 0));
3641
3642 return Math.round(number * decimalPlaces) / decimalPlaces;
3643 }
3644 };
3645
3646 /**
3647 * String utilities.
3648 */
3649 WCF.String = {
3650 /**
3651 * Adds thousands separators to a given number.
3652 *
3653 * @see http://stackoverflow.com/a/6502556/782822
3654 * @param mixed number
3655 * @return string
3656 */
3657 addThousandsSeparator: function(number) {
3658 return String(number).replace(/(^-?\d{1,3}|\d{3})(?=(?:\d{3})+(?:$|\.))/g, '$1' + WCF.Language.get('wcf.global.thousandsSeparator'));
3659 },
3660
3661 /**
3662 * Escapes special HTML-characters within a string
3663 *
3664 * @param string string
3665 * @return string
3666 */
3667 escapeHTML: function (string) {
3668 return String(string).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3669 },
3670
3671 /**
3672 * Escapes a String to work with RegExp.
3673 *
3674 * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/regexp.js#L25
3675 * @param string string
3676 * @return string
3677 */
3678 escapeRegExp: function(string) {
3679 return String(string).replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1');
3680 },
3681
3682 /**
3683 * Rounds number to given count of floating point digits, localizes decimal-point and inserts thousands-separators
3684 *
3685 * @param mixed number
3686 * @return string
3687 */
3688 formatNumeric: function(number, decimalPlaces) {
3689 number = String(WCF.Number.round(number, decimalPlaces || 2));
3690 number = number.replace('.', WCF.Language.get('wcf.global.decimalPoint'));
3691
3692 number = this.addThousandsSeparator(number);
3693 number = number.replace('-', '\u2212');
3694
3695 return number;
3696 },
3697
3698 /**
3699 * Makes a string's first character lowercase
3700 *
3701 * @param string string
3702 * @return string
3703 */
3704 lcfirst: function(string) {
3705 return String(string).substring(0, 1).toLowerCase() + string.substring(1);
3706 },
3707
3708 /**
3709 * Makes a string's first character uppercase
3710 *
3711 * @param string string
3712 * @return string
3713 */
3714 ucfirst: function(string) {
3715 return String(string).substring(0, 1).toUpperCase() + string.substring(1);
3716 },
3717
3718 /**
3719 * Unescapes special HTML-characters within a string
3720 *
3721 * @param string string
3722 * @return string
3723 */
3724 unescapeHTML: function (string) {
3725 return String(string).replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
3726 }
3727 };
3728
3729 /**
3730 * Basic implementation for WCF TabMenus. Use the data attributes 'active' to specify the
3731 * tab which should be shown on init. Furthermore you may specify a 'store' data-attribute
3732 * which will be filled with the currently selected tab.
3733 */
3734 WCF.TabMenu = {
3735 /**
3736 * list of tabmenu containers
3737 * @var object
3738 */
3739 _containers: { },
3740
3741 /**
3742 * initialization state
3743 * @var boolean
3744 */
3745 _didInit: false,
3746
3747 /**
3748 * Initializes all TabMenus
3749 */
3750 init: function() {
3751 var $containers = $('.tabMenuContainer:not(.staticTabMenuContainer)');
3752 var self = this;
3753 $containers.each(function(index, tabMenu) {
3754 var $tabMenu = $(tabMenu);
3755 var $containerID = $tabMenu.wcfIdentify();
3756 if (self._containers[$containerID]) {
3757 // continue with next container
3758 return true;
3759 }
3760
3761 if ($tabMenu.data('store') && !$('#' + $tabMenu.data('store')).length) {
3762 $('<input type="hidden" name="' + $tabMenu.data('store') + '" value="" id="' + $tabMenu.data('store') + '" />').appendTo($tabMenu.parents('form').find('.formSubmit'));
3763 }
3764
3765 // init jQuery UI TabMenu
3766 self._containers[$containerID] = $tabMenu;
3767 $tabMenu.wcfTabs({
3768 active: false,
3769 activate: function(event, eventData) {
3770 var $panel = $(eventData.newPanel);
3771 var $container = $panel.closest('.tabMenuContainer');
3772
3773 // store currently selected item
3774 var $tabMenu = $container;
3775 while (true) {
3776 // do not trigger on init
3777 if ($tabMenu.data('isParent') === undefined) {
3778 break;
3779 }
3780
3781 if ($tabMenu.data('isParent')) {
3782 if ($tabMenu.data('store')) {
3783 $('#' + $tabMenu.data('store')).val($panel.attr('id'));
3784 }
3785
3786 break;
3787 }
3788 else {
3789 $tabMenu = $tabMenu.data('parent');
3790 }
3791 }
3792
3793 // set panel id as location hash
3794 if (WCF.TabMenu._didInit) {
3795 // do not update history if within an overlay
3796 if ($panel.data('inTabMenu') == undefined) {
3797 $panel.data('inTabMenu', ($panel.parents('.dialogContainer').length));
3798 }
3799
3800 if (!$panel.data('inTabMenu')) {
3801 if (window.history) {
3802 window.history.pushState(null, document.title, window.location.toString().replace(/#.+$/, '') + '#' + $panel.attr('id'));
3803 }
3804 else {
3805 location.hash = '#' + $panel.attr('id');
3806 }
3807 }
3808 }
3809 }
3810 });
3811
3812 $tabMenu.data('isParent', ($tabMenu.children('.tabMenuContainer, .tabMenuContent').length > 0)).data('parent', false);
3813 if (!$tabMenu.data('isParent')) {
3814 // check if we're a child element
3815 if ($tabMenu.parent().hasClass('tabMenuContainer')) {
3816 $tabMenu.data('parent', $tabMenu.parent());
3817 }
3818 }
3819 });
3820
3821 // try to resolve location hash
3822 if (!this._didInit) {
3823 this._selectActiveTab();
3824 $(window).bind('hashchange', $.proxy(this.selectTabs, this));
3825
3826 if (!this._selectErroneousTab()) {
3827 this.selectTabs();
3828 }
3829
3830 if ($.browser.mozilla && location.hash) {
3831 var $target = $(location.hash);
3832 if ($target.length && $target.hasClass('tabMenuContent')) {
3833 var $offset = $target.offset();
3834 window.scrollTo($offset.left, $offset.top);
3835 }
3836 }
3837 }
3838
3839 this._didInit = true;
3840 },
3841
3842 /**
3843 * Reloads the tab menus.
3844 */
3845 reload: function() {
3846 this._containers = { };
3847 this.init();
3848 },
3849
3850 /**
3851 * Force display of first erroneous tab and returns true if at least one
3852 * tab contains an error.
3853 *
3854 * @return boolean
3855 */
3856 _selectErroneousTab: function() {
3857 var $foundErrors = false;
3858 for (var $containerID in this._containers) {
3859 var $tabMenu = this._containers[$containerID];
3860
3861 if ($tabMenu.find('.formError').length) {
3862 $foundErrors = true;
3863
3864 if (!$tabMenu.data('isParent')) {
3865 while (true) {
3866 if ($tabMenu.data('parent') === false) {
3867 break;
3868 }
3869
3870 $tabMenu = $tabMenu.data('parent').wcfTabs('selectTab', $tabMenu.wcfIdentify());
3871 }
3872
3873 return true;
3874 }
3875 }
3876 }
3877
3878 // found an error in a non-nested tab menu
3879 if ($foundErrors) {
3880 for (var $containerID in this._containers) {
3881 var $tabMenu = this._containers[$containerID];
3882 var $formError = $tabMenu.find('.formError:eq(0)');
3883
3884 if ($formError.length) {
3885 // find the tab container
3886 $tabMenu.wcfTabs('selectTab', $formError.parents('.tabMenuContent').wcfIdentify());
3887
3888 while (true) {
3889 if ($tabMenu.data('parent') === false) {
3890 break;
3891 }
3892
3893 $tabMenu = $tabMenu.data('parent').wcfTabs('selectTab', $tabMenu.wcfIdentify());
3894 }
3895
3896 return true;
3897 }
3898 }
3899 }
3900
3901 return false;
3902 },
3903
3904 /**
3905 * Selects the active tab menu item.
3906 */
3907 _selectActiveTab: function() {
3908 for (var $containerID in this._containers) {
3909 var $tabMenu = this._containers[$containerID];
3910 if ($tabMenu.data('active')) {
3911 var $index = $tabMenu.data('active');
3912 var $subIndex = null;
3913 if (/-/.test($index)) {
3914 var $tmp = $index.split('-');
3915 $index = $tmp[0];
3916 $subIndex = $tmp[1];
3917 }
3918
3919 $tabMenu.find('.tabMenuContent').each(function(innerIndex, tabMenuItem) {
3920 var $tabMenuItem = $(tabMenuItem);
3921 if ($tabMenuItem.wcfIdentify() == $index) {
3922 $tabMenu.wcfTabs('select', innerIndex);
3923 if ($subIndex !== null) {
3924 if ($tabMenuItem.hasClass('tabMenuContainer')) {
3925 $tabMenuItem.wcfTabs('selectTab', $tabMenu.data('active'));
3926 }
3927 else {
3928 $tabMenu.wcfTabs('selectTab', $tabMenu.data('active'));
3929 }
3930 }
3931
3932 return false;
3933 }
3934 });
3935 }
3936 }
3937 },
3938
3939 /**
3940 * Resolves location hash to display tab menus.
3941 *
3942 * @return boolean
3943 */
3944 selectTabs: function() {
3945 if (location.hash) {
3946 var $hash = location.hash.substr(1);
3947
3948 // try to find matching tab menu container
3949 var $tabMenu = $('#' + $.wcfEscapeID($hash));
3950 if ($tabMenu.length === 1 && $tabMenu.hasClass('ui-tabs-panel')) {
3951 $tabMenu = $tabMenu.parent('.ui-tabs');
3952 if ($tabMenu.length) {
3953 $tabMenu.wcfTabs('selectTab', $hash);
3954
3955 // check if this is a nested tab menu
3956 if ($tabMenu.hasClass('ui-tabs-panel')) {
3957 $hash = $tabMenu.wcfIdentify();
3958 $tabMenu = $tabMenu.parent('.ui-tabs');
3959 if ($tabMenu.length) {
3960 $tabMenu.wcfTabs('selectTab', $hash);
3961 }
3962 }
3963
3964 return true;
3965 }
3966 }
3967 }
3968
3969 return false;
3970 }
3971 };
3972
3973 /**
3974 * Templates that may be fetched more than once with different variables.
3975 * Based upon ideas from Prototype's template.
3976 *
3977 * Usage:
3978 * var myTemplate = new WCF.Template('{$hello} World');
3979 * myTemplate.fetch({ hello: 'Hi' }); // Hi World
3980 * myTemplate.fetch({ hello: 'Hello' }); // Hello World
3981 *
3982 * my2ndTemplate = new WCF.Template('{@$html}{$html}');
3983 * my2ndTemplate.fetch({ html: '<b>Test</b>' }); // <b>Test</b>&lt;b&gt;Test&lt;/b&gt;
3984 *
3985 * var my3rdTemplate = new WCF.Template('You can use {literal}{$variable}{/literal}-Tags here');
3986 * my3rdTemplate.fetch({ variable: 'Not shown' }); // You can use {$variable}-Tags here
3987 *
3988 * @param template template-content
3989 * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/template.js
3990 */
3991 WCF.Template = Class.extend({
3992 /**
3993 * Prepares template
3994 *
3995 * @param $template template-content
3996 */
3997 init: function(template) {
3998 var $literals = new WCF.Dictionary();
3999 var $tagID = 0;
4000
4001 // escape \ and ' and newlines
4002 template = template.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/(\r\n|\n|\r)/g, '\\n');
4003
4004 // save literal-tags
4005 template = template.replace(/\{literal\}(.*?)\{\/literal\}/g, $.proxy(function(match) {
4006 // hopefully no one uses this string in one of his templates
4007 var id = '@@@@@@@@@@@'+Math.random()+'@@@@@@@@@@@';
4008 $literals.add(id, match.replace(/\{\/?literal\}/g, ''));
4009
4010 return id;
4011 }, this));
4012
4013 // remove comments
4014 template = template.replace(/\{\*.*?\*\}/g, '');
4015
4016 var parseParameterList = function(parameterString) {
4017 var $chars = parameterString.split('');
4018 var $parameters = { };
4019 var $inName = true;
4020 var $name = '';
4021 var $value = '';
4022 var $doubleQuoted = false;
4023 var $singleQuoted = false;
4024 var $escaped = false;
4025
4026 for (var $i = 0, $max = $chars.length; $i < $max; $i++) {
4027 var $char = $chars[$i];
4028 if ($inName && $char != '=' && $char != ' ') $name += $char;
4029 else if ($inName && $char == '=') {
4030 $inName = false;
4031 $singleQuoted = false;
4032 $doubleQuoted = false;
4033 $escaped = false;
4034 }
4035 else if (!$inName && !$singleQuoted && !$doubleQuoted && $char == ' ') {
4036 $inName = true;
4037 $parameters[$name] = $value;
4038 $value = $name = '';
4039 }
4040 else if (!$inName && $singleQuoted && !$escaped && $char == "'") {
4041 $singleQuoted = false;
4042 $value += $char;
4043 }
4044 else if (!$inName && !$singleQuoted && !$doubleQuoted && $char == "'") {
4045 $singleQuoted = true;
4046 $value += $char;
4047 }
4048 else if (!$inName && $doubleQuoted && !$escaped && $char == '"') {
4049 $doubleQuoted = false;
4050 $value += $char;
4051 }
4052 else if (!$inName && !$singleQuoted && !$doubleQuoted && $char == '"') {
4053 $doubleQuoted = true;
4054 $value += $char;
4055 }
4056 else if (!$inName && ($doubleQuoted || $singleQuoted) && !$escaped && $char == '\\') {
4057 $escaped = true;
4058 $value += $char;
4059 }
4060 else if (!$inName) {
4061 $escaped = false;
4062 $value += $char;
4063 }
4064 }
4065 $parameters[$name] = $value;
4066
4067 if ($doubleQuoted || $singleQuoted || $escaped) throw new Error('Syntax error in parameterList: "' + parameterString + '"');
4068
4069 return $parameters;
4070 };
4071
4072 var unescape = function(string) {
4073 return string.replace(/\\n/g, "\n").replace(/\\\\/g, '\\').replace(/\\'/g, "'");
4074 };
4075
4076 template = template.replace(/\{(\$[^\}]+?)\}/g, function(_, content) {
4077 content = unescape(content.replace(/\$([^.\[\s]+)/g, "(v['$1'])"));
4078
4079 return "' + WCF.String.escapeHTML(" + content + ") + '";
4080 })
4081 // Numeric Variable
4082 .replace(/\{#(\$[^\}]+?)\}/g, function(_, content) {
4083 content = unescape(content.replace(/\$([^.\[\s]+)/g, "(v['$1'])"));
4084
4085 return "' + WCF.String.formatNumeric(" + content + ") + '";
4086 })
4087 // Variable without escaping
4088 .replace(/\{@(\$[^\}]+?)\}/g, function(_, content) {
4089 content = unescape(content.replace(/\$([^.\[\s]+)/g, "(v['$1'])"));
4090
4091 return "' + " + content + " + '";
4092 })
4093 // {lang}foo{/lang}
4094 .replace(/{lang}(.+?){\/lang}/g, function(_, content) {
4095 return "' + WCF.Language.get('" + unescape(content) + "') + '";
4096 })
4097 // {if}
4098 .replace(/\{if (.+?)\}/g, function(_, content) {
4099 content = unescape(content.replace(/\$([^.\[\s]+)/g, "(v['$1'])"));
4100
4101 return "';\n" +
4102 "if (" + content + ") {\n" +
4103 " $output += '";
4104 })
4105 // {elseif}
4106 .replace(/\{else ?if (.+?)\}/g, function(_, content) {
4107 content = unescape(content.replace(/\$([^.\[\s]+)/g, "(v['$1'])"));
4108
4109 return "';\n" +
4110 "}\n" +
4111 "else if (" + content + ") {\n" +
4112 " $output += '";
4113 })
4114 // {implode}
4115 .replace(/\{implode (.+?)\}/g, function(_, content) {
4116 $tagID++;
4117
4118 content = content.replace(/\\\\/g, '\\').replace(/\\'/g, "'");
4119 var $parameters = parseParameterList(content);
4120
4121 if (typeof $parameters['from'] === 'undefined') throw new Error('Missing from attribute in implode-tag');
4122 if (typeof $parameters['item'] === 'undefined') throw new Error('Missing item attribute in implode-tag');
4123 if (typeof $parameters['glue'] === 'undefined') $parameters['glue'] = "', '";
4124
4125 $parameters['from'] = $parameters['from'].replace(/\$([^.\[\s]+)/g, "(v.$1)");
4126
4127 return "';\n"+
4128 "var $implode_" + $tagID + " = false;\n" +
4129 "for ($implodeKey_" + $tagID + " in " + $parameters['from'] + ") {\n" +
4130 " v[" + $parameters['item'] + "] = " + $parameters['from'] + "[$implodeKey_" + $tagID + "];\n" +
4131 (typeof $parameters['key'] !== 'undefined' ? " v[" + $parameters['key'] + "] = $implodeKey_" + $tagID + ";\n" : "") +
4132 " if ($implode_" + $tagID + ") $output += " + $parameters['glue'] + ";\n" +
4133 " $implode_" + $tagID + " = true;\n" +
4134 " $output += '";
4135 })
4136 // {foreach}
4137 .replace(/\{foreach (.+?)\}/g, function(_, content) {
4138 $tagID++;
4139
4140 content = content.replace(/\\\\/g, '\\').replace(/\\'/g, "'");
4141 var $parameters = parseParameterList(content);
4142
4143 if (typeof $parameters['from'] === 'undefined') throw new Error('Missing from attribute in foreach-tag');
4144 if (typeof $parameters['item'] === 'undefined') throw new Error('Missing item attribute in foreach-tag');
4145 $parameters['from'] = $parameters['from'].replace(/\$([^.\[\s]+)/g, "(v.$1)");
4146
4147 return "';\n" +
4148 "$foreach_"+$tagID+" = false;\n" +
4149 "for ($foreachKey_" + $tagID + " in " + $parameters['from'] + ") {\n" +
4150 " $foreach_"+$tagID+" = true;\n" +
4151 " break;\n" +
4152 "}\n" +
4153 "if ($foreach_"+$tagID+") {\n" +
4154 " for ($foreachKey_" + $tagID + " in " + $parameters['from'] + ") {\n" +
4155 " v[" + $parameters['item'] + "] = " + $parameters['from'] + "[$foreachKey_" + $tagID + "];\n" +
4156 (typeof $parameters['key'] !== 'undefined' ? " v[" + $parameters['key'] + "] = $foreachKey_" + $tagID + ";\n" : "") +
4157 " $output += '";
4158 })
4159 // {foreachelse}
4160 .replace(/\{foreachelse\}/g,
4161 "';\n" +
4162 " }\n" +
4163 "}\n" +
4164 "else {\n" +
4165 " {\n" +
4166 " $output += '"
4167 )
4168 // {/foreach}
4169 .replace(/\{\/foreach\}/g,
4170 "';\n" +
4171 " }\n" +
4172 "}\n" +
4173 "$output += '"
4174 )
4175 // {else}
4176 .replace(/\{else\}/g,
4177 "';\n" +
4178 "}\n" +
4179 "else {\n" +
4180 " $output += '"
4181 )
4182 // {/if} and {/implode}
4183 .replace(/\{\/(if|implode)\}/g,
4184 "';\n" +
4185 "}\n" +
4186 "$output += '"
4187 );
4188
4189 // call callback
4190 for (var key in WCF.Template.callbacks) {
4191 template = WCF.Template.callbacks[key](template);
4192 }
4193
4194 // insert delimiter tags
4195 template = template.replace('{ldelim}', '{').replace('{rdelim}', '}');
4196
4197 $literals.each(function(pair) {
4198 template = template.replace(pair.key, pair.value);
4199 });
4200
4201 template = "$output += '" + template + "';";
4202
4203 try {
4204 this.fetch = new Function("v", "if (typeof v != 'object') { v = {}; } v.__window = window; v.__wcf = window.WCF; var $output = ''; " + template + ' return $output;');
4205 }
4206 catch (e) {
4207 console.debug("var $output = ''; " + template + ' return $output;');
4208 throw e;
4209 }
4210 },
4211
4212 /**
4213 * Fetches the template with the given variables.
4214 *
4215 * @param v variables to insert
4216 * @return parsed template
4217 */
4218 fetch: function(v) {
4219 // this will be replaced in the init function
4220 }
4221 });
4222
4223 /**
4224 * Array of callbacks that will be called after parsing the included tags. Only applies to Templates compiled after the callback was added.
4225 *
4226 * @var array<Function>
4227 */
4228 WCF.Template.callbacks = [ ];
4229
4230 /**
4231 * Toggles options.
4232 *
4233 * @param string element
4234 * @param array showItems
4235 * @param array hideItems
4236 * @param function callback
4237 */
4238 WCF.ToggleOptions = Class.extend({
4239 /**
4240 * target item
4241 *
4242 * @var jQuery
4243 */
4244 _element: null,
4245
4246 /**
4247 * list of items to be shown
4248 *
4249 * @var array
4250 */
4251 _showItems: [],
4252
4253 /**
4254 * list of items to be hidden
4255 *
4256 * @var array
4257 */
4258 _hideItems: [],
4259
4260 /**
4261 * callback after options were toggled
4262 *
4263 * @var function
4264 */
4265 _callback: null,
4266
4267 /**
4268 * Initializes option toggle.
4269 *
4270 * @param string element
4271 * @param array showItems
4272 * @param array hideItems
4273 * @param function callback
4274 */
4275 init: function(element, showItems, hideItems, callback) {
4276 this._element = $('#' + element);
4277 this._showItems = showItems;
4278 this._hideItems = hideItems;
4279 if (callback !== undefined) {
4280 this._callback = callback;
4281 }
4282
4283 // bind event
4284 this._element.click($.proxy(this._toggle, this));
4285
4286 // execute toggle on init
4287 this._toggle();
4288 },
4289
4290 /**
4291 * Toggles items.
4292 */
4293 _toggle: function() {
4294 if (!this._element.prop('checked')) return;
4295
4296 for (var $i = 0, $length = this._showItems.length; $i < $length; $i++) {
4297 var $item = this._showItems[$i];
4298
4299 $('#' + $item).show();
4300 }
4301
4302 for (var $i = 0, $length = this._hideItems.length; $i < $length; $i++) {
4303 var $item = this._hideItems[$i];
4304
4305 $('#' + $item).hide();
4306 }
4307
4308 if (this._callback !== null) {
4309 this._callback();
4310 }
4311 }
4312 });
4313
4314 /**
4315 * Namespace for all kind of collapsible containers.
4316 */
4317 WCF.Collapsible = {};
4318
4319 /**
4320 * Simple implementation for collapsible content, neither does it
4321 * store its state nor does it allow AJAX callbacks to fetch content.
4322 */
4323 WCF.Collapsible.Simple = {
4324 /**
4325 * Initializes collapsibles.
4326 */
4327 init: function() {
4328 $('.jsCollapsible').each($.proxy(function(index, button) {
4329 this._initButton(button);
4330 }, this));
4331 },
4332
4333 /**
4334 * Binds an event listener on all buttons triggering the collapsible.
4335 *
4336 * @param object button
4337 */
4338 _initButton: function(button) {
4339 var $button = $(button);
4340 var $isOpen = $button.data('isOpen');
4341
4342 if (!$isOpen) {
4343 // hide container on init
4344 $('#' + $button.data('collapsibleContainer')).hide();
4345 }
4346
4347 $button.click($.proxy(this._toggle, this));
4348 },
4349
4350 /**
4351 * Toggles collapsible containers on click.
4352 *
4353 * @param object event
4354 */
4355 _toggle: function(event) {
4356 var $button = $(event.currentTarget);
4357 var $isOpen = $button.data('isOpen');
4358 var $target = $('#' + $.wcfEscapeID($button.data('collapsibleContainer')));
4359
4360 if ($isOpen) {
4361 $target.stop().wcfBlindOut('vertical', $.proxy(function() {
4362 this._toggleImage($button);
4363 }, this));
4364 $isOpen = false;
4365 }
4366 else {
4367 $target.stop().wcfBlindIn('vertical', $.proxy(function() {
4368 this._toggleImage($button);
4369 }, this));
4370 $isOpen = true;
4371 }
4372
4373 $button.data('isOpen', $isOpen);
4374
4375 // suppress event
4376 event.stopPropagation();
4377 return false;
4378 },
4379
4380 /**
4381 * Toggles image of target button.
4382 *
4383 * @param jQuery button
4384 */
4385 _toggleImage: function(button) {
4386 var $icon = button.find('span.icon');
4387 if (button.data('isOpen')) {
4388 $icon.removeClass('icon-chevron-right').addClass('icon-chevron-down');
4389 }
4390 else {
4391 $icon.removeClass('icon-chevron-down').addClass('icon-chevron-right');
4392 }
4393 }
4394 };
4395
4396 /**
4397 * Basic implementation for collapsible containers with AJAX support. Results for open
4398 * and closed state will be cached.
4399 *
4400 * @param string className
4401 */
4402 WCF.Collapsible.Remote = Class.extend({
4403 /**
4404 * class name
4405 * @var string
4406 */
4407 _className: '',
4408
4409 /**
4410 * list of active containers
4411 * @var object
4412 */
4413 _containers: {},
4414
4415 /**
4416 * container meta data
4417 * @var object
4418 */
4419 _containerData: {},
4420
4421 /**
4422 * action proxy
4423 * @var WCF.Action.Proxy
4424 */
4425 _proxy: null,
4426
4427 /**
4428 * Initializes the controller for collapsible containers with AJAX support.
4429 *
4430 * @param string className
4431 */
4432 init: function(className) {
4433 this._className = className;
4434 this._proxy = new WCF.Action.Proxy({
4435 success: $.proxy(this._success, this)
4436 });
4437
4438 // initialize each container
4439 this._init();
4440
4441 WCF.DOMNodeInsertedHandler.addCallback('WCF.Collapsible.Remote', $.proxy(this._init, this));
4442 },
4443
4444 /**
4445 * Initializes a collapsible container.
4446 *
4447 * @param string containerID
4448 */
4449 _init: function(containerID) {
4450 this._getContainers().each($.proxy(function(index, container) {
4451 var $container = $(container);
4452 var $containerID = $container.wcfIdentify();
4453
4454 if (this._containers[$containerID] === undefined) {
4455 this._containers[$containerID] = $container;
4456
4457 this._initContainer($containerID);
4458 }
4459 }, this));
4460 },
4461
4462 /**
4463 * Initializes a collapsible container.
4464 *
4465 * @param string containerID
4466 */
4467 _initContainer: function(containerID) {
4468 var $target = this._getTarget(containerID);
4469 var $buttonContainer = this._getButtonContainer(containerID);
4470 var $button = this._createButton(containerID, $buttonContainer);
4471
4472 // store container meta data
4473 this._containerData[containerID] = {
4474 button: $button,
4475 buttonContainer: $buttonContainer,
4476 isOpen: this._containers[containerID].data('isOpen'),
4477 target: $target
4478 };
4479
4480 // add 'jsCollapsed' CSS class
4481 if (!this._containers[containerID].data('isOpen')) {
4482 $('#' + containerID).addClass('jsCollapsed');
4483 }
4484 },
4485
4486 /**
4487 * Returns a collection of collapsible containers.
4488 *
4489 * @return jQuery
4490 */
4491 _getContainers: function() { },
4492
4493 /**
4494 * Returns the target element for current collapsible container.
4495 *
4496 * @param integer containerID
4497 * @return jQuery
4498 */
4499 _getTarget: function(containerID) { },
4500
4501 /**
4502 * Returns the button container for current collapsible container.
4503 *
4504 * @param integer containerID
4505 * @return jQuery
4506 */
4507 _getButtonContainer: function(containerID) { },
4508
4509 /**
4510 * Creates the toggle button.
4511 *
4512 * @param integer containerID
4513 * @param jQuery buttonContainer
4514 */
4515 _createButton: function(containerID, buttonContainer) {
4516 var $isOpen = this._containers[containerID].data('isOpen');
4517 var $button = $('<span class="collapsibleButton jsTooltip pointer icon icon16 icon-' + ($isOpen ? 'chevron-down' : 'chevron-right') + '" title="'+WCF.Language.get('wcf.global.button.collapsible')+'">').prependTo(buttonContainer);
4518 $button.data('containerID', containerID).click($.proxy(this._toggleContainer, this));
4519
4520 return $button;
4521 },
4522
4523 /**
4524 * Toggles a container.
4525 *
4526 * @param object event
4527 */
4528 _toggleContainer: function(event) {
4529 var $button = $(event.currentTarget);
4530 var $containerID = $button.data('containerID');
4531 var $isOpen = this._containerData[$containerID].isOpen;
4532 var $state = ($isOpen) ? 'open' : 'close';
4533 var $newState = ($isOpen) ? 'close' : 'open';
4534
4535 // fetch content state via AJAX
4536 this._proxy.setOption('data', {
4537 actionName: 'loadContainer',
4538 className: this._className,
4539 interfaceName: 'wcf\\data\\ILoadableContainerAction',
4540 objectIDs: [ this._getObjectID($containerID) ],
4541 parameters: $.extend(true, {
4542 containerID: $containerID,
4543 currentState: $state,
4544 newState: $newState
4545 }, this._getAdditionalParameters($containerID))
4546 });
4547 this._proxy.sendRequest();
4548
4549 // toogle 'jsCollapsed' CSS class
4550 $('#' + $containerID).toggleClass('jsCollapsed');
4551
4552 // set spinner for current button
4553 // this._exchangeIcon($button);
4554 },
4555
4556 /**
4557 * Exchanges button icon.
4558 *
4559 * @param jQuery button
4560 * @param string newIcon
4561 */
4562 _exchangeIcon: function(button, newIcon) {
4563 newIcon = newIcon || 'spinner';
4564 button.removeClass('icon-chevron-down icon-chevron-right icon-spinner').addClass('icon-' + newIcon);
4565 },
4566
4567 /**
4568 * Returns the object id for current container.
4569 *
4570 * @param integer containerID
4571 * @return integer
4572 */
4573 _getObjectID: function(containerID) {
4574 return $('#' + containerID).data('objectID');
4575 },
4576
4577 /**
4578 * Returns additional parameters.
4579 *
4580 * @param integer containerID
4581 * @return object
4582 */
4583 _getAdditionalParameters: function(containerID) {
4584 return {};
4585 },
4586
4587 /**
4588 * Updates container content.
4589 *
4590 * @param integer containerID
4591 * @param string newContent
4592 * @param string newState
4593 */
4594 _updateContent: function(containerID, newContent, newState) {
4595 this._containerData[containerID].target.html(newContent);
4596 },
4597
4598 /**
4599 * Sets content upon successfull AJAX request.
4600 *
4601 * @param object data
4602 * @param string textStatus
4603 * @param jQuery jqXHR
4604 */
4605 _success: function(data, textStatus, jqXHR) {
4606 // validate container id
4607 if (!data.returnValues.containerID) return;
4608 var $containerID = data.returnValues.containerID;
4609
4610 // check if container id is known
4611 if (!this._containers[$containerID]) return;
4612
4613 // update content storage
4614 this._containerData[$containerID].isOpen = (data.returnValues.isOpen) ? true : false;
4615 var $newState = (data.returnValues.isOpen) ? 'open' : 'close';
4616
4617 // update container content
4618 this._updateContent($containerID, $.trim(data.returnValues.content), $newState);
4619
4620 // update icon
4621 this._exchangeIcon(this._containerData[$containerID].button, (data.returnValues.isOpen ? 'chevron-down' : 'chevron-right'));
4622 }
4623 });
4624
4625 /**
4626 * Basic implementation for collapsible containers with AJAX support. Requires collapsible
4627 * content to be available in DOM already, if you want to load content on the fly use
4628 * WCF.Collapsible.Remote instead.
4629 */
4630 WCF.Collapsible.SimpleRemote = WCF.Collapsible.Remote.extend({
4631 /**
4632 * Initializes an AJAX-based collapsible handler.
4633 *
4634 * @param string className
4635 */
4636 init: function(className) {
4637 this._super(className);
4638
4639 // override settings for action proxy
4640 this._proxy = new WCF.Action.Proxy({
4641 showLoadingOverlay: false
4642 });
4643 },
4644
4645 /**
4646 * @see WCF.Collapsible.Remote._initContainer()
4647 */
4648 _initContainer: function(containerID) {
4649 this._super(containerID);
4650
4651 // hide container on init if applicable
4652 if (!this._containerData[containerID].isOpen) {
4653 this._containerData[containerID].target.hide();
4654 this._exchangeIcon(this._containerData[containerID].button, 'chevron-right');
4655 }
4656 },
4657
4658 /**
4659 * Toggles container visibility.
4660 *
4661 * @param object event
4662 */
4663 _toggleContainer: function(event) {
4664 var $button = $(event.currentTarget);
4665 var $containerID = $button.data('containerID');
4666 var $isOpen = this._containerData[$containerID].isOpen;
4667 var $currentState = ($isOpen) ? 'open' : 'close';
4668 var $newState = ($isOpen) ? 'close' : 'open';
4669
4670 this._proxy.setOption('data', {
4671 actionName: 'toggleContainer',
4672 className: this._className,
4673 interfaceName: 'wcf\\data\\IToggleContainerAction',
4674 objectIDs: [ this._getObjectID($containerID) ],
4675 parameters: $.extend(true, {
4676 containerID: $containerID,
4677 currentState: $currentState,
4678 newState: $newState
4679 }, this._getAdditionalParameters($containerID))
4680 });
4681 this._proxy.sendRequest();
4682
4683 // exchange icon
4684 this._exchangeIcon(this._containerData[$containerID].button, ($newState === 'open' ? 'chevron-down' : 'chevron-right'));
4685
4686 // toggle container
4687 if ($newState === 'open') {
4688 this._containerData[$containerID].target.show();
4689 }
4690 else {
4691 this._containerData[$containerID].target.hide();
4692 }
4693
4694 // toogle 'jsCollapsed' CSS class
4695 $('#' + $containerID).toggleClass('jsCollapsed');
4696
4697 // update container data
4698 this._containerData[$containerID].isOpen = ($newState === 'open' ? true : false);
4699 }
4700 });
4701
4702 /**
4703 * Provides collapsible sidebars with persistency support.
4704 */
4705 WCF.Collapsible.Sidebar = Class.extend({
4706 /**
4707 * trigger button object
4708 * @var jQuery
4709 */
4710 _button: null,
4711
4712 /**
4713 * trigger button height
4714 * @var integer
4715 */
4716 _buttonHeight: 0,
4717
4718 /**
4719 * sidebar state
4720 * @var boolean
4721 */
4722 _isOpen: false,
4723
4724 /**
4725 * main container object
4726 * @var jQuery
4727 */
4728 _mainContainer: null,
4729
4730 /**
4731 * action proxy
4732 * @var WCF.Action.Proxy
4733 */
4734 _proxy: null,
4735
4736 /**
4737 * sidebar object
4738 * @var jQuery
4739 */
4740 _sidebar: null,
4741
4742 /**
4743 * sidebar height
4744 * @var integer
4745 */
4746 _sidebarHeight: 0,
4747
4748 /**
4749 * sidebar identifier
4750 * @var string
4751 */
4752 _sidebarName: '',
4753
4754 /**
4755 * sidebar offset from document top
4756 * @var integer
4757 */
4758 _sidebarOffset: 0,
4759
4760 /**
4761 * user panel height
4762 * @var integer
4763 */
4764 _userPanelHeight: 0,
4765
4766 /**
4767 * Creates a new WCF.Collapsible.Sidebar object.
4768 */
4769 init: function() {
4770 this._sidebar = $('.sidebar:eq(0)');
4771 if (!this._sidebar.length) {
4772 console.debug("[WCF.Collapsible.Sidebar] Could not find sidebar, aborting.");
4773 return;
4774 }
4775
4776 this._isOpen = (this._sidebar.data('isOpen')) ? true : false;
4777 this._sidebarName = this._sidebar.data('sidebarName');
4778 this._mainContainer = $('#main');
4779 this._sidebarHeight = this._sidebar.height();
4780 this._sidebarOffset = this._sidebar.getOffsets('offset').top;
4781 this._userPanelHeight = $('#topMenu').outerHeight();
4782
4783 // add toggle button
4784 this._button = $('<a class="collapsibleButton jsTooltip" title="' + WCF.Language.get('wcf.global.button.collapsible') + '" />').prependTo(this._sidebar);
4785 this._button.wrap('<span />');
4786 this._button.click($.proxy(this._click, this));
4787 this._buttonHeight = this._button.outerHeight();
4788
4789 WCF.DOMNodeInsertedHandler.execute();
4790
4791 this._proxy = new WCF.Action.Proxy({
4792 showLoadingOverlay: false,
4793 url: 'index.php/AJAXInvoke/?t=' + SECURITY_TOKEN + SID_ARG_2ND
4794 });
4795
4796 $(document).scroll($.proxy(this._scroll, this)).resize($.proxy(this._scroll, this));
4797
4798 this._renderSidebar();
4799 this._scroll();
4800
4801 // fake resize event once transition has completed
4802 var $window = $(window);
4803 this._sidebar.on('webkitTransitionEnd transitionend msTransitionEnd oTransitionEnd', function() { $window.trigger('resize'); });
4804 },
4805
4806 /**
4807 * Handles clicks on the trigger button.
4808 */
4809 _click: function() {
4810 this._isOpen = (this._isOpen) ? false : true;
4811
4812 this._proxy.setOption('data', {
4813 actionName: 'toggle',
4814 className: 'wcf\\system\\user\\collapsible\\content\\UserCollapsibleSidebarHandler',
4815 isOpen: (this._isOpen ? 1 : 0),
4816 sidebarName: this._sidebarName
4817 });
4818 this._proxy.sendRequest();
4819
4820 this._renderSidebar();
4821 },
4822
4823 /**
4824 * Aligns the toggle button upon scroll or resize.
4825 */
4826 _scroll: function() {
4827 var $window = $(window);
4828 var $scrollOffset = $window.scrollTop();
4829
4830 // calculate top and bottom coordinates of visible sidebar
4831 var $topOffset = Math.max($scrollOffset - this._sidebarOffset, 0);
4832 var $bottomOffset = Math.min(this._mainContainer.height(), ($window.height() + $scrollOffset) - this._sidebarOffset);
4833
4834 var $buttonTop = 0;
4835 if ($bottomOffset === $topOffset) {
4836 // sidebar not within visible area
4837 $buttonTop = this._sidebarOffset + this._sidebarHeight;
4838 }
4839 else {
4840 $buttonTop = $topOffset + (($bottomOffset - $topOffset) / 2);
4841
4842 // if the user panel is above the sidebar, substract it's height
4843 var $overlap = Math.max(Math.min($topOffset - this._userPanelHeight, this._userPanelHeight), 0);
4844 if ($overlap > 0) {
4845 $buttonTop += ($overlap / 2);
4846 }
4847 }
4848
4849 // ensure the button does not exceed bottom boundaries
4850 if (($bottomOffset - $topOffset - this._userPanelHeight) < this._buttonHeight) {
4851 $buttonTop = $buttonTop - this._buttonHeight;
4852 }
4853 else {
4854 // exclude half button height
4855 $buttonTop = Math.max($buttonTop - (this._buttonHeight / 2), 0);
4856 }
4857
4858 this._button.css({ top: $buttonTop + 'px' });
4859
4860 },
4861
4862 /**
4863 * Renders the sidebar state.
4864 */
4865 _renderSidebar: function() {
4866 if (this._isOpen) {
4867 $('.sidebarOrientationLeft, .sidebarOrientationRight').removeClass('sidebarCollapsed');
4868 }
4869 else {
4870 $('.sidebarOrientationLeft, .sidebarOrientationRight').addClass('sidebarCollapsed');
4871 }
4872
4873 // update button position
4874 this._scroll();
4875
4876 // IE9 does not support transitions, fire resize event manually
4877 if ($.browser.msie && $.browser.version.indexOf('9') === 0) {
4878 $(window).trigger('resize');
4879 }
4880 }
4881 });
4882
4883 /**
4884 * Holds userdata of the current user
4885 */
4886 WCF.User = {
4887 /**
4888 * id of the active user
4889 * @var integer
4890 */
4891 userID: 0,
4892
4893 /**
4894 * name of the active user
4895 * @var string
4896 */
4897 username: '',
4898
4899 /**
4900 * Initializes userdata
4901 *
4902 * @param integer userID
4903 * @param string username
4904 */
4905 init: function(userID, username) {
4906 this.userID = userID;
4907 this.username = username;
4908 }
4909 };
4910
4911 /**
4912 * Namespace for effect-related functions.
4913 */
4914 WCF.Effect = {};
4915
4916 /**
4917 * Scrolls to a specific element offset, optionally handling menu height.
4918 */
4919 WCF.Effect.Scroll = Class.extend({
4920 /**
4921 * Scrolls to a specific element offset.
4922 *
4923 * @param jQuery element
4924 * @param boolean excludeMenuHeight
4925 * @param boolean disableAnimation
4926 * @return boolean
4927 */
4928 scrollTo: function(element, excludeMenuHeight, disableAnimation) {
4929 if (!element.length) {
4930 return true;
4931 }
4932
4933 var $elementOffset = element.getOffsets('offset').top;
4934 var $documentHeight = $(document).height();
4935 var $windowHeight = $(window).height();
4936
4937 // handles menu height
4938 /*if (excludeMenuHeight) {
4939 $elementOffset = Math.max($elementOffset - $('#topMenu').outerHeight(), 0);
4940 }*/
4941
4942 if ($elementOffset > $documentHeight - $windowHeight) {
4943 $elementOffset = $documentHeight - $windowHeight;
4944 if ($elementOffset < 0) {
4945 $elementOffset = 0;
4946 }
4947 }
4948
4949 if (disableAnimation === true) {
4950 $('html,body').scrollTop($elementOffset);
4951 }
4952 else {
4953 $('html,body').animate({ scrollTop: $elementOffset }, 400, function (x, t, b, c, d) {
4954 return -c * ( ( t = t / d - 1 ) * t * t * t - 1) + b;
4955 });
4956 }
4957
4958 return false;
4959 }
4960 });
4961
4962 /**
4963 * Creates a smooth scroll effect.
4964 */
4965 WCF.Effect.SmoothScroll = WCF.Effect.Scroll.extend({
4966 /**
4967 * Initializes effect.
4968 */
4969 init: function() {
4970 var self = this;
4971 $(document).on('click', 'a[href$=#top],a[href$=#bottom]', function() {
4972 var $target = $(this.hash);
4973 self.scrollTo($target, true);
4974
4975 return false;
4976 });
4977 }
4978 });
4979
4980 /**
4981 * Creates the balloon tool-tip.
4982 */
4983 WCF.Effect.BalloonTooltip = Class.extend({
4984 /**
4985 * initialization state
4986 * @var boolean
4987 */
4988 _didInit: false,
4989
4990 /**
4991 * tooltip element
4992 * @var jQuery
4993 */
4994 _tooltip: null,
4995
4996 /**
4997 * cache viewport dimensions
4998 * @var object
4999 */
5000 _viewportDimensions: { },
5001
5002 /**
5003 * Initializes tooltips.
5004 */
5005 init: function() {
5006 if (jQuery.browser.mobile) return;
5007
5008 if (!this._didInit) {
5009 // create empty div
5010 this._tooltip = $('<div id="balloonTooltip" class="balloonTooltip"><span id="balloonTooltipText"></span><span class="pointer"><span></span></span></div>').appendTo($('body')).hide();
5011
5012 // get viewport dimensions
5013 this._updateViewportDimensions();
5014
5015 // update viewport dimensions on resize
5016 $(window).resize($.proxy(this._updateViewportDimensions, this));
5017
5018 // observe DOM changes
5019 WCF.DOMNodeInsertedHandler.addCallback('WCF.Effect.BalloonTooltip', $.proxy(this.init, this));
5020
5021 this._didInit = true;
5022 }
5023
5024 // init elements
5025 $('.jsTooltip').each($.proxy(this._initTooltip, this));
5026 },
5027
5028 /**
5029 * Updates cached viewport dimensions.
5030 */
5031 _updateViewportDimensions: function() {
5032 this._viewportDimensions = $(document).getDimensions();
5033 },
5034
5035 /**
5036 * Initializes a tooltip element.
5037 *
5038 * @param integer index
5039 * @param object element
5040 */
5041 _initTooltip: function(index, element) {
5042 var $element = $(element);
5043
5044 if ($element.hasClass('jsTooltip')) {
5045 $element.removeClass('jsTooltip');
5046 var $title = $element.attr('title');
5047
5048 // ignore empty elements
5049 if ($title !== '') {
5050 $element.data('tooltip', $title);
5051 $element.removeAttr('title');
5052
5053 $element.hover(
5054 $.proxy(this._mouseEnterHandler, this),
5055 $.proxy(this._mouseLeaveHandler, this)
5056 );
5057 $element.click($.proxy(this._mouseLeaveHandler, this));
5058 }
5059 }
5060 },
5061
5062 /**
5063 * Shows tooltip on hover.
5064 *
5065 * @param object event
5066 */
5067 _mouseEnterHandler: function(event) {
5068 var $element = $(event.currentTarget);
5069
5070 var $title = $element.attr('title');
5071 if ($title && $title !== '') {
5072 $element.data('tooltip', $title);
5073 $element.removeAttr('title');
5074 }
5075
5076 // reset tooltip position
5077 this._tooltip.css({
5078 top: "0px",
5079 left: "0px"
5080 });
5081
5082 // empty tooltip, skip
5083 if (!$element.data('tooltip')) {
5084 this._tooltip.hide();
5085 return;
5086 }
5087
5088 // update text
5089 this._tooltip.children('span:eq(0)').text($element.data('tooltip'));
5090
5091 // get arrow
5092 var $arrow = this._tooltip.find('.pointer');
5093
5094 // get arrow width
5095 this._tooltip.show();
5096 var $arrowWidth = $arrow.outerWidth();
5097 this._tooltip.hide();
5098
5099 // calculate position
5100 var $elementOffsets = $element.getOffsets('offset');
5101 var $elementDimensions = $element.getDimensions('outer');
5102 var $tooltipDimensions = this._tooltip.getDimensions('outer');
5103 var $tooltipDimensionsInner = this._tooltip.getDimensions('inner');
5104
5105 var $elementCenter = $elementOffsets.left + Math.ceil($elementDimensions.width / 2);
5106 var $tooltipHalfWidth = Math.ceil($tooltipDimensions.width / 2);
5107
5108 // determine alignment
5109 var $alignment = 'center';
5110 if (($elementCenter - $tooltipHalfWidth) < 5) {
5111 $alignment = 'left';
5112 }
5113 else if ((this._viewportDimensions.width - 5) < ($elementCenter + $tooltipHalfWidth)) {
5114 $alignment = 'right';
5115 }
5116
5117 // calculate top offset
5118 if ($elementOffsets.top + $elementDimensions.height + $tooltipDimensions.height - $(document).scrollTop() < $(window).height()) {
5119 var $top = $elementOffsets.top + $elementDimensions.height + 7;
5120 this._tooltip.removeClass('inverse');
5121 $arrow.css('top', -5);
5122 }
5123 else {
5124 var $top = $elementOffsets.top - $tooltipDimensions.height - 7;
5125 this._tooltip.addClass('inverse');
5126 $arrow.css('top', $tooltipDimensions.height);
5127 }
5128
5129 // calculate left offset
5130 switch ($alignment) {
5131 case 'center':
5132 var $left = Math.round($elementOffsets.left - $tooltipHalfWidth + ($elementDimensions.width / 2));
5133
5134 $arrow.css({
5135 left: ($tooltipDimensionsInner.width / 2 - $arrowWidth / 2) + "px"
5136 });
5137 break;
5138
5139 case 'left':
5140 var $left = $elementOffsets.left;
5141
5142 $arrow.css({
5143 left: "5px"
5144 });
5145 break;
5146
5147 case 'right':
5148 var $left = $elementOffsets.left + $elementDimensions.width - $tooltipDimensions.width;
5149
5150 $arrow.css({
5151 left: ($tooltipDimensionsInner.width - $arrowWidth - 5) + "px"
5152 });
5153 break;
5154 }
5155
5156 // move tooltip
5157 this._tooltip.css({
5158 top: $top + "px",
5159 left: $left + "px"
5160 });
5161
5162 // show tooltip
5163 this._tooltip.wcfFadeIn();
5164 },
5165
5166 /**
5167 * Hides tooltip once cursor left the element.
5168 *
5169 * @param object event
5170 */
5171 _mouseLeaveHandler: function(event) {
5172 this._tooltip.stop().hide().css({
5173 opacity: 1
5174 });
5175 }
5176 });
5177
5178 /**
5179 * Handles clicks outside an overlay, hitting body-tag through bubbling.
5180 *
5181 * You should always remove callbacks before disposing the attached element,
5182 * preventing errors from blocking the iteration. Furthermore you should
5183 * always handle clicks on your overlay's container and return 'false' to
5184 * prevent bubbling.
5185 */
5186 WCF.CloseOverlayHandler = {
5187 /**
5188 * list of callbacks
5189 * @var WCF.Dictionary
5190 */
5191 _callbacks: new WCF.Dictionary(),
5192
5193 /**
5194 * indicates that overlay handler is listening to click events on body-tag
5195 * @var boolean
5196 */
5197 _isListening: false,
5198
5199 /**
5200 * Adds a new callback.
5201 *
5202 * @param string identifier
5203 * @param object callback
5204 */
5205 addCallback: function(identifier, callback) {
5206 this._bindListener();
5207
5208 if (this._callbacks.isset(identifier)) {
5209 console.debug("[WCF.CloseOverlayHandler] identifier '" + identifier + "' is already bound to a callback");
5210 return false;
5211 }
5212
5213 this._callbacks.add(identifier, callback);
5214 },
5215
5216 /**
5217 * Removes a callback from list.
5218 *
5219 * @param string identifier
5220 */
5221 removeCallback: function(identifier) {
5222 if (this._callbacks.isset(identifier)) {
5223 this._callbacks.remove(identifier);
5224 }
5225 },
5226
5227 /**
5228 * Binds click event handler.
5229 */
5230 _bindListener: function() {
5231 if (this._isListening) return;
5232
5233 $('body').click($.proxy(this._executeCallbacks, this));
5234
5235 this._isListening = true;
5236 },
5237
5238 /**
5239 * Executes callbacks on click.
5240 */
5241 _executeCallbacks: function(event) {
5242 this._callbacks.each(function(pair) {
5243 // execute callback
5244 pair.value();
5245 });
5246 }
5247 };
5248
5249 /**
5250 * Notifies objects once a DOM node was inserted.
5251 */
5252 WCF.DOMNodeInsertedHandler = {
5253 /**
5254 * list of callbacks
5255 * @var array<object>
5256 */
5257 _callbacks: [ ],
5258
5259 /**
5260 * prevent infinite loop if a callback manipulates DOM
5261 * @var boolean
5262 */
5263 _isExecuting: false,
5264
5265 /**
5266 * Adds a new callback.
5267 *
5268 * @param string identifier
5269 * @param object callback
5270 */
5271 addCallback: function(identifier, callback) {
5272 this._callbacks.push(callback);
5273 },
5274
5275 /**
5276 * Executes callbacks on click.
5277 */
5278 _executeCallbacks: function() {
5279 if (this._isExecuting) return;
5280
5281 // do not track events while executing callbacks
5282 this._isExecuting = true;
5283
5284 for (var $i = 0, $length = this._callbacks.length; $i < $length; $i++) {
5285 this._callbacks[$i]();
5286 }
5287
5288 // enable listener again
5289 this._isExecuting = false;
5290 },
5291
5292 /**
5293 * Executes all callbacks.
5294 */
5295 execute: function() {
5296 this._executeCallbacks();
5297 }
5298 };
5299
5300 /**
5301 * Notifies objects once a DOM node was removed.
5302 */
5303 WCF.DOMNodeRemovedHandler = {
5304 /**
5305 * list of callbacks
5306 * @var WCF.Dictionary
5307 */
5308 _callbacks: new WCF.Dictionary(),
5309
5310 /**
5311 * prevent infinite loop if a callback manipulates DOM
5312 * @var boolean
5313 */
5314 _isExecuting: false,
5315
5316 /**
5317 * indicates that overlay handler is listening to DOMNodeRemoved events on body-tag
5318 * @var boolean
5319 */
5320 _isListening: false,
5321
5322 /**
5323 * Adds a new callback.
5324 *
5325 * @param string identifier
5326 * @param object callback
5327 */
5328 addCallback: function(identifier, callback) {
5329 this._bindListener();
5330
5331 if (this._callbacks.isset(identifier)) {
5332 console.debug("[WCF.DOMNodeRemovedHandler] identifier '" + identifier + "' is already bound to a callback");
5333 return false;
5334 }
5335
5336 this._callbacks.add(identifier, callback);
5337 },
5338
5339 /**
5340 * Removes a callback from list.
5341 *
5342 * @param string identifier
5343 */
5344 removeCallback: function(identifier) {
5345 if (this._callbacks.isset(identifier)) {
5346 this._callbacks.remove(identifier);
5347 }
5348 },
5349
5350 /**
5351 * Binds click event handler.
5352 */
5353 _bindListener: function() {
5354 if (this._isListening) return;
5355
5356 $(document).bind('DOMNodeRemoved', $.proxy(this._executeCallbacks, this));
5357
5358 this._isListening = true;
5359 },
5360
5361 /**
5362 * Executes callbacks if a DOM node is removed.
5363 */
5364 _executeCallbacks: function(event) {
5365 if (this._isExecuting) return;
5366
5367 // do not track events while executing callbacks
5368 this._isExecuting = true;
5369
5370 this._callbacks.each(function(pair) {
5371 // execute callback
5372 pair.value(event);
5373 });
5374
5375 // enable listener again
5376 this._isExecuting = false;
5377 }
5378 };
5379
5380 WCF.PageVisibilityHandler = {
5381 /**
5382 * list of callbacks
5383 * @var WCF.Dictionary
5384 */
5385 _callbacks: new WCF.Dictionary(),
5386
5387 /**
5388 * indicates that event listeners are bound
5389 * @var boolean
5390 */
5391 _isListening: false,
5392
5393 /**
5394 * name of window's hidden property
5395 * @var string
5396 */
5397 _hiddenFieldName: '',
5398
5399 /**
5400 * Adds a new callback.
5401 *
5402 * @param string identifier
5403 * @param object callback
5404 */
5405 addCallback: function(identifier, callback) {
5406 this._bindListener();
5407
5408 if (this._callbacks.isset(identifier)) {
5409 console.debug("[WCF.PageVisibilityHandler] identifier '" + identifier + "' is already bound to a callback");
5410 return false;
5411 }
5412
5413 this._callbacks.add(identifier, callback);
5414 },
5415
5416 /**
5417 * Removes a callback from list.
5418 *
5419 * @param string identifier
5420 */
5421 removeCallback: function(identifier) {
5422 if (this._callbacks.isset(identifier)) {
5423 this._callbacks.remove(identifier);
5424 }
5425 },
5426
5427 /**
5428 * Binds click event handler.
5429 */
5430 _bindListener: function() {
5431 if (this._isListening) return;
5432
5433 var $eventName = null;
5434 if (typeof document.hidden !== "undefined") {
5435 this._hiddenFieldName = "hidden";
5436 $eventName = "visibilitychange";
5437 }
5438 else if (typeof document.mozHidden !== "undefined") {
5439 this._hiddenFieldName = "mozHidden";
5440 $eventName = "mozvisibilitychange";
5441 }
5442 else if (typeof document.msHidden !== "undefined") {
5443 this._hiddenFieldName = "msHidden";
5444 $eventName = "msvisibilitychange";
5445 }
5446 else if (typeof document.webkitHidden !== "undefined") {
5447 this._hiddenFieldName = "webkitHidden";
5448 $eventName = "webkitvisibilitychange";
5449 }
5450
5451 if ($eventName === null) {
5452 console.debug("[WCF.PageVisibilityHandler] This browser does not support the page visibility API.");
5453 }
5454 else {
5455 $(document).on($eventName, $.proxy(this._executeCallbacks, this));
5456 }
5457
5458 this._isListening = true;
5459 },
5460
5461 /**
5462 * Executes callbacks if page is hidden/visible again.
5463 */
5464 _executeCallbacks: function(event) {
5465 if (this._isExecuting) return;
5466
5467 // do not track events while executing callbacks
5468 this._isExecuting = true;
5469
5470 var $state = document[this._hiddenFieldName];
5471 this._callbacks.each(function(pair) {
5472 // execute callback
5473 pair.value($state);
5474 });
5475
5476 // enable listener again
5477 this._isExecuting = false;
5478 }
5479 };
5480
5481 /**
5482 * Namespace for table related classes.
5483 */
5484 WCF.Table = {};
5485
5486 /**
5487 * Handles empty tables which can be used in combination with WCF.Action.Proxy.
5488 */
5489 WCF.Table.EmptyTableHandler = Class.extend({
5490 /**
5491 * handler options
5492 * @var object
5493 */
5494 _options: {},
5495
5496 /**
5497 * class name of the relevant rows
5498 * @var string
5499 */
5500 _rowClassName: '',
5501
5502 /**
5503 * Initalizes a new WCF.Table.EmptyTableHandler object.
5504 *
5505 * @param jQuery tableContainer
5506 * @param string rowClassName
5507 * @param object options
5508 */
5509 init: function(tableContainer, rowClassName, options) {
5510 this._rowClassName = rowClassName;
5511 this._tableContainer = tableContainer;
5512
5513 this._options = $.extend(true, {
5514 emptyMessage: null,
5515 messageType: 'info',
5516 refreshPage: false,
5517 updatePageNumber: false
5518 }, options || { });
5519
5520 WCF.DOMNodeRemovedHandler.addCallback('WCF.Table.EmptyTableHandler.' + rowClassName, $.proxy(this._remove, this));
5521 },
5522
5523 /**
5524 * Handles the removal of a DOM node.
5525 */
5526 _remove: function(event) {
5527 var element = $(event.target);
5528
5529 // check if DOM element is relevant
5530 if (element.hasClass(this._rowClassName)) {
5531 var tbody = element.parents('tbody:eq(0)');
5532
5533 // check if table will be empty if DOM node is removed
5534 if (tbody.children('tr').length == 1) {
5535 if (this._options.emptyMessage) {
5536 // insert message
5537 this._tableContainer.replaceWith($('<p />').addClass(this._options.messageType).text(this._options.emptyMessage));
5538 }
5539 else if (this._options.refreshPage) {
5540 // refresh page
5541 if (this._options.updatePageNumber) {
5542 // calculate the new page number
5543 var pageNumberURLComponents = window.location.href.match(/(\?|&)pageNo=(\d+)/g);
5544 if (pageNumberURLComponents) {
5545 var currentPageNumber = pageNumberURLComponents[pageNumberURLComponents.length - 1].match(/\d+/g);
5546 if (this._options.updatePageNumber > 0) {
5547 currentPageNumber++;
5548 }
5549 else {
5550 currentPageNumber--;
5551 }
5552
5553 window.location = window.location.href.replace(pageNumberURLComponents[pageNumberURLComponents.length - 1], pageNumberURLComponents[pageNumberURLComponents.length - 1][0] + 'pageNo=' + currentPageNumber);
5554 }
5555 }
5556 else {
5557 window.location.reload();
5558 }
5559 }
5560 else {
5561 // simply remove the table container
5562 this._tableContainer.remove();
5563 }
5564 }
5565 }
5566 }
5567 });
5568
5569 /**
5570 * Namespace for search related classes.
5571 */
5572 WCF.Search = {};
5573
5574 /**
5575 * Performs a quick search.
5576 */
5577 WCF.Search.Base = Class.extend({
5578 /**
5579 * notification callback
5580 * @var object
5581 */
5582 _callback: null,
5583
5584 /**
5585 * class name
5586 * @var string
5587 */
5588 _className: '',
5589
5590 /**
5591 * comma seperated list
5592 * @var boolean
5593 */
5594 _commaSeperated: false,
5595
5596 /**
5597 * delay in miliseconds before a request is send to the server
5598 * @var integer
5599 */
5600 _delay: 0,
5601
5602 /**
5603 * list with values that are excluded from seaching
5604 * @var array
5605 */
5606 _excludedSearchValues: [],
5607
5608 /**
5609 * count of available results
5610 * @var integer
5611 */
5612 _itemCount: 0,
5613
5614 /**
5615 * item index, -1 if none is selected
5616 * @var integer
5617 */
5618 _itemIndex: -1,
5619
5620 /**
5621 * result list
5622 * @var jQuery
5623 */
5624 _list: null,
5625
5626 /**
5627 * old search string, used for comparison
5628 * @var array<string>
5629 */
5630 _oldSearchString: [ ],
5631
5632 /**
5633 * action proxy
5634 * @var WCF.Action.Proxy
5635 */
5636 _proxy: null,
5637
5638 /**
5639 * search input field
5640 * @var jQuery
5641 */
5642 _searchInput: null,
5643
5644 /**
5645 * minimum search input length, MUST be 1 or higher
5646 * @var integer
5647 */
5648 _triggerLength: 3,
5649
5650 /**
5651 * delay timer
5652 * @var WCF.PeriodicalExecuter
5653 */
5654 _timer: null,
5655
5656 /**
5657 * Initializes a new search.
5658 *
5659 * @param jQuery searchInput
5660 * @param object callback
5661 * @param array excludedSearchValues
5662 * @param boolean commaSeperated
5663 * @param boolean showLoadingOverlay
5664 */
5665 init: function(searchInput, callback, excludedSearchValues, commaSeperated, showLoadingOverlay) {
5666 if (callback !== null && callback !== undefined && !$.isFunction(callback)) {
5667 console.debug("[WCF.Search.Base] The given callback is invalid, aborting.");
5668 return;
5669 }
5670
5671 this._callback = (callback) ? callback : null;
5672 this._delay = 0;
5673 this._excludedSearchValues = [];
5674 if (excludedSearchValues) {
5675 this._excludedSearchValues = excludedSearchValues;
5676 }
5677
5678 this._searchInput = $(searchInput);
5679 if (!this._searchInput.length) {
5680 console.debug("[WCF.Search.Base] Selector '" + searchInput + "' for search input is invalid, aborting.");
5681 return;
5682 }
5683
5684 this._searchInput.keydown($.proxy(this._keyDown, this)).keyup($.proxy(this._keyUp, this)).wrap('<span class="dropdown" />');
5685
5686 if ($.browser.mozilla && $.browser.touch) {
5687 this._searchInput.on('input', $.proxy(this._keyUp, this));
5688 }
5689
5690 this._list = $('<ul class="dropdownMenu" />').insertAfter(this._searchInput);
5691 this._commaSeperated = (commaSeperated) ? true : false;
5692 this._oldSearchString = [ ];
5693
5694 this._itemCount = 0;
5695 this._itemIndex = -1;
5696
5697 this._proxy = new WCF.Action.Proxy({
5698 showLoadingOverlay: (showLoadingOverlay !== true ? false : true),
5699 success: $.proxy(this._success, this),
5700 autoAbortPrevious: true
5701 });
5702
5703 if (this._searchInput.is('input')) {
5704 this._searchInput.attr('autocomplete', 'off');
5705 }
5706
5707 this._searchInput.blur($.proxy(this._blur, this));
5708
5709 WCF.Dropdown.initDropdownFragment(this._searchInput.parent(), this._list);
5710 },
5711
5712 /**
5713 * Closes the dropdown after a short delay.
5714 */
5715 _blur: function() {
5716 var self = this;
5717 new WCF.PeriodicalExecuter(function(pe) {
5718 if (self._list.is(':visible')) {
5719 self._clearList(false);
5720 }
5721
5722 pe.stop();
5723 }, 250);
5724 },
5725
5726 /**
5727 * Blocks execution of 'Enter' event.
5728 *
5729 * @param object event
5730 */
5731 _keyDown: function(event) {
5732 if (event.which === $.ui.keyCode.ENTER) {
5733 var $dropdown = this._searchInput.parents('.dropdown');
5734
5735 if ($dropdown.data('disableAutoFocus')) {
5736 if (this._itemIndex !== -1) {
5737 event.preventDefault();
5738 }
5739 }
5740 else if ($dropdown.data('preventSubmit') || this._itemIndex !== -1) {
5741 event.preventDefault();
5742 }
5743 }
5744 },
5745
5746 /**
5747 * Performs a search upon key up.
5748 *
5749 * @param object event
5750 */
5751 _keyUp: function(event) {
5752 // handle arrow keys and return key
5753 switch (event.which) {
5754 case 37: // arrow-left
5755 case 39: // arrow-right
5756 return;
5757 break;
5758
5759 case 38: // arrow up
5760 this._selectPreviousItem();
5761 return;
5762 break;
5763
5764 case 40: // arrow down
5765 this._selectNextItem();
5766 return;
5767 break;
5768
5769 case 13: // return key
5770 return this._selectElement(event);
5771 break;
5772 }
5773
5774 var $content = this._getSearchString(event);
5775 if ($content === '') {
5776 this._clearList(true);
5777 }
5778 else if ($content.length >= this._triggerLength) {
5779 var $parameters = {
5780 data: {
5781 excludedSearchValues: this._excludedSearchValues,
5782 searchString: $content
5783 }
5784 };
5785
5786 if (this._delay) {
5787 if (this._timer !== null) {
5788 this._timer.stop();
5789 }
5790
5791 var self = this;
5792 this._timer = new WCF.PeriodicalExecuter(function() {
5793 self._queryServer($parameters);
5794
5795 self._timer.stop();
5796 self._timer = null;
5797 }, this._delay);
5798 }
5799 else {
5800 this._queryServer($parameters);
5801 }
5802 }
5803 else {
5804 // input below trigger length
5805 this._clearList(false);
5806 }
5807 },
5808
5809 /**
5810 * Queries the server.
5811 *
5812 * @param object parameters
5813 */
5814 _queryServer: function(parameters) {
5815 this._searchInput.parents('.searchBar').addClass('loading');
5816 this._proxy.setOption('data', {
5817 actionName: 'getSearchResultList',
5818 className: this._className,
5819 interfaceName: 'wcf\\data\\ISearchAction',
5820 parameters: this._getParameters(parameters)
5821 });
5822 this._proxy.sendRequest();
5823 },
5824
5825 /**
5826 * Sets query delay in miliseconds.
5827 *
5828 * @param integer delay
5829 */
5830 setDelay: function(delay) {
5831 this._delay = delay;
5832 },
5833
5834 /**
5835 * Selects the next item in list.
5836 */
5837 _selectNextItem: function() {
5838 if (this._itemCount === 0) {
5839 return;
5840 }
5841
5842 // remove previous marking
5843 this._itemIndex++;
5844 if (this._itemIndex === this._itemCount) {
5845 this._itemIndex = 0;
5846 }
5847
5848 this._highlightSelectedElement();
5849 },
5850
5851 /**
5852 * Selects the previous item in list.
5853 */
5854 _selectPreviousItem: function() {
5855 if (this._itemCount === 0) {
5856 return;
5857 }
5858
5859 this._itemIndex--;
5860 if (this._itemIndex === -1) {
5861 this._itemIndex = this._itemCount - 1;
5862 }
5863
5864 this._highlightSelectedElement();
5865 },
5866
5867 /**
5868 * Highlights the active item.
5869 */
5870 _highlightSelectedElement: function() {
5871 this._list.find('li').removeClass('dropdownNavigationItem');
5872 this._list.find('li:eq(' + this._itemIndex + ')').addClass('dropdownNavigationItem');
5873 },
5874
5875 /**
5876 * Selects the active item by pressing the return key.
5877 *
5878 * @param object event
5879 * @return boolean
5880 */
5881 _selectElement: function(event) {
5882 if (this._itemCount === 0) {
5883 return true;
5884 }
5885
5886 this._list.find('li.dropdownNavigationItem').trigger('click');
5887
5888 return false;
5889 },
5890
5891 /**
5892 * Returns search string.
5893 *
5894 * @return string
5895 */
5896 _getSearchString: function(event) {
5897 var $searchString = $.trim(this._searchInput.val());
5898 if (this._commaSeperated) {
5899 var $keyCode = event.keyCode || event.which;
5900 if ($keyCode == $.ui.keyCode.COMMA) {
5901 // ignore event if char is ','
5902 return '';
5903 }
5904
5905 var $current = $searchString.split(',');
5906 var $length = $current.length;
5907 for (var $i = 0; $i < $length; $i++) {
5908 // remove whitespaces at the beginning or end
5909 $current[$i] = $.trim($current[$i]);
5910 }
5911
5912 for (var $i = 0; $i < $length; $i++) {
5913 var $part = $current[$i];
5914
5915 if (this._oldSearchString[$i]) {
5916 // compare part
5917 if ($part != this._oldSearchString[$i]) {
5918 // current part was changed
5919 $searchString = $part;
5920 break;
5921 }
5922 }
5923 else {
5924 // new part was added
5925 $searchString = $part;
5926 break;
5927 }
5928 }
5929
5930 this._oldSearchString = $current;
5931 }
5932
5933 return $searchString;
5934 },
5935
5936 /**
5937 * Returns parameters for quick search.
5938 *
5939 * @param object parameters
5940 * @return object
5941 */
5942 _getParameters: function(parameters) {
5943 return parameters;
5944 },
5945
5946 /**
5947 * Evalutes search results.
5948 *
5949 * @param object data
5950 * @param string textStatus
5951 * @param jQuery jqXHR
5952 */
5953 _success: function(data, textStatus, jqXHR) {
5954 this._clearList(false);
5955 this._searchInput.parents('.searchBar').removeClass('loading');
5956
5957 if ($.getLength(data.returnValues)) {
5958 for (var $i in data.returnValues) {
5959 var $item = data.returnValues[$i];
5960
5961 this._createListItem($item);
5962 }
5963 }
5964 else if (!this._handleEmptyResult()) {
5965 return;
5966 }
5967
5968 WCF.CloseOverlayHandler.addCallback('WCF.Search.Base', $.proxy(function() { this._clearList(); }, this));
5969
5970 var $containerID = this._searchInput.parents('.dropdown').wcfIdentify();
5971 if (!WCF.Dropdown.getDropdownMenu($containerID).hasClass('dropdownOpen')) {
5972 WCF.Dropdown.toggleDropdown($containerID);
5973 }
5974
5975 // pre-select first item
5976 this._itemIndex = -1;
5977 if (!WCF.Dropdown.getDropdown($containerID).data('disableAutoFocus')) {
5978 this._selectNextItem();
5979 }
5980 },
5981
5982 /**
5983 * Handles empty result lists, should return false if dropdown should be hidden.
5984 *
5985 * @return boolean
5986 */
5987 _handleEmptyResult: function() {
5988 return false;
5989 },
5990
5991 /**
5992 * Creates a new list item.
5993 *
5994 * @param object item
5995 * @return jQuery
5996 */
5997 _createListItem: function(item) {
5998 var $listItem = $('<li><span>' + WCF.String.escapeHTML(item.label) + '</span></li>').appendTo(this._list);
5999 $listItem.data('objectID', item.objectID).data('label', item.label).click($.proxy(this._executeCallback, this));
6000
6001 this._itemCount++;
6002
6003 return $listItem;
6004 },
6005
6006 /**
6007 * Executes callback upon result click.
6008 *
6009 * @param object event
6010 */
6011 _executeCallback: function(event) {
6012 var $clearSearchInput = false;
6013 var $listItem = $(event.currentTarget);
6014 // notify callback
6015 if (this._commaSeperated) {
6016 // auto-complete current part
6017 var $result = $listItem.data('label');
6018 for (var $i = 0, $length = this._oldSearchString.length; $i < $length; $i++) {
6019 var $part = this._oldSearchString[$i];
6020 if ($result.toLowerCase().indexOf($part.toLowerCase()) === 0) {
6021 this._oldSearchString[$i] = $result;
6022 this._searchInput.val(this._oldSearchString.join(', '));
6023
6024 if ($.browser.webkit) {
6025 // chrome won't display the new value until the textarea is rendered again
6026 // this quick fix forces chrome to render it again, even though it changes nothing
6027 this._searchInput.css({ display: 'block' });
6028 }
6029
6030 // set focus on input field again
6031 var $position = this._searchInput.val().toLowerCase().indexOf($result.toLowerCase()) + $result.length;
6032 this._searchInput.focus().setCaret($position);
6033
6034 break;
6035 }
6036 }
6037 }
6038 else {
6039 if (this._callback === null) {
6040 this._searchInput.val($listItem.data('label'));
6041 }
6042 else {
6043 $clearSearchInput = (this._callback($listItem.data()) === true) ? true : false;
6044 }
6045 }
6046
6047 // close list and revert input
6048 this._clearList($clearSearchInput);
6049 },
6050
6051 /**
6052 * Closes the suggestion list and clears search input on demand.
6053 *
6054 * @param boolean clearSearchInput
6055 */
6056 _clearList: function(clearSearchInput) {
6057 if (clearSearchInput && !this._commaSeperated) {
6058 this._searchInput.val('');
6059 }
6060
6061 // close dropdown
6062 WCF.Dropdown.getDropdown(this._searchInput.parents('.dropdown').wcfIdentify()).removeClass('dropdownOpen');
6063 WCF.Dropdown.getDropdownMenu(this._searchInput.parents('.dropdown').wcfIdentify()).removeClass('dropdownOpen');
6064
6065 this._list.end().empty();
6066
6067 WCF.CloseOverlayHandler.removeCallback('WCF.Search.Base');
6068
6069 // reset item navigation
6070 this._itemCount = 0;
6071 this._itemIndex = -1;
6072 },
6073
6074 /**
6075 * Adds an excluded search value.
6076 *
6077 * @param string value
6078 */
6079 addExcludedSearchValue: function(value) {
6080 if (!WCF.inArray(value, this._excludedSearchValues)) {
6081 this._excludedSearchValues.push(value);
6082 }
6083 },
6084
6085 /**
6086 * Removes an excluded search value.
6087 *
6088 * @param string value
6089 */
6090 removeExcludedSearchValue: function(value) {
6091 var index = $.inArray(value, this._excludedSearchValues);
6092 if (index != -1) {
6093 this._excludedSearchValues.splice(index, 1);
6094 }
6095 }
6096 });
6097
6098 /**
6099 * Provides quick search for users and user groups.
6100 *
6101 * @see WCF.Search.Base
6102 */
6103 WCF.Search.User = WCF.Search.Base.extend({
6104 /**
6105 * @see WCF.Search.Base._className
6106 */
6107 _className: 'wcf\\data\\user\\UserAction',
6108
6109 /**
6110 * include user groups in search
6111 * @var boolean
6112 */
6113 _includeUserGroups: false,
6114
6115 /**
6116 * @see WCF.Search.Base.init()
6117 */
6118 init: function(searchInput, callback, includeUserGroups, excludedSearchValues, commaSeperated) {
6119 this._includeUserGroups = includeUserGroups;
6120
6121 this._super(searchInput, callback, excludedSearchValues, commaSeperated);
6122 },
6123
6124 /**
6125 * @see WCF.Search.Base._getParameters()
6126 */
6127 _getParameters: function(parameters) {
6128 parameters.data.includeUserGroups = this._includeUserGroups ? 1 : 0;
6129
6130 return parameters;
6131 },
6132
6133 /**
6134 * @see WCF.Search.Base._createListItem()
6135 */
6136 _createListItem: function(item) {
6137 var $listItem = this._super(item);
6138
6139 var $icon = null;
6140 if (item.icon) {
6141 $icon = $(item.icon);
6142 }
6143 else if (this._includeUserGroups && item.type === 'group') {
6144 $icon = $('<span class="icon icon16 icon-group" />');
6145 }
6146
6147 if ($icon) {
6148 var $label = $listItem.find('span').detach();
6149
6150 var $box16 = $('<div />').addClass('box16').appendTo($listItem);
6151
6152 $box16.append($icon);
6153 $box16.append($('<div />').append($label));
6154 }
6155
6156 // insert item type
6157 $listItem.data('type', item.type);
6158
6159 return $listItem;
6160 }
6161 });
6162
6163 /**
6164 * Namespace for system-related classes.
6165 */
6166 WCF.System = { };
6167
6168 /**
6169 * Namespace for dependency-related classes.
6170 */
6171 WCF.System.Dependency = { };
6172
6173 /**
6174 * JavaScript Dependency Manager.
6175 */
6176 WCF.System.Dependency.Manager = {
6177 /**
6178 * list of callbacks grouped by identifier
6179 * @var object
6180 */
6181 _callbacks: { },
6182
6183 /**
6184 * list of loaded identifiers
6185 * @var array<string>
6186 */
6187 _loaded: [ ],
6188
6189 /**
6190 * list of setup callbacks grouped by identifier
6191 * @var object
6192 */
6193 _setupCallbacks: { },
6194
6195 /**
6196 * Registers a callback for given identifier, will be executed after all setup
6197 * callbacks have been invoked.
6198 *
6199 * @param string identifier
6200 * @param object callback
6201 */
6202 register: function(identifier, callback) {
6203 if (!$.isFunction(callback)) {
6204 console.debug("[WCF.System.Dependency.Manager] Callback for identifier '" + identifier + "' is invalid, aborting.");
6205 return;
6206 }
6207
6208 // already loaded, invoke now
6209 if (WCF.inArray(identifier, this._loaded)) {
6210 setTimeout(function() {
6211 callback();
6212 }, 1);
6213 }
6214 else {
6215 if (!this._callbacks[identifier]) {
6216 this._callbacks[identifier] = [ ];
6217 }
6218
6219 this._callbacks[identifier].push(callback);
6220 }
6221 },
6222
6223 /**
6224 * Registers a setup callback for given identifier, will be invoked
6225 * prior to all other callbacks.
6226 *
6227 * @param string identifier
6228 * @param object callback
6229 */
6230 setup: function(identifier, callback) {
6231 if (!$.isFunction(callback)) {
6232 console.debug("[WCF.System.Dependency.Manager] Setup callback for identifier '" + identifier + "' is invalid, aborting.");
6233 return;
6234 }
6235
6236 if (!this._setupCallbacks[identifier]) {
6237 this._setupCallbacks[identifier] = [ ];
6238 }
6239
6240 this._setupCallbacks[identifier].push(callback);
6241 },
6242
6243 /**
6244 * Invokes all callbacks for given identifier and marks it as loaded.
6245 *
6246 * @param string identifier
6247 */
6248 invoke: function(identifier) {
6249 if (this._setupCallbacks[identifier]) {
6250 for (var $i = 0, $length = this._setupCallbacks[identifier].length; $i < $length; $i++) {
6251 this._setupCallbacks[identifier][$i]();
6252 }
6253
6254 delete this._setupCallbacks[identifier];
6255 }
6256
6257 this._loaded.push(identifier);
6258
6259 if (this._callbacks[identifier]) {
6260 for (var $i = 0, $length = this._callbacks[identifier].length; $i < $length; $i++) {
6261 this._callbacks[identifier][$i]();
6262 }
6263
6264 delete this._callbacks[identifier];
6265 }
6266 }
6267 };
6268
6269 /**
6270 * Provides flexible dropdowns for tab-based menus.
6271 */
6272 WCF.System.FlexibleMenu = {
6273 /**
6274 * list of containers
6275 * @var object<jQuery>
6276 */
6277 _containers: { },
6278
6279 /**
6280 * list of registered container ids
6281 * @var array<string>
6282 */
6283 _containerIDs: [ ],
6284
6285 /**
6286 * list of dropdowns
6287 * @var object<jQuery>
6288 */
6289 _dropdowns: { },
6290
6291 /**
6292 * list of dropdown menus
6293 * @var object<jQuery>
6294 */
6295 _dropdownMenus: { },
6296
6297 /**
6298 * list of hidden status for containers
6299 * @var object<boolean>
6300 */
6301 _hasHiddenItems: { },
6302
6303 /**
6304 * true if menus are currently rebuilt
6305 * @var boolean
6306 */
6307 _isWorking: false,
6308
6309 /**
6310 * list of tab menu items per container
6311 * @var object<jQuery>
6312 */
6313 _menuItems: { },
6314
6315 /**
6316 * Initializes the WCF.System.FlexibleMenu class.
6317 */
6318 init: function() {
6319 // register .mainMenu and .navigationHeader by default
6320 this.registerMenu('mainMenu');
6321 this.registerMenu($('.navigationHeader:eq(0)').wcfIdentify());
6322
6323 this._registerTabMenus();
6324
6325 $(window).resize($.proxy(this.rebuildAll, this));
6326 WCF.DOMNodeInsertedHandler.addCallback('WCF.System.FlexibleMenu', $.proxy(this._registerTabMenus, this));
6327 },
6328
6329 /**
6330 * Registers tab menus.
6331 */
6332 _registerTabMenus: function() {
6333 // register tab menus
6334 $('.tabMenuContainer:not(.jsFlexibleMenuEnabled)').each(function(index, tabMenuContainer) {
6335 var $navigation = $(tabMenuContainer).children('nav');
6336 if ($navigation.length && $navigation.find('> ul:eq(0) > li').length) {
6337 WCF.System.FlexibleMenu.registerMenu($navigation.wcfIdentify());
6338 }
6339 });
6340 },
6341
6342 /**
6343 * Registers a tab-based menu by id.
6344 *
6345 * Required DOM:
6346 * <container>
6347 * <ul style="white-space: nowrap">
6348 * <li>tab 1</li>
6349 * <li>tab 2</li>
6350 * ...
6351 * <li>tab n</li>
6352 * </ul>
6353 * </container>
6354 *
6355 * @param string containerID
6356 */
6357 registerMenu: function(containerID) {
6358 var $container = $('#' + containerID);
6359 if (!$container.length) {
6360 console.debug("[WCF.System.FlexibleMenu] Unable to find container identified by '" + containerID + "', aborting.");
6361 return;
6362 }
6363
6364 this._containerIDs.push(containerID);
6365 this._containers[containerID] = $container;
6366 this._menuItems[containerID] = $container.find('> ul:eq(0) > li');
6367 this._dropdowns[containerID] = $('<li class="dropdown"><a class="icon icon16 icon-list" /></li>').data('containerID', containerID).click($.proxy(this._click, this));
6368 this._dropdownMenus[containerID] = $('<ul class="dropdownMenu" />').appendTo(this._dropdowns[containerID]);
6369 this._hasHiddenItems[containerID] = false;
6370
6371 this.rebuild(containerID);
6372
6373 WCF.Dropdown.initDropdown(this._dropdowns[containerID].children('a'));
6374 },
6375
6376 /**
6377 * Rebuilds all registered containers.
6378 */
6379 rebuildAll: function() {
6380 if (this._isWorking) {
6381 return;
6382 }
6383
6384 this._isWorking = true;
6385
6386 for (var $i = 0, $length = this._containerIDs.length; $i < $length; $i++) {
6387 this.rebuild(this._containerIDs[$i]);
6388 }
6389
6390 this._isWorking = false;
6391 },
6392
6393 /**
6394 * Rebuilds a container, will be automatically invoked on window resize and registering.
6395 *
6396 * @param string containerID
6397 */
6398 rebuild: function(containerID) {
6399 if (!this._containers[containerID]) {
6400 console.debug("[WCF.System.FlexibleMenu] Cannot rebuild unknown container identified by '" + containerID + "'");
6401 return;
6402 }
6403
6404 var $changedItems = false;
6405 var $container = this._containers[containerID];
6406 var $currentWidth = 0;
6407
6408 // the current width is based upon all items without the dropdown
6409 var $menuItems = this._menuItems[containerID].filter(':visible');
6410 for (var $i = 0, $length = $menuItems.length; $i < $length; $i++) {
6411 $currentWidth += $($menuItems[$i]).outerWidth(true);
6412 }
6413
6414 // insert dropdown for calculation purposes
6415 if (!this._hasHiddenItems[containerID]) {
6416 this._dropdowns[containerID].appendTo($container.children('ul:eq(0)'));
6417 }
6418
6419 var $dropdownWidth = this._dropdowns[containerID].outerWidth(true);
6420
6421 // remove dropdown previously inserted
6422 if (!this._hasHiddenItems[containerID]) {
6423 this._dropdowns[containerID].detach();
6424 }
6425
6426 var $maximumWidth = $container.parent().innerWidth();
6427
6428 // substract padding from the parent element
6429 $maximumWidth -= parseInt($container.parent().css('padding-left').replace(/px$/, '')) + parseInt($container.parent().css('padding-right').replace(/px$/, ''));
6430
6431 // substract margins and paddings from the container itself
6432 $maximumWidth -= parseInt($container.css('margin-left').replace(/px$/, '')) + parseInt($container.css('margin-right').replace(/px$/, ''));
6433 $maximumWidth -= parseInt($container.css('padding-left').replace(/px$/, '')) + parseInt($container.css('padding-right').replace(/px$/, ''));
6434
6435 // substract paddings from the actual list
6436 $maximumWidth -= parseInt($container.children('ul:eq(0)').css('padding-left').replace(/px$/, '')) + parseInt($container.children('ul:eq(0)').css('padding-right').replace(/px$/, ''));
6437 if ($currentWidth > $maximumWidth || (this._hasHiddenItems[containerID] && ($currentWidth > $maximumWidth - $dropdownWidth))) {
6438 var $menuItems = $menuItems.filter(':not(.active):not(.ui-state-active):visible');
6439
6440 // substract dropdown width from maximum width
6441 $maximumWidth -= $dropdownWidth;
6442
6443 // hide items starting with the last in list (ignores active item)
6444 for (var $i = ($menuItems.length - 1); $i >= 0; $i--) {
6445 if ($currentWidth > $maximumWidth) {
6446 var $item = $($menuItems[$i]);
6447 $currentWidth -= $item.outerWidth(true);
6448 $item.hide();
6449
6450 $changedItems = true;
6451 this._hasHiddenItems[containerID] = true;
6452 }
6453 else {
6454 break;
6455 }
6456 }
6457
6458 if (this._hasHiddenItems[containerID]) {
6459 this._dropdowns[containerID].appendTo($container.children('ul:eq(0)'));
6460 }
6461 }
6462 else if (this._hasHiddenItems[containerID] && $currentWidth < $maximumWidth) {
6463 var $hiddenItems = this._menuItems[containerID].filter(':not(:visible)');
6464
6465 // substract dropdown width from maximum width unless it is the last item
6466 $maximumWidth -= $dropdownWidth;
6467
6468 // reverts items starting with the first hidden one
6469 for (var $i = 0, $length = $hiddenItems.length; $i < $length; $i++) {
6470 var $item = $($hiddenItems[$i]);
6471 $currentWidth += $item.outerWidth();
6472
6473 if ($i + 1 == $length) {
6474 $maximumWidth += $dropdownWidth;
6475 }
6476
6477 if ($currentWidth < $maximumWidth) {
6478 // enough space, show item
6479 $item.css('display', '');
6480 $changedItems = true;
6481 }
6482 else {
6483 break;
6484 }
6485 }
6486
6487 if ($changedItems) {
6488 this._hasHiddenItems[containerID] = (this._menuItems[containerID].filter(':not(:visible)').length > 0);
6489 if (!this._hasHiddenItems[containerID]) {
6490 this._dropdowns[containerID].detach();
6491 }
6492 }
6493 }
6494
6495 // build dropdown menu for hidden items
6496 if ($changedItems) {
6497 this._dropdownMenus[containerID].empty();
6498 this._menuItems[containerID].filter(':not(:visible)').each($.proxy(function(index, item) {
6499 $('<li>' + $(item).html() + '</li>').appendTo(this._dropdownMenus[containerID]);
6500 }, this));
6501 }
6502 }
6503 };
6504
6505 /**
6506 * Namespace for mobile device-related classes.
6507 */
6508 WCF.System.Mobile = { };
6509
6510 /**
6511 * Handles general navigation and UX on mobile devices.
6512 */
6513 WCF.System.Mobile.UX = {
6514 /**
6515 * true if mobile optimizations are enabled
6516 * @var boolean
6517 */
6518 _enabled: false,
6519
6520 /**
6521 * main container
6522 * @var jQuery
6523 */
6524 _main: null,
6525
6526 /**
6527 * sidebar container
6528 * @var jQuery
6529 */
6530 _sidebar: null,
6531
6532 /**
6533 * Initializes the WCF.System.Mobile.UX class.
6534 */
6535 init: function() {
6536 this._enabled = false;
6537 this._main = $('#main');
6538 this._sidebar = this._main.find('> div > div > .sidebar');
6539
6540 if ($.browser.touch) {
6541 $('html').addClass('touch');
6542 }
6543
6544 enquire.register('screen and (max-width: 800px)', {
6545 match: $.proxy(this._enable, this),
6546 unmatch: $.proxy(this._disable, this),
6547 setup: $.proxy(this._setup, this),
6548 deferSetup: true
6549 });
6550
6551 if ($.browser.msie && this._sidebar.width() > 305) {
6552 // sidebar is rarely broken on IE9/IE10
6553 this._sidebar.css('display', 'none').css('display', '');
6554 }
6555 },
6556
6557 /**
6558 * Initializes the mobile optimization once the media query matches.
6559 */
6560 _setup: function() {
6561 this._initSidebarToggleButtons();
6562 this._initSearchBar();
6563 this._initButtonGroupNavigation();
6564
6565 WCF.CloseOverlayHandler.addCallback('WCF.System.Mobile.UX', $.proxy(this._closeMenus, this));
6566 WCF.DOMNodeInsertedHandler.addCallback('WCF.System.Mobile.UX', $.proxy(this._initButtonGroupNavigation, this));
6567 },
6568
6569 /**
6570 * Enables the mobile optimization.
6571 */
6572 _enable: function() {
6573 this._enabled = true;
6574
6575 if ($.browser.msie) {
6576 this._sidebar.css('display', 'none').css('display', '');
6577 }
6578 },
6579
6580 /**
6581 * Disables the mobile optimization.
6582 */
6583 _disable: function() {
6584 this._enabled = false;
6585
6586 if ($.browser.msie) {
6587 this._sidebar.css('display', 'none').css('display', '');
6588 }
6589 },
6590
6591 /**
6592 * Initializes the sidebar toggle buttons.
6593 */
6594 _initSidebarToggleButtons: function() {
6595 var $sidebarLeft = this._main.hasClass('sidebarOrientationLeft');
6596 var $sidebarRight = this._main.hasClass('sidebarOrientationRight');
6597 if ($sidebarLeft || $sidebarRight) {
6598 // use icons if language item is empty/non-existant
6599 var $languageShowSidebar = 'wcf.global.sidebar.show' + ($sidebarLeft ? 'Left' : 'Right') + 'Sidebar';
6600 if ($languageShowSidebar === WCF.Language.get($languageShowSidebar) || WCF.Language.get($languageShowSidebar) === '') {
6601 $languageShowSidebar = '<span class="icon icon16 icon-double-angle-' + ($sidebarLeft ? 'left' : 'right') + '" />';
6602 }
6603
6604 var $languageHideSidebar = 'wcf.global.sidebar.hide' + ($sidebarLeft ? 'Left' : 'Right') + 'Sidebar';
6605 if ($languageHideSidebar === WCF.Language.get($languageHideSidebar) || WCF.Language.get($languageHideSidebar) === '') {
6606 $languageHideSidebar = '<span class="icon icon16 icon-double-angle-' + ($sidebarLeft ? 'right' : 'left') + '" />';
6607 }
6608
6609 // add toggle buttons
6610 var self = this;
6611 $('<span class="button small mobileSidebarToggleButton">' + $languageShowSidebar + '</span>').appendTo($('.content')).click(function() { self._main.addClass('mobileShowSidebar'); });
6612 $('<span class="button small mobileSidebarToggleButton">' + $languageHideSidebar + '</span>').appendTo($('.sidebar')).click(function() { self._main.removeClass('mobileShowSidebar'); });
6613 }
6614 },
6615
6616 /**
6617 * Initializes the search bar.
6618 */
6619 _initSearchBar: function() {
6620 var $searchBar = $('.searchBar:eq(0)');
6621
6622 var self = this;
6623 $searchBar.click(function() {
6624 if (self._enabled) {
6625 $searchBar.addClass('searchBarOpen');
6626 }
6627 });
6628
6629 this._main.click(function() { $searchBar.removeClass('searchBarOpen'); });
6630 },
6631
6632 /**
6633 * Initializes the button group lists, converting them into native dropdowns.
6634 */
6635 _initButtonGroupNavigation: function() {
6636 $('.buttonGroupNavigation:not(.jsMobileButtonGroupNavigation)').each(function(index, navigation) {
6637 var $navigation = $(navigation).addClass('jsMobileButtonGroupNavigation');
6638 var $button = $('<a class="dropdownLabel"><span class="icon icon24 icon-list" /></a>').prependTo($navigation);
6639
6640 $button.click(function() { $button.next().toggleClass('open'); return false; });
6641 });
6642 },
6643
6644 /**
6645 * Closes menus.
6646 */
6647 _closeMenus: function() {
6648 $('.jsMobileButtonGroupNavigation > ul.open').removeClass('open');
6649 }
6650 };
6651
6652 /**
6653 * System notification overlays.
6654 *
6655 * @param string message
6656 * @param string cssClassNames
6657 */
6658 WCF.System.Notification = Class.extend({
6659 /**
6660 * callback on notification close
6661 * @var object
6662 */
6663 _callback: null,
6664
6665 /**
6666 * CSS class names
6667 * @var string
6668 */
6669 _cssClassNames: '',
6670
6671 /**
6672 * notification message
6673 * @var string
6674 */
6675 _message: '',
6676
6677 /**
6678 * notification overlay
6679 * @var jQuery
6680 */
6681 _overlay: null,
6682
6683 /**
6684 * Creates a new system notification overlay.
6685 *
6686 * @param string message
6687 * @param string cssClassNames
6688 */
6689 init: function(message, cssClassNames) {
6690 this._cssClassNames = cssClassNames || 'success';
6691 this._message = message || WCF.Language.get('wcf.global.success');
6692 this._overlay = $('#systemNotification');
6693
6694 if (!this._overlay.length) {
6695 this._overlay = $('<div id="systemNotification"><p></p></div>').hide().appendTo(document.body);
6696 }
6697 },
6698
6699 /**
6700 * Shows the notification overlay.
6701 *
6702 * @param object callback
6703 * @param integer duration
6704 * @param string message
6705 * @param string cssClassName
6706 */
6707 show: function(callback, duration, message, cssClassNames) {
6708 duration = parseInt(duration);
6709 if (!duration) duration = 2000;
6710
6711 if (callback && $.isFunction(callback)) {
6712 this._callback = callback;
6713 }
6714
6715 this._overlay.children('p').html((message || this._message));
6716 this._overlay.children('p').removeClass().addClass((cssClassNames || this._cssClassNames));
6717
6718 // hide overlay after specified duration
6719 new WCF.PeriodicalExecuter($.proxy(this._hide, this), duration);
6720
6721 this._overlay.wcfFadeIn(undefined, 300);
6722 },
6723
6724 /**
6725 * Hides the notification overlay after executing the callback.
6726 *
6727 * @param WCF.PeriodicalExecuter pe
6728 */
6729 _hide: function(pe) {
6730 if (this._callback !== null) {
6731 this._callback();
6732 }
6733
6734 this._overlay.wcfFadeOut(undefined, 300);
6735
6736 pe.stop();
6737 }
6738 });
6739
6740 /**
6741 * Provides dialog-based confirmations.
6742 */
6743 WCF.System.Confirmation = {
6744 /**
6745 * notification callback
6746 * @var object
6747 */
6748 _callback: null,
6749
6750 /**
6751 * confirmation dialog
6752 * @var jQuery
6753 */
6754 _dialog: null,
6755
6756 /**
6757 * callback parameters
6758 * @var object
6759 */
6760 _parameters: null,
6761
6762 /**
6763 * dialog visibility
6764 * @var boolean
6765 */
6766 _visible: false,
6767
6768 /**
6769 * confirmation button
6770 * @var jQuery
6771 */
6772 _confirmationButton: null,
6773
6774 /**
6775 * Displays a confirmation dialog.
6776 *
6777 * @param string message
6778 * @param object callback
6779 * @param object parameters
6780 * @param jQuery template
6781 */
6782 show: function(message, callback, parameters, template) {
6783 if (this._visible) {
6784 console.debug('[WCF.System.Confirmation] Confirmation dialog is already open, refusing action.');
6785 return;
6786 }
6787
6788 if (!$.isFunction(callback)) {
6789 console.debug('[WCF.System.Confirmation] Given callback is invalid, aborting.');
6790 return;
6791 }
6792
6793 this._callback = callback;
6794 this._parameters = parameters;
6795
6796 var $render = true;
6797 if (this._dialog === null) {
6798 this._createDialog();
6799 $render = false;
6800 }
6801
6802 this._dialog.find('#wcfSystemConfirmationContent').empty().hide();
6803 if (template && template.length) {
6804 template.appendTo(this._dialog.find('#wcfSystemConfirmationContent').show());
6805 }
6806
6807 this._dialog.find('p').text(message);
6808 this._dialog.wcfDialog({
6809 onClose: $.proxy(this._close, this),
6810 onShow: $.proxy(this._show, this),
6811 title: WCF.Language.get('wcf.global.confirmation.title')
6812 });
6813 if ($render) {
6814 this._dialog.wcfDialog('render');
6815 }
6816
6817 this._confirmationButton.focus();
6818 this._visible = true;
6819 },
6820
6821 /**
6822 * Creates the confirmation dialog on first use.
6823 */
6824 _createDialog: function() {
6825 this._dialog = $('<div id="wcfSystemConfirmation" class="systemConfirmation"><p /><div id="wcfSystemConfirmationContent" /></div>').hide().appendTo(document.body);
6826 var $formButtons = $('<div class="formSubmit" />').appendTo(this._dialog);
6827
6828 this._confirmationButton = $('<button class="buttonPrimary">' + WCF.Language.get('wcf.global.confirmation.confirm') + '</button>').data('action', 'confirm').click($.proxy(this._click, this)).appendTo($formButtons);
6829 $('<button>' + WCF.Language.get('wcf.global.confirmation.cancel') + '</button>').data('action', 'cancel').click($.proxy(this._click, this)).appendTo($formButtons);
6830 },
6831
6832 /**
6833 * Handles button clicks.
6834 *
6835 * @param object event
6836 */
6837 _click: function(event) {
6838 this._notify($(event.currentTarget).data('action'));
6839 },
6840
6841 /**
6842 * Handles dialog being closed.
6843 */
6844 _close: function() {
6845 if (this._visible) {
6846 this._notify('cancel');
6847 }
6848 },
6849
6850 /**
6851 * Notifies callback upon user's decision.
6852 *
6853 * @param string action
6854 */
6855 _notify: function(action) {
6856 this._visible = false;
6857 this._dialog.wcfDialog('close');
6858
6859 this._callback(action, this._parameters);
6860 },
6861
6862 /**
6863 * Tries to set focus on confirm button.
6864 */
6865 _show: function() {
6866 this._dialog.find('button.buttonPrimary').blur().focus();
6867 }
6868 };
6869
6870 /**
6871 * Disables the ability to scroll the page.
6872 */
6873 WCF.System.DisableScrolling = {
6874 /**
6875 * number of times scrolling was disabled (nested calls)
6876 * @var integer
6877 */
6878 _depth: 0,
6879
6880 /**
6881 * old overflow-value of the body element
6882 * @var string
6883 */
6884 _oldOverflow: null,
6885
6886 /**
6887 * Disables scrolling.
6888 */
6889 disable: function () {
6890 // do not block scrolling on touch devices
6891 if ($.browser.touch) {
6892 return;
6893 }
6894
6895 if (this._depth === 0) {
6896 this._oldOverflow = $(document.body).css('overflow');
6897 $(document.body).css('overflow', 'hidden');
6898 }
6899
6900 this._depth++;
6901 },
6902
6903 /**
6904 * Enables scrolling again.
6905 * Must be called the same number of times disable() was called to enable scrolling.
6906 */
6907 enable: function () {
6908 if (this._depth === 0) return;
6909
6910 this._depth--;
6911
6912 if (this._depth === 0) {
6913 $(document.body).css('overflow', this._oldOverflow);
6914 }
6915 }
6916 };
6917
6918 /**
6919 * Provides the 'jump to page' overlay.
6920 */
6921 WCF.System.PageNavigation = {
6922 /**
6923 * submit button
6924 * @var jQuery
6925 */
6926 _button: null,
6927
6928 /**
6929 * page No description
6930 * @var jQuery
6931 */
6932 _description: null,
6933
6934 /**
6935 * dialog overlay
6936 * @var jQuery
6937 */
6938 _dialog: null,
6939
6940 /**
6941 * active element id
6942 * @var string
6943 */
6944 _elementID: '',
6945
6946 /**
6947 * list of tracked navigation bars
6948 * @var object
6949 */
6950 _elements: { },
6951
6952 /**
6953 * page No input
6954 * @var jQuery
6955 */
6956 _pageNo: null,
6957
6958 /**
6959 * Initializes the 'jump to page' overlay for given selector.
6960 *
6961 * @param string selector
6962 * @param object callback
6963 */
6964 init: function(selector, callback) {
6965 var $elements = $(selector);
6966 if (!$elements.length) {
6967 return;
6968 }
6969
6970 callback = callback || null;
6971 if (callback !== null && !$.isFunction(callback)) {
6972 console.debug("[WCF.System.PageNavigation] Callback for selector '" + selector + "' is invalid, aborting.");
6973 return;
6974 }
6975
6976 this._initElements($elements, callback);
6977 },
6978
6979 /**
6980 * Initializes the 'jump to page' overlay for given elements.
6981 *
6982 * @param jQuery elements
6983 * @param object callback
6984 */
6985 _initElements: function(elements, callback) {
6986 var self = this;
6987 elements.each(function(index, element) {
6988 var $element = $(element);
6989 var $elementID = $element.wcfIdentify();
6990
6991 if (self._elements[$elementID] === undefined) {
6992 self._elements[$elementID] = $element;
6993 $element.find('li.jumpTo').data('elementID', $elementID).click($.proxy(self._click, self));
6994 }
6995 }).data('callback', callback);
6996 },
6997
6998 /**
6999 * Shows the 'jump to page' overlay.
7000 *
7001 * @param object event
7002 */
7003 _click: function(event) {
7004 this._elementID = $(event.currentTarget).data('elementID');
7005
7006 if (this._dialog === null) {
7007 this._dialog = $('<div id="pageNavigationOverlay" />').hide().appendTo(document.body);
7008
7009 var $fieldset = $('<fieldset><legend>' + WCF.Language.get('wcf.global.page.jumpTo') + '</legend></fieldset>').appendTo(this._dialog);
7010 $('<dl><dt><label for="jsPageNavigationPageNo">' + WCF.Language.get('wcf.global.page.jumpTo') + '</label></dt><dd></dd></dl>').appendTo($fieldset);
7011 this._pageNo = $('<input type="number" id="jsPageNavigationPageNo" value="1" min="1" max="1" class="tiny" />').keyup($.proxy(this._keyUp, this)).appendTo($fieldset.find('dd'));
7012 this._description = $('<small></small>').insertAfter(this._pageNo);
7013 var $formSubmit = $('<div class="formSubmit" />').appendTo(this._dialog);
7014 this._button = $('<button class="buttonPrimary">' + WCF.Language.get('wcf.global.button.submit') + '</button>').click($.proxy(this._submit, this)).appendTo($formSubmit);
7015 }
7016
7017 this._button.enable();
7018 this._description.html(WCF.Language.get('wcf.global.page.jumpTo.description').replace(/#pages#/, this._elements[this._elementID].data('pages')));
7019 this._pageNo.val(this._elements[this._elementID].data('pages')).attr('max', this._elements[this._elementID].data('pages'));
7020
7021 this._dialog.wcfDialog({
7022 'title': WCF.Language.get('wcf.global.page.pageNavigation')
7023 });
7024 },
7025
7026 /**
7027 * Validates the page No input.
7028 */
7029 _keyUp: function() {
7030 var $pageNo = parseInt(this._pageNo.val()) || 0;
7031 if ($pageNo < 1 || $pageNo > this._pageNo.attr('max')) {
7032 this._button.disable();
7033 }
7034 else {
7035 this._button.enable();
7036 }
7037 },
7038
7039 /**
7040 * Redirects to given page No.
7041 */
7042 _submit: function() {
7043 var $pageNavigation = this._elements[this._elementID];
7044 if ($pageNavigation.data('callback') === null) {
7045 var $redirectURL = $pageNavigation.data('link').replace(/pageNo=%d/, 'pageNo=' + this._pageNo.val());
7046 window.location = $redirectURL;
7047 }
7048 else {
7049 $pageNavigation.data('callback')(this._pageNo.val());
7050 this._dialog.wcfDialog('close');
7051 }
7052 }
7053 };
7054
7055 /**
7056 * Sends periodical requests to protect the session from expiring. By default
7057 * it will send a request 1 minute before it would expire.
7058 *
7059 * @param integer seconds
7060 */
7061 WCF.System.KeepAlive = Class.extend({
7062 /**
7063 * Initializes the WCF.System.KeepAlive class.
7064 *
7065 * @param integer seconds
7066 */
7067 init: function(seconds) {
7068 new WCF.PeriodicalExecuter(function(pe) {
7069 new WCF.Action.Proxy({
7070 autoSend: true,
7071 data: {
7072 actionName: 'keepAlive',
7073 className: 'wcf\\data\\session\\SessionAction'
7074 },
7075 failure: function() { pe.stop(); },
7076 showLoadingOverlay: false,
7077 suppressErrors: true
7078 });
7079 }, (seconds * 1000));
7080 }
7081 });
7082
7083 /**
7084 * Default implementation for inline editors.
7085 *
7086 * @param string elementSelector
7087 */
7088 WCF.InlineEditor = Class.extend({
7089 /**
7090 * list of registered callbacks
7091 * @var array<object>
7092 */
7093 _callbacks: [ ],
7094
7095 /**
7096 * list of dropdown selections
7097 * @var object
7098 */
7099 _dropdowns: { },
7100
7101 /**
7102 * list of container elements
7103 * @var object
7104 */
7105 _elements: { },
7106
7107 /**
7108 * notification object
7109 * @var WCF.System.Notification
7110 */
7111 _notification: null,
7112
7113 /**
7114 * list of known options
7115 * @var array<object>
7116 */
7117 _options: [ ],
7118
7119 /**
7120 * action proxy
7121 * @var WCF.Action.Proxy
7122 */
7123 _proxy: null,
7124
7125 /**
7126 * list of data to update upon success
7127 * @var array<object>
7128 */
7129 _updateData: [ ],
7130
7131 /**
7132 * Initializes a new inline editor.
7133 */
7134 init: function(elementSelector) {
7135 var $elements = $(elementSelector);
7136 if (!$elements.length) {
7137 return;
7138 }
7139
7140 this._setOptions();
7141 var $quickOption = '';
7142 for (var $i = 0, $length = this._options.length; $i < $length; $i++) {
7143 if (this._options[$i].isQuickOption) {
7144 $quickOption = this._options[$i].optionName;
7145 break;
7146 }
7147 }
7148
7149 var self = this;
7150 $elements.each(function(index, element) {
7151 var $element = $(element);
7152 var $elementID = $element.wcfIdentify();
7153
7154 // find trigger element
7155 var $trigger = self._getTriggerElement($element);
7156 if ($trigger === null || $trigger.length !== 1) {
7157 return;
7158 }
7159
7160 $trigger.click($.proxy(self._show, self)).data('elementID', $elementID);
7161 if ($quickOption) {
7162 // simulate click on target action
7163 $trigger.disableSelection().data('optionName', $quickOption).dblclick($.proxy(self._click, self));
7164 }
7165
7166 // store reference
7167 self._elements[$elementID] = $element;
7168 });
7169
7170 this._proxy = new WCF.Action.Proxy({
7171 success: $.proxy(this._success, this)
7172 });
7173
7174 WCF.CloseOverlayHandler.addCallback('WCF.InlineEditor', $.proxy(this._closeAll, this));
7175
7176 this._notification = new WCF.System.Notification(WCF.Language.get('wcf.global.success'), 'success');
7177 },
7178
7179 /**
7180 * Closes all inline editors.
7181 */
7182 _closeAll: function() {
7183 for (var $elementID in this._elements) {
7184 this._hide($elementID);
7185 }
7186 },
7187
7188 /**
7189 * Sets options for this inline editor.
7190 */
7191 _setOptions: function() {
7192 this._options = [ ];
7193 },
7194
7195 /**
7196 * Register an option callback for validation and execution.
7197 *
7198 * @param object callback
7199 */
7200 registerCallback: function(callback) {
7201 if ($.isFunction(callback)) {
7202 this._callbacks.push(callback);
7203 }
7204 },
7205
7206 /**
7207 * Returns the triggering element.
7208 *
7209 * @param jQuery element
7210 * @return jQuery
7211 */
7212 _getTriggerElement: function(element) {
7213 return null;
7214 },
7215
7216 /**
7217 * Shows a dropdown menu if options are available.
7218 *
7219 * @param object event
7220 */
7221 _show: function(event) {
7222 var $elementID = $(event.currentTarget).data('elementID');
7223
7224 // build dropdown
7225 var $trigger = null;
7226 if (!this._dropdowns[$elementID]) {
7227 $trigger = this._getTriggerElement(this._elements[$elementID]).addClass('dropdownToggle').wrap('<span class="dropdown" />');
7228 this._dropdowns[$elementID] = $('<ul class="dropdownMenu" />').insertAfter($trigger);
7229 }
7230 this._dropdowns[$elementID].empty();
7231
7232 // validate options
7233 var $hasOptions = false;
7234 var $lastElementType = '';
7235 for (var $i = 0, $length = this._options.length; $i < $length; $i++) {
7236 var $option = this._options[$i];
7237
7238 if ($option.optionName === 'divider') {
7239 if ($lastElementType !== '' && $lastElementType !== 'divider') {
7240 $('<li class="dropdownDivider" />').appendTo(this._dropdowns[$elementID]);
7241 $lastElementType = $option.optionName;
7242 }
7243 }
7244 else if (this._validate($elementID, $option.optionName) || this._validateCallbacks($elementID, $option.optionName)) {
7245 var $listItem = $('<li><span>' + $option.label + '</span></li>').appendTo(this._dropdowns[$elementID]);
7246 $listItem.data('elementID', $elementID).data('optionName', $option.optionName).data('isQuickOption', ($option.isQuickOption ? true : false)).click($.proxy(this._click, this));
7247
7248 $hasOptions = true;
7249 $lastElementType = $option.optionName;
7250 }
7251 }
7252
7253 if ($hasOptions) {
7254 // if last child is divider, remove it
7255 var $lastChild = this._dropdowns[$elementID].children().last();
7256 if ($lastChild.hasClass('dropdownDivider')) {
7257 $lastChild.remove();
7258 }
7259
7260 // check if only element is a quick option
7261 var $quickOption = null;
7262 var $count = 0;
7263 this._dropdowns[$elementID].children().each(function(index, child) {
7264 var $child = $(child);
7265 if (!$child.hasClass('dropdownDivider')) {
7266 if ($child.data('isQuickOption')) {
7267 $quickOption = $child;
7268 }
7269 else {
7270 $count++;
7271 }
7272 }
7273 });
7274
7275 if (!$count) {
7276 $quickOption.trigger('click');
7277
7278 if ($trigger !== null) {
7279 WCF.Dropdown.close($trigger.parents('.dropdown').wcfIdentify());
7280 }
7281
7282 return false;
7283 }
7284 }
7285
7286 if ($trigger !== null) {
7287 WCF.Dropdown.initDropdown($trigger, true);
7288 }
7289
7290 return false;
7291 },
7292
7293 /**
7294 * Validates an option.
7295 *
7296 * @param string elementID
7297 * @param string optionName
7298 * @returns boolean
7299 */
7300 _validate: function(elementID, optionName) {
7301 return false;
7302 },
7303
7304 /**
7305 * Validates an option provided by callbacks.
7306 *
7307 * @param string elementID
7308 * @param string optionName
7309 * @return boolean
7310 */
7311 _validateCallbacks: function(elementID, optionName) {
7312 var $length = this._callbacks.length;
7313 if ($length) {
7314 for (var $i = 0; $i < $length; $i++) {
7315 if (this._callbacks[$i].validate(this._elements[elementID], optionName)) {
7316 return true;
7317 }
7318 }
7319 }
7320
7321 return false;
7322 },
7323
7324 /**
7325 * Handles AJAX responses.
7326 *
7327 * @param object data
7328 * @param string textStatus
7329 * @param jQuery jqXHR
7330 */
7331 _success: function(data, textStatus, jqXHR) {
7332 var $length = this._updateData.length;
7333 if (!$length) {
7334 return;
7335 }
7336
7337 this._updateState(data);
7338
7339 this._updateData = [ ];
7340 },
7341
7342 /**
7343 * Update element states based upon update data.
7344 *
7345 * @param object data
7346 */
7347 _updateState: function(data) { },
7348
7349 /**
7350 * Handles clicks within dropdown.
7351 *
7352 * @param object event
7353 */
7354 _click: function(event) {
7355 var $listItem = $(event.currentTarget);
7356 var $elementID = $listItem.data('elementID');
7357 var $optionName = $listItem.data('optionName');
7358
7359 if (!this._execute($elementID, $optionName)) {
7360 this._executeCallback($elementID, $optionName);
7361 }
7362
7363 this._hide($elementID);
7364 },
7365
7366 /**
7367 * Executes actions associated with an option.
7368 *
7369 * @param string elementID
7370 * @param string optionName
7371 * @return boolean
7372 */
7373 _execute: function(elementID, optionName) {
7374 return false;
7375 },
7376
7377 /**
7378 * Executes actions associated with an option provided by callbacks.
7379 *
7380 * @param string elementID
7381 * @param string optionName
7382 * @return boolean
7383 */
7384 _executeCallback: function(elementID, optionName) {
7385 var $length = this._callbacks.length;
7386 if ($length) {
7387 for (var $i = 0; $i < $length; $i++) {
7388 if (this._callbacks[$i].execute(this._elements[elementID], optionName)) {
7389 return true;
7390 }
7391 }
7392 }
7393
7394 return false;
7395 },
7396
7397 /**
7398 * Hides a dropdown menu.
7399 *
7400 * @param string elementID
7401 */
7402 _hide: function(elementID) {
7403 if (this._dropdowns[elementID]) {
7404 this._dropdowns[elementID].empty().removeClass('dropdownOpen');
7405 }
7406 }
7407 });
7408
7409 /**
7410 * Default implementation for ajax file uploads
7411 *
7412 * @param jquery buttonSelector
7413 * @param jquery fileListSelector
7414 * @param string className
7415 * @param jquery options
7416 */
7417 WCF.Upload = Class.extend({
7418 /**
7419 * name of the upload field
7420 * @var string
7421 */
7422 _name: '__files[]',
7423
7424 /**
7425 * button selector
7426 * @var jQuery
7427 */
7428 _buttonSelector: null,
7429
7430 /**
7431 * file list selector
7432 * @var jQuery
7433 */
7434 _fileListSelector: null,
7435
7436 /**
7437 * upload file
7438 * @var jQuery
7439 */
7440 _fileUpload: null,
7441
7442 /**
7443 * class name
7444 * @var string
7445 */
7446 _className: '',
7447
7448 /**
7449 * iframe for IE<10 fallback
7450 * @var jQuery
7451 */
7452 _iframe: null,
7453
7454 /**
7455 * internal file id
7456 * @var integer
7457 */
7458 _internalFileID: 0,
7459
7460 /**
7461 * additional options
7462 * @var jQuery
7463 */
7464 _options: {},
7465
7466 /**
7467 * upload matrix
7468 * @var array
7469 */
7470 _uploadMatrix: [],
7471
7472 /**
7473 * true, if the active user's browser supports ajax file uploads
7474 * @var boolean
7475 */
7476 _supportsAJAXUpload: true,
7477
7478 /**
7479 * fallback overlay for stupid browsers
7480 * @var jquery
7481 */
7482 _overlay: null,
7483
7484 /**
7485 * Initializes a new upload handler.
7486 *
7487 * @param string buttonSelector
7488 * @param string fileListSelector
7489 * @param string className
7490 * @param object options
7491 */
7492 init: function(buttonSelector, fileListSelector, className, options) {
7493 this._buttonSelector = buttonSelector;
7494 this._fileListSelector = fileListSelector;
7495 this._className = className;
7496 this._internalFileID = 0;
7497 this._options = $.extend(true, {
7498 action: 'upload',
7499 multiple: false,
7500 url: 'index.php/AJAXUpload/?t=' + SECURITY_TOKEN + SID_ARG_2ND
7501 }, options || { });
7502
7503 // check for ajax upload support
7504 var $xhr = new XMLHttpRequest();
7505 this._supportsAJAXUpload = ($xhr && ('upload' in $xhr) && ('onprogress' in $xhr.upload));
7506
7507 // create upload button
7508 this._createButton();
7509 },
7510
7511 /**
7512 * Creates the upload button.
7513 */
7514 _createButton: function() {
7515 if (this._supportsAJAXUpload) {
7516 this._fileUpload = $('<input type="file" name="' + this._name + '" ' + (this._options.multiple ? 'multiple="true" ' : '') + '/>');
7517 this._fileUpload.change($.proxy(this._upload, this));
7518 var $button = $('<p class="button uploadButton"><span>' + WCF.Language.get('wcf.global.button.upload') + '</span></p>');
7519 $button.prepend(this._fileUpload);
7520 }
7521 else {
7522 var $button = $('<p class="button uploadFallbackButton"><span>' + WCF.Language.get('wcf.global.button.upload') + '</span></p>');
7523 $button.click($.proxy(this._showOverlay, this));
7524 }
7525
7526 this._insertButton($button);
7527 },
7528
7529 /**
7530 * Inserts the upload button.
7531 *
7532 * @param jQuery button
7533 */
7534 _insertButton: function(button) {
7535 this._buttonSelector.append(button);
7536 },
7537
7538 /**
7539 * Removes the upload button.
7540 */
7541 _removeButton: function() {
7542 var $selector = '.uploadButton';
7543 if (!this._supportsAJAXUpload) {
7544 $selector = '.uploadFallbackButton';
7545 }
7546
7547 this._buttonSelector.find($selector).remove();
7548 },
7549
7550 /**
7551 * Callback for file uploads.
7552 */
7553 _upload: function() {
7554 var $files = this._fileUpload.prop('files');
7555 if ($files.length) {
7556 var $fd = new FormData();
7557 var $uploadID = this._createUploadMatrix($files);
7558
7559 // no more files left, abort
7560 if (!this._uploadMatrix[$uploadID].length) {
7561 return;
7562 }
7563
7564 for (var $i = 0, $length = $files.length; $i < $length; $i++) {
7565 if (this._uploadMatrix[$uploadID][$i]) {
7566 var $internalFileID = this._uploadMatrix[$uploadID][$i].data('internalFileID');
7567 $fd.append('__files[' + $internalFileID + ']', $files[$i]);
7568 }
7569 }
7570
7571 $fd.append('actionName', this._options.action);
7572 $fd.append('className', this._className);
7573 var $additionalParameters = this._getParameters();
7574 for (var $name in $additionalParameters) {
7575 $fd.append('parameters[' + $name + ']', $additionalParameters[$name]);
7576 }
7577
7578 var self = this;
7579 $.ajax({
7580 type: 'POST',
7581 url: this._options.url,
7582 enctype: 'multipart/form-data',
7583 data: $fd,
7584 contentType: false,
7585 processData: false,
7586 success: function(data, textStatus, jqXHR) {
7587 self._success($uploadID, data);
7588 },
7589 error: $.proxy(this._error, this),
7590 xhr: function() {
7591 var $xhr = $.ajaxSettings.xhr();
7592 if ($xhr) {
7593 $xhr.upload.addEventListener('progress', function(event) {
7594 self._progress($uploadID, event);
7595 }, false);
7596 }
7597 return $xhr;
7598 }
7599 });
7600 }
7601 },
7602
7603 /**
7604 * Creates upload matrix for provided files.
7605 *
7606 * @param array<object> files
7607 * @return integer
7608 */
7609 _createUploadMatrix: function(files) {
7610 if (files.length) {
7611 var $uploadID = this._uploadMatrix.length;
7612 this._uploadMatrix[$uploadID] = [ ];
7613
7614 for (var $i = 0, $length = files.length; $i < $length; $i++) {
7615 var $file = files[$i];
7616 var $li = this._initFile($file);
7617
7618 if (!$li.hasClass('uploadFailed')) {
7619 $li.data('filename', $file.name).data('internalFileID', this._internalFileID++);
7620 this._uploadMatrix[$uploadID][$i] = $li;
7621 }
7622 }
7623
7624 return $uploadID;
7625 }
7626
7627 return null;
7628 },
7629
7630 /**
7631 * Callback for success event.
7632 *
7633 * @param integer uploadID
7634 * @param object data
7635 */
7636 _success: function(uploadID, data) { },
7637
7638 /**
7639 * Callback for error event.
7640 *
7641 * @param jQuery jqXHR
7642 * @param string textStatus
7643 * @param string errorThrown
7644 */
7645 _error: function(jqXHR, textStatus, errorThrown) { },
7646
7647 /**
7648 * Callback for progress event.
7649 *
7650 * @param integer uploadID
7651 * @param object event
7652 */
7653 _progress: function(uploadID, event) {
7654 var $percentComplete = Math.round(event.loaded * 100 / event.total);
7655
7656 for (var $i in this._uploadMatrix[uploadID]) {
7657 this._uploadMatrix[uploadID][$i].find('progress').attr('value', $percentComplete);
7658 }
7659 },
7660
7661 /**
7662 * Returns additional parameters.
7663 *
7664 * @return object
7665 */
7666 _getParameters: function() {
7667 return {};
7668 },
7669
7670 /**
7671 * Initializes list item for uploaded file.
7672 *
7673 * @return jQuery
7674 */
7675 _initFile: function(file) {
7676 return $('<li>' + file.name + ' (' + file.size + ')<progress max="100" /></li>').appendTo(this._fileListSelector);
7677 },
7678
7679 /**
7680 * Shows the fallback overlay (work in progress)
7681 */
7682 _showOverlay: function() {
7683 // create iframe
7684 if (this._iframe === null) {
7685 this._iframe = $('<iframe name="__fileUploadIFrame" />').hide().appendTo(document.body);
7686 }
7687
7688 // create overlay
7689 if (!this._overlay) {
7690 this._overlay = $('<div><form enctype="multipart/form-data" method="post" action="' + this._options.url + '" target="__fileUploadIFrame" /></div>').hide().appendTo(document.body);
7691
7692 var $form = this._overlay.find('form');
7693 $('<dl class="wide"><dd><input type="file" id="__fileUpload" name="' + this._name + '" ' + (this._options.multiple ? 'multiple="true" ' : '') + '/></dd></dl>').appendTo($form);
7694 $('<div class="formSubmit"><input type="submit" value="Upload" accesskey="s" /></div></form>').appendTo($form);
7695
7696 $('<input type="hidden" name="isFallback" value="1" />').appendTo($form);
7697 $('<input type="hidden" name="actionName" value="' + this._options.action + '" />').appendTo($form);
7698 $('<input type="hidden" name="className" value="' + this._className + '" />').appendTo($form);
7699 var $additionalParameters = this._getParameters();
7700 for (var $name in $additionalParameters) {
7701 $('<input type="hidden" name="' + $name + '" value="' + $additionalParameters[$name] + '" />').appendTo($form);
7702 }
7703
7704 $form.submit($.proxy(function() {
7705 var $file = {
7706 name: this._getFilename(),
7707 size: ''
7708 };
7709
7710 var $uploadID = this._createUploadMatrix([ $file ]);
7711 var self = this;
7712 this._iframe.data('loading', true).off('load').load(function() { self._evaluateResponse($uploadID); });
7713 this._overlay.wcfDialog('close');
7714 }, this));
7715 }
7716
7717 this._overlay.wcfDialog({
7718 title: WCF.Language.get('wcf.global.button.upload')
7719 });
7720 },
7721
7722 /**
7723 * Evaluates iframe response.
7724 *
7725 * @param integer uploadID
7726 */
7727 _evaluateResponse: function(uploadID) {
7728 var $returnValues = $.parseJSON(this._iframe.contents().find('pre').html());
7729 this._success(uploadID, $returnValues);
7730 },
7731
7732 /**
7733 * Returns name of selected file.
7734 *
7735 * @return string
7736 */
7737 _getFilename: function() {
7738 return $('#__fileUpload').val().split('\\').pop();
7739 }
7740 });
7741
7742 /**
7743 * Default implementation for parallel AJAX file uploads.
7744 */
7745 WCF.Upload.Parallel = WCF.Upload.extend({
7746 /**
7747 * @see WCF.Upload.init()
7748 */
7749 init: function(buttonSelector, fileListSelector, className, options) {
7750 // force multiple uploads
7751 options = $.extend(true, options || { }, {
7752 multiple: true
7753 });
7754
7755 this._super(buttonSelector, fileListSelector, className, options);
7756 },
7757
7758 /**
7759 * @see WCF.Upload._upload()
7760 */
7761 _upload: function() {
7762 var $files = this._fileUpload.prop('files');
7763 for (var $i = 0, $length = $files.length; $i < $length; $i++) {
7764 var $file = $files[$i];
7765 var $formData = new FormData();
7766 var $internalFileID = this._createUploadMatrix($file);
7767
7768 if (!this._uploadMatrix[$internalFileID].length) {
7769 continue;
7770 }
7771
7772 $formData.append('__files[' + $internalFileID + ']', $file);
7773 $formData.append('actionName', this._options.action);
7774 $formData.append('className', this._className);
7775 var $additionalParameters = this._getParameters();
7776 for (var $name in $additionalParameters) {
7777 $formData.append('parameters[' + $name + ']', $additionalParameters[$name]);
7778 }
7779
7780 this._sendRequest($internalFileID, $formData);
7781 }
7782 },
7783
7784 /**
7785 * Sends an AJAX request to upload a file.
7786 *
7787 * @param integer internalFileID
7788 * @param FormData formData
7789 */
7790 _sendRequest: function(internalFileID, formData) {
7791 var self = this;
7792 $.ajax({
7793 type: 'POST',
7794 url: this._options.url,
7795 enctype: 'multipart/form-data',
7796 data: formData,
7797 contentType: false,
7798 processData: false,
7799 success: function(data, textStatus, jqXHR) {
7800 self._success(internalFileID, data);
7801 },
7802 error: $.proxy(this._error, this),
7803 xhr: function() {
7804 var $xhr = $.ajaxSettings.xhr();
7805 if ($xhr) {
7806 $xhr.upload.addEventListener('progress', function(event) {
7807 self._progress(internalFileID, event);
7808 }, false);
7809 }
7810 return $xhr;
7811 }
7812 });
7813 },
7814
7815 /**
7816 * Creates upload matrix for provided file and returns its internal file id.
7817 *
7818 * @param object file
7819 * @return integer
7820 */
7821 _createUploadMatrix: function(file) {
7822 var $li = this._initFile(file);
7823 if (!$li.hasClass('uploadFailed')) {
7824 $li.data('filename', file.name).data('internalFileID', this._internalFileID);
7825 this._uploadMatrix[this._internalFileID++] = $li;
7826
7827 return this._internalFileID - 1;
7828 }
7829
7830 return null;
7831 },
7832
7833 /**
7834 * Callback for success event.
7835 *
7836 * @param integer internalFileID
7837 * @param object data
7838 */
7839 _success: function(internalFileID, data) { },
7840
7841 /**
7842 * Callback for progress event.
7843 *
7844 * @param integer internalFileID
7845 * @param object event
7846 */
7847 _progress: function(internalFileID, event) {
7848 var $percentComplete = Math.round(event.loaded * 100 / event.total);
7849
7850 this._uploadMatrix[internalFileID].find('progress').attr('value', $percentComplete);
7851 },
7852
7853 /**
7854 * @see WCF.Upload._showOverlay()
7855 */
7856 _showOverlay: function() {
7857 // create iframe
7858 if (this._iframe === null) {
7859 this._iframe = $('<iframe name="__fileUploadIFrame" />').hide().appendTo(document.body);
7860 }
7861
7862 // create overlay
7863 if (!this._overlay) {
7864 this._overlay = $('<div><form enctype="multipart/form-data" method="post" action="' + this._options.url + '" target="__fileUploadIFrame" /></div>').hide().appendTo(document.body);
7865
7866 var $form = this._overlay.find('form');
7867 $('<dl class="wide"><dd><input type="file" id="__fileUpload" name="' + this._name + '" ' + (this._options.multiple ? 'multiple="true" ' : '') + '/></dd></dl>').appendTo($form);
7868 $('<div class="formSubmit"><input type="submit" value="Upload" accesskey="s" /></div></form>').appendTo($form);
7869
7870 $('<input type="hidden" name="isFallback" value="1" />').appendTo($form);
7871 $('<input type="hidden" name="actionName" value="' + this._options.action + '" />').appendTo($form);
7872 $('<input type="hidden" name="className" value="' + this._className + '" />').appendTo($form);
7873 var $additionalParameters = this._getParameters();
7874 for (var $name in $additionalParameters) {
7875 $('<input type="hidden" name="' + $name + '" value="' + $additionalParameters[$name] + '" />').appendTo($form);
7876 }
7877
7878 $form.submit($.proxy(function() {
7879 var $file = {
7880 name: this._getFilename(),
7881 size: ''
7882 };
7883
7884 var $internalFileID = this._createUploadMatrix($file);
7885 var self = this;
7886 this._iframe.data('loading', true).off('load').load(function() { self._evaluateResponse($internalFileID); });
7887 this._overlay.wcfDialog('close');
7888 }, this));
7889 }
7890
7891 this._overlay.wcfDialog({
7892 title: WCF.Language.get('wcf.global.button.upload')
7893 });
7894 },
7895
7896 /**
7897 * Evaluates iframe response.
7898 *
7899 * @param integer internalFileID
7900 */
7901 _evaluateResponse: function(internalFileID) {
7902 var $returnValues = $.parseJSON(this._iframe.contents().find('pre').html());
7903 this._success(internalFileID, $returnValues);
7904 }
7905 });
7906
7907 /**
7908 * Namespace for sortables.
7909 */
7910 WCF.Sortable = { };
7911
7912 /**
7913 * Sortable implementation for lists.
7914 *
7915 * @param string containerID
7916 * @param string className
7917 * @param integer offset
7918 * @param object options
7919 */
7920 WCF.Sortable.List = Class.extend({
7921 /**
7922 * additional parameters for AJAX request
7923 * @var object
7924 */
7925 _additionalParameters: { },
7926
7927 /**
7928 * action class name
7929 * @var string
7930 */
7931 _className: '',
7932
7933 /**
7934 * container id
7935 * @var string
7936 */
7937 _containerID: '',
7938
7939 /**
7940 * container object
7941 * @var jQuery
7942 */
7943 _container: null,
7944
7945 /**
7946 * notification object
7947 * @var WCF.System.Notification
7948 */
7949 _notification: null,
7950
7951 /**
7952 * show order offset
7953 * @var integer
7954 */
7955 _offset: 0,
7956
7957 /**
7958 * list of options
7959 * @var object
7960 */
7961 _options: { },
7962
7963 /**
7964 * proxy object
7965 * @var WCF.Action.Proxy
7966 */
7967 _proxy: null,
7968
7969 /**
7970 * object structure
7971 * @var object
7972 */
7973 _structure: { },
7974
7975 /**
7976 * Creates a new sortable list.
7977 *
7978 * @param string containerID
7979 * @param string className
7980 * @param integer offset
7981 * @param object options
7982 * @param boolean isSimpleSorting
7983 * @param object additionalParameters
7984 */
7985 init: function(containerID, className, offset, options, isSimpleSorting, additionalParameters) {
7986 this._additionalParameters = additionalParameters || { };
7987 this._containerID = $.wcfEscapeID(containerID);
7988 this._container = $('#' + this._containerID);
7989 this._className = className;
7990 this._offset = (offset) ? offset : 0;
7991 this._proxy = new WCF.Action.Proxy({
7992 success: $.proxy(this._success, this)
7993 });
7994 this._structure = { };
7995
7996 // init sortable
7997 this._options = $.extend(true, {
7998 axis: 'y',
7999 connectWith: '#' + this._containerID + ' .sortableList',
8000 disableNesting: 'sortableNoNesting',
8001 doNotClear: true,
8002 errorClass: 'sortableInvalidTarget',
8003 forcePlaceholderSize: true,
8004 helper: 'clone',
8005 items: 'li:not(.sortableNoSorting)',
8006 opacity: .6,
8007 placeholder: 'sortablePlaceholder',
8008 tolerance: 'pointer',
8009 toleranceElement: '> span'
8010 }, options || { });
8011
8012 if (isSimpleSorting) {
8013 $('#' + this._containerID + ' .sortableList').sortable(this._options);
8014 }
8015 else {
8016 $('#' + this._containerID + ' > .sortableList').nestedSortable(this._options);
8017 }
8018
8019 if (this._className) {
8020 var $formSubmit = this._container.find('.formSubmit');
8021 if (!$formSubmit.length) {
8022 $formSubmit = this._container.next('.formSubmit');
8023 if (!$formSubmit.length) {
8024 console.debug("[WCF.Sortable.Simple] Unable to find form submit for saving, aborting.");
8025 return;
8026 }
8027 }
8028
8029 $formSubmit.children('button[data-type="submit"]').click($.proxy(this._submit, this));
8030 }
8031 },
8032
8033 /**
8034 * Saves object structure.
8035 */
8036 _submit: function() {
8037 // reset structure
8038 this._structure = { };
8039
8040 // build structure
8041 this._container.find('.sortableList').each($.proxy(function(index, list) {
8042 var $list = $(list);
8043 var $parentID = $list.data('objectID');
8044
8045 if ($parentID !== undefined) {
8046 $list.children(this._options.items).each($.proxy(function(index, listItem) {
8047 var $objectID = $(listItem).data('objectID');
8048
8049 if (!this._structure[$parentID]) {
8050 this._structure[$parentID] = [ ];
8051 }
8052
8053 this._structure[$parentID].push($objectID);
8054 }, this));
8055 }
8056 }, this));
8057
8058 // send request
8059 var $parameters = $.extend(true, {
8060 data: {
8061 offset: this._offset,
8062 structure: this._structure
8063 }
8064 }, this._additionalParameters);
8065
8066 this._proxy.setOption('data', {
8067 actionName: 'updatePosition',
8068 className: this._className,
8069 interfaceName: 'wcf\\data\\ISortableAction',
8070 parameters: $parameters
8071 });
8072 this._proxy.sendRequest();
8073 },
8074
8075 /**
8076 * Shows notification upon success.
8077 *
8078 * @param object data
8079 * @param string textStatus
8080 * @param jQuery jqXHR
8081 */
8082 _success: function(data, textStatus, jqXHR) {
8083 if (this._notification === null) {
8084 this._notification = new WCF.System.Notification(WCF.Language.get('wcf.global.success.edit'));
8085 }
8086
8087 this._notification.show();
8088 }
8089 });
8090
8091 WCF.Popover = Class.extend({
8092 /**
8093 * currently active element id
8094 * @var string
8095 */
8096 _activeElementID: '',
8097
8098 /**
8099 * cancels popover
8100 * @var boolean
8101 */
8102 _cancelPopover: false,
8103
8104 /**
8105 * element data
8106 * @var object
8107 */
8108 _data: { },
8109
8110 /**
8111 * default dimensions, should reflect the estimated size
8112 * @var object
8113 */
8114 _defaultDimensions: {
8115 height: 150,
8116 width: 450
8117 },
8118
8119 /**
8120 * default orientation, may be a combintion of left/right and bottom/top
8121 * @var object
8122 */
8123 _defaultOrientation: {
8124 x: 'right',
8125 y: 'top'
8126 },
8127
8128 /**
8129 * delay to show or hide popover, values in miliseconds
8130 * @var object
8131 */
8132 _delay: {
8133 show: 800,
8134 hide: 500
8135 },
8136
8137 /**
8138 * true, if an element is being hovered
8139 * @var boolean
8140 */
8141 _hoverElement: false,
8142
8143 /**
8144 * element id of element being hovered
8145 * @var string
8146 */
8147 _hoverElementID: '',
8148
8149 /**
8150 * true, if popover is being hovered
8151 * @var boolean
8152 */
8153 _hoverPopover: false,
8154
8155 /**
8156 * minimum margin (all directions) for popover
8157 * @var integer
8158 */
8159 _margin: 20,
8160
8161 /**
8162 * periodical executer once element or popover is no longer being hovered
8163 * @var WCF.PeriodicalExecuter
8164 */
8165 _peOut: null,
8166
8167 /**
8168 * periodical executer once an element is being hovered
8169 * @var WCF.PeriodicalExecuter
8170 */
8171 _peOverElement: null,
8172
8173 /**
8174 * popover object
8175 * @var jQuery
8176 */
8177 _popover: null,
8178
8179 /**
8180 * popover content
8181 * @var jQuery
8182 */
8183 _popoverContent: null,
8184
8185 /**
8186 * popover horizontal offset
8187 * @var integer
8188 */
8189 _popoverOffset: 10,
8190
8191 /**
8192 * element selector
8193 * @var string
8194 */
8195 _selector: '',
8196
8197 /**
8198 * Initializes a new WCF.Popover object.
8199 *
8200 * @param string selector
8201 */
8202 init: function(selector) {
8203 if ($.browser.mobile) return;
8204
8205 // assign default values
8206 this._activeElementID = '';
8207 this._cancelPopover = false;
8208 this._data = { };
8209 this._defaultDimensions = {
8210 height: 150,
8211 width: 450
8212 };
8213 this._defaultOrientation = {
8214 x: 'right',
8215 y: 'top'
8216 };
8217 this._delay = {
8218 show: 800,
8219 hide: 500
8220 };
8221 this._hoverElement = false;
8222 this._hoverElementID = '';
8223 this._hoverPopover = false;
8224 this._margin = 20;
8225 this._peOut = null;
8226 this._peOverElement = null;
8227 this._popoverOffset = 10;
8228 this._selector = selector;
8229
8230 this._popover = $('<div class="popover"><span class="icon icon48 icon-spinner"></span><div class="popoverContent"></div></div>').hide().appendTo(document.body);
8231 this._popoverContent = this._popover.children('.popoverContent:eq(0)');
8232 this._popover.hover($.proxy(this._overPopover, this), $.proxy(this._out, this));
8233
8234 this._initContainers();
8235 WCF.DOMNodeInsertedHandler.addCallback('WCF.Popover.'+selector, $.proxy(this._initContainers, this));
8236 },
8237
8238 /**
8239 * Initializes all element triggers.
8240 */
8241 _initContainers: function() {
8242 if ($.browser.mobile) return;
8243
8244 var $elements = $(this._selector);
8245 if (!$elements.length) {
8246 return;
8247 }
8248
8249 $elements.each($.proxy(function(index, element) {
8250 var $element = $(element);
8251 var $elementID = $element.wcfIdentify();
8252
8253 if (!this._data[$elementID]) {
8254 this._data[$elementID] = {
8255 'content': null,
8256 'isLoading': false
8257 };
8258
8259 $element.hover($.proxy(this._overElement, this), $.proxy(this._out, this));
8260
8261 if ($element.is('a') && $element.attr('href')) {
8262 $element.click($.proxy(this._cancel, this));
8263 }
8264 }
8265 }, this));
8266 },
8267
8268 /**
8269 * Cancels popovers if link is being clicked
8270 */
8271 _cancel: function(event) {
8272 this._cancelPopover = true;
8273 this._hide(true);
8274 },
8275
8276 /**
8277 * Triggered once an element is being hovered.
8278 *
8279 * @param object event
8280 */
8281 _overElement: function(event) {
8282 if (this._cancelPopover) {
8283 return;
8284 }
8285
8286 if (this._peOverElement !== null) {
8287 this._peOverElement.stop();
8288 }
8289
8290 var $elementID = $(event.currentTarget).wcfIdentify();
8291 this._hoverElementID = $elementID;
8292 this._peOverElement = new WCF.PeriodicalExecuter($.proxy(function(pe) {
8293 pe.stop();
8294
8295 // still above the same element
8296 if (this._hoverElementID === $elementID) {
8297 this._activeElementID = $elementID;
8298 this._prepare();
8299 }
8300 }, this), this._delay.show);
8301
8302 this._hoverElement = true;
8303 this._hoverPopover = false;
8304 },
8305
8306 /**
8307 * Prepares popover to be displayed.
8308 */
8309 _prepare: function() {
8310 if (this._cancelPopover) {
8311 return;
8312 }
8313
8314 if (this._peOut !== null) {
8315 this._peOut.stop();
8316 }
8317
8318 // hide and reset
8319 if (this._popover.is(':visible')) {
8320 this._hide(true);
8321 }
8322
8323 // insert html
8324 if (!this._data[this._activeElementID].loading && this._data[this._activeElementID].content) {
8325 this._popoverContent.html(this._data[this._activeElementID].content);
8326
8327 WCF.DOMNodeInsertedHandler.execute();
8328 }
8329 else {
8330 this._data[this._activeElementID].loading = true;
8331 }
8332
8333 // get dimensions
8334 var $dimensions = this._popover.show().getDimensions();
8335 if (this._data[this._activeElementID].loading) {
8336 $dimensions = {
8337 height: Math.max($dimensions.height, this._defaultDimensions.height),
8338 width: Math.max($dimensions.width, this._defaultDimensions.width)
8339 };
8340 }
8341 else {
8342 $dimensions = this._fixElementDimensions(this._popover, $dimensions);
8343 }
8344 this._popover.hide();
8345
8346 // get orientation
8347 var $orientation = this._getOrientation($dimensions.height, $dimensions.width);
8348 this._popover.css(this._getCSS($orientation.x, $orientation.y));
8349
8350 // apply orientation to popover
8351 this._popover.removeClass('bottom left right top').addClass($orientation.x).addClass($orientation.y);
8352
8353 this._show();
8354 },
8355
8356 /**
8357 * Displays the popover.
8358 */
8359 _show: function() {
8360 if (this._cancelPopover) {
8361 return;
8362 }
8363
8364 this._popover.stop().show().css({ opacity: 1 }).wcfFadeIn();
8365
8366 if (this._data[this._activeElementID].loading) {
8367 this._popover.children('span').show();
8368 this._loadContent();
8369 }
8370 else {
8371 this._popover.children('span').hide();
8372 this._popoverContent.css({ opacity: 1 });
8373 }
8374 },
8375
8376 /**
8377 * Loads content, should be overwritten by child classes.
8378 */
8379 _loadContent: function() { },
8380
8381 /**
8382 * Inserts content and animating transition.
8383 *
8384 * @param string elementID
8385 * @param boolean animate
8386 */
8387 _insertContent: function(elementID, content, animate) {
8388 this._data[elementID] = {
8389 content: content,
8390 loading: false
8391 };
8392
8393 // only update content if element id is active
8394 if (this._activeElementID === elementID) {
8395 if (animate) {
8396 // get current dimensions
8397 var $dimensions = this._popoverContent.getDimensions();
8398
8399 // insert new content
8400 this._popoverContent.css({
8401 height: 'auto',
8402 width: 'auto'
8403 });
8404 this._popoverContent.html(this._data[elementID].content);
8405 var $newDimensions = this._popoverContent.getDimensions();
8406
8407 // enforce current dimensions and remove HTML
8408 this._popoverContent.html('').css({
8409 height: $dimensions.height + 'px',
8410 width: $dimensions.width + 'px'
8411 });
8412
8413 // animate to new dimensons
8414 var self = this;
8415 this._popoverContent.animate({
8416 height: $newDimensions.height + 'px',
8417 width: $newDimensions.width + 'px'
8418 }, 300, function() {
8419 self._popover.children('span').hide();
8420 self._popoverContent.html(self._data[elementID].content).css({ opacity: 0 }).animate({ opacity: 1 }, 200);
8421
8422 WCF.DOMNodeInsertedHandler.execute();
8423 });
8424 }
8425 else {
8426 // insert new content
8427 this._popover.children('span').hide();
8428 this._popoverContent.html(this._data[elementID].content);
8429
8430 WCF.DOMNodeInsertedHandler.execute();
8431 }
8432 }
8433 },
8434
8435 /**
8436 * Hides the popover.
8437 */
8438 _hide: function(disableAnimation) {
8439 var self = this;
8440 this._popoverContent.stop();
8441 this._popover.stop();
8442
8443 if (disableAnimation) {
8444 self._popover.css({ opacity: 0 }).hide();
8445 self._popoverContent.empty().css({ height: 'auto', opacity: 0, width: 'auto' });
8446 }
8447 else {
8448 this._popover.wcfFadeOut(function() {
8449 self._popoverContent.empty().css({ height: 'auto', opacity: 0, width: 'auto' });
8450 self._popover.hide();
8451 });
8452 }
8453 },
8454
8455 /**
8456 * Triggered once popover is being hovered.
8457 */
8458 _overPopover: function() {
8459 if (this._peOut !== null) {
8460 this._peOut.stop();
8461 }
8462
8463 this._hoverElement = false;
8464 this._hoverPopover = true;
8465 },
8466
8467 /**
8468 * Triggered once element *or* popover is now longer hovered.
8469 */
8470 _out: function(event) {
8471 if (this._cancelPopover) {
8472 return;
8473 }
8474
8475 this._hoverElementID = '';
8476 this._hoverElement = false;
8477 this._hoverPopover = false;
8478
8479 this._peOut = new WCF.PeriodicalExecuter($.proxy(function(pe) {
8480 pe.stop();
8481
8482 // hide popover is neither element nor popover was hovered given time
8483 if (!this._hoverElement && !this._hoverPopover) {
8484 this._hide(false);
8485 }
8486 }, this), this._delay.hide);
8487 },
8488
8489 /**
8490 * Resolves popover orientation, tries to use default orientation first.
8491 *
8492 * @param integer height
8493 * @param integer width
8494 * @return object
8495 */
8496 _getOrientation: function(height, width) {
8497 // get offsets and dimensions
8498 var $element = $('#' + this._activeElementID);
8499 var $offsets = $element.getOffsets('offset');
8500 var $elementDimensions = $element.getDimensions();
8501 var $documentDimensions = $(document).getDimensions();
8502
8503 // try default orientation first
8504 var $orientationX = (this._defaultOrientation.x === 'left') ? 'left' : 'right';
8505 var $orientationY = (this._defaultOrientation.y === 'bottom') ? 'bottom' : 'top';
8506 var $result = this._evaluateOrientation($orientationX, $orientationY, $offsets, $elementDimensions, $documentDimensions, height, width);
8507
8508 if ($result.flawed) {
8509 // try flipping orientationX
8510 $orientationX = ($orientationX === 'left') ? 'right' : 'left';
8511 $result = this._evaluateOrientation($orientationX, $orientationY, $offsets, $elementDimensions, $documentDimensions, height, width);
8512
8513 if ($result.flawed) {
8514 // try flipping orientationY while maintaing original orientationX
8515 $orientationX = ($orientationX === 'right') ? 'left' : 'right';
8516 $orientationY = ($orientationY === 'bottom') ? 'top' : 'bottom';
8517 $result = this._evaluateOrientation($orientationX, $orientationY, $offsets, $elementDimensions, $documentDimensions, height, width);
8518
8519 if ($result.flawed) {
8520 // try flipping both orientationX and orientationY compared to default values
8521 $orientationX = ($orientationX === 'left') ? 'right' : 'left';
8522 $result = this._evaluateOrientation($orientationX, $orientationY, $offsets, $elementDimensions, $documentDimensions, height, width);
8523
8524 if ($result.flawed) {
8525 // fuck this shit, we will use the default orientation
8526 $orientationX = (this._defaultOrientationX === 'left') ? 'left' : 'right';
8527 $orientationY = (this._defaultOrientationY === 'bottom') ? 'bottom' : 'top';
8528 }
8529 }
8530 }
8531 }
8532
8533 return {
8534 x: $orientationX,
8535 y: $orientationY
8536 };
8537 },
8538
8539 /**
8540 * Evaluates if popover fits into given orientation.
8541 *
8542 * @param string orientationX
8543 * @param string orientationY
8544 * @param object offsets
8545 * @param object elementDimensions
8546 * @param object documentDimensions
8547 * @param integer height
8548 * @param integer width
8549 * @return object
8550 */
8551 _evaluateOrientation: function(orientationX, orientationY, offsets, elementDimensions, documentDimensions, height, width) {
8552 var $heightDifference = 0, $widthDifference = 0;
8553 switch (orientationX) {
8554 case 'left':
8555 $widthDifference = offsets.left - width;
8556 break;
8557
8558 case 'right':
8559 $widthDifference = documentDimensions.width - (offsets.left + width);
8560 break;
8561 }
8562
8563 switch (orientationY) {
8564 case 'bottom':
8565 $heightDifference = documentDimensions.height - (offsets.top + elementDimensions.height + this._popoverOffset + height);
8566 break;
8567
8568 case 'top':
8569 $heightDifference = offsets.top - (height - this._popoverOffset);
8570 break;
8571 }
8572
8573 // check if both difference are above margin
8574 var $flawed = false;
8575 if ($heightDifference < this._margin || $widthDifference < this._margin) {
8576 $flawed = true;
8577 }
8578
8579 return {
8580 flawed: $flawed,
8581 x: $widthDifference,
8582 y: $heightDifference
8583 };
8584 },
8585
8586 /**
8587 * Computes CSS for popover.
8588 *
8589 * @param string orientationX
8590 * @param string orientationY
8591 * @return object
8592 */
8593 _getCSS: function(orientationX, orientationY) {
8594 var $css = {
8595 bottom: 'auto',
8596 left: 'auto',
8597 right: 'auto',
8598 top: 'auto'
8599 };
8600
8601 var $element = $('#' + this._activeElementID);
8602 var $offsets = $element.getOffsets('offset');
8603 var $elementDimensions = this._fixElementDimensions($element, $element.getDimensions());
8604 var $windowDimensions = $(window).getDimensions();
8605
8606 switch (orientationX) {
8607 case 'left':
8608 $css.right = $windowDimensions.width - ($offsets.left + $elementDimensions.width);
8609 break;
8610
8611 case 'right':
8612 $css.left = $offsets.left;
8613 break;
8614 }
8615
8616 switch (orientationY) {
8617 case 'bottom':
8618 $css.top = $offsets.top + ($elementDimensions.height + this._popoverOffset);
8619 break;
8620
8621 case 'top':
8622 $css.bottom = $windowDimensions.height - ($offsets.top - this._popoverOffset);
8623 break;
8624 }
8625
8626 return $css;
8627 },
8628
8629 /**
8630 * Tries to fix dimensions if element is partially hidden (overflow: hidden).
8631 *
8632 * @param jQuery element
8633 * @param object dimensions
8634 * @return dimensions
8635 */
8636 _fixElementDimensions: function(element, dimensions) {
8637 var $parentDimensions = element.parent().getDimensions();
8638
8639 if ($parentDimensions.height < dimensions.height) {
8640 dimensions.height = $parentDimensions.height;
8641 }
8642
8643 if ($parentDimensions.width < dimensions.width) {
8644 dimensions.width = $parentDimensions.width;
8645 }
8646
8647 return dimensions;
8648 }
8649 });
8650
8651 /**
8652 * Provides an extensible item list with built-in search.
8653 *
8654 * @param string itemListSelector
8655 * @param string searchInputSelector
8656 */
8657 WCF.EditableItemList = Class.extend({
8658 /**
8659 * allows custom input not recognized by search to be added
8660 * @var boolean
8661 */
8662 _allowCustomInput: false,
8663
8664 /**
8665 * action class name
8666 * @var string
8667 */
8668 _className: '',
8669
8670 /**
8671 * internal data storage
8672 * @var mixed
8673 */
8674 _data: { },
8675
8676 /**
8677 * form container
8678 * @var jQuery
8679 */
8680 _form: null,
8681
8682 /**
8683 * item list container
8684 * @var jQuery
8685 */
8686 _itemList: null,
8687
8688 /**
8689 * current object id
8690 * @var integer
8691 */
8692 _objectID: 0,
8693
8694 /**
8695 * object type id
8696 * @var integer
8697 */
8698 _objectTypeID: 0,
8699
8700 /**
8701 * search controller
8702 * @var WCF.Search.Base
8703 */
8704 _search: null,
8705
8706 /**
8707 * search input element
8708 * @var jQuery
8709 */
8710 _searchInput: null,
8711
8712 /**
8713 * Creates a new WCF.EditableItemList object.
8714 *
8715 * @param string itemListSelector
8716 * @param string searchInputSelector
8717 */
8718 init: function(itemListSelector, searchInputSelector) {
8719 this._itemList = $(itemListSelector);
8720 this._searchInput = $(searchInputSelector);
8721 this._data = { };
8722
8723 if (!this._itemList.length || !this._searchInput.length) {
8724 console.debug("[WCF.EditableItemList] Item list and/or search input do not exist, aborting.");
8725 return;
8726 }
8727
8728 this._objectID = this._getObjectID();
8729 this._objectTypeID = this._getObjectTypeID();
8730
8731 // bind item listener
8732 this._itemList.find('.jsEditableItem').click($.proxy(this._click, this));
8733
8734 // create item list
8735 if (!this._itemList.children('ul').length) {
8736 $('<ul />').appendTo(this._itemList);
8737 }
8738 this._itemList = this._itemList.children('ul');
8739
8740 // bind form submit
8741 this._form = this._itemList.parents('form').submit($.proxy(this._submit, this));
8742
8743 if (this._allowCustomInput) {
8744 var self = this;
8745 this._searchInput.keydown($.proxy(this._keyDown, this)).on('paste', function() {
8746 setTimeout(function() { self._onPaste(); }, 100);
8747 });
8748 }
8749
8750 // block form submit through [ENTER]
8751 this._searchInput.parents('.dropdown').data('preventSubmit', true);
8752 },
8753
8754 /**
8755 * Handles the key down event.
8756 *
8757 * @param object event
8758 */
8759 _keyDown: function(event) {
8760 // 188 = [,]
8761 if (event === null || event.which === 188 || event.which === $.ui.keyCode.ENTER) {
8762 if (event !== null && event.which === $.ui.keyCode.ENTER && this._search) {
8763 if (this._search._itemIndex !== -1) {
8764 return false;
8765 }
8766 }
8767
8768 var $value = $.trim(this._searchInput.val());
8769
8770 // read everything left from caret position
8771 if (event && event.which === 188) {
8772 $value = $value.substring(0, this._searchInput.getCaret());
8773 }
8774
8775 if ($value === '') {
8776 return true;
8777 }
8778
8779 this.addItem({
8780 objectID: 0,
8781 label: $value
8782 });
8783
8784 // reset input
8785 if (event && event.which === 188) {
8786 this._searchInput.val($.trim(this._searchInput.val().substr(this._searchInput.getCaret())));
8787 }
8788 else {
8789 this._searchInput.val('');
8790 }
8791
8792 if (event !== null) {
8793 event.stopPropagation();
8794 }
8795
8796 return false;
8797 }
8798
8799 return true;
8800 },
8801
8802 /**
8803 * Handle paste event.
8804 */
8805 _onPaste: function() {
8806 // split content by comma
8807 var $value = $.trim(this._searchInput.val());
8808 $value = $value.split(',');
8809
8810 for (var $i = 0, $length = $value.length; $i < $length; $i++) {
8811 var $label = $.trim($value[$i]);
8812 if ($label === '') {
8813 continue;
8814 }
8815
8816 this.addItem({
8817 objectID: 0,
8818 label: $label
8819 });
8820 }
8821
8822 this._searchInput.val('');
8823 },
8824
8825 /**
8826 * Loads raw data and converts it into internal structure. Override this methods
8827 * in your derived classes.
8828 *
8829 * @param object data
8830 */
8831 load: function(data) { },
8832
8833 /**
8834 * Removes an item on click.
8835 *
8836 * @param object event
8837 * @return boolean
8838 */
8839 _click: function(event) {
8840 var $element = $(event.currentTarget);
8841 var $objectID = $element.data('objectID');
8842 var $label = $element.data('label');
8843
8844 if (this._search) {
8845 this._search.removeExcludedSearchValue($label);
8846 }
8847 this._removeItem($objectID, $label);
8848
8849 $element.remove();
8850
8851 event.stopPropagation();
8852 return false;
8853 },
8854
8855 /**
8856 * Returns current object id.
8857 *
8858 * @return integer
8859 */
8860 _getObjectID: function() {
8861 return 0;
8862 },
8863
8864 /**
8865 * Returns current object type id.
8866 *
8867 * @return integer
8868 */
8869 _getObjectTypeID: function() {
8870 return 0;
8871 },
8872
8873 /**
8874 * Adds a new item to the list.
8875 *
8876 * @param object data
8877 * @return boolean
8878 */
8879 addItem: function(data) {
8880 if (this._data[data.objectID]) {
8881 if (!(data.objectID === 0 && this._allowCustomInput)) {
8882 return true;
8883 }
8884 }
8885
8886 var $listItem = $('<li class="badge">' + WCF.String.escapeHTML(data.label) + '</li>').data('objectID', data.objectID).data('label', data.label).appendTo(this._itemList);
8887 $listItem.click($.proxy(this._click, this));
8888
8889 if (this._search) {
8890 this._search.addExcludedSearchValue(data.label);
8891 }
8892 this._addItem(data.objectID, data.label);
8893
8894 return true;
8895 },
8896
8897 /**
8898 * Clears the list of items.
8899 */
8900 clearList: function() {
8901 this._itemList.children('li').each($.proxy(function(index, element) {
8902 var $element = $(element);
8903
8904 if (this._search) {
8905 this._search.removeExcludedSearchValue($element.data('label'));
8906 }
8907
8908 $element.remove();
8909 this._removeItem($element.data('objectID'), $element.data('label'));
8910 }, this));
8911 },
8912
8913 /**
8914 * Handles form submit, override in your class.
8915 */
8916 _submit: function() {
8917 this._keyDown(null);
8918 },
8919
8920 /**
8921 * Adds an item to internal storage.
8922 *
8923 * @param integer objectID
8924 * @param string label
8925 */
8926 _addItem: function(objectID, label) {
8927 this._data[objectID] = label;
8928 },
8929
8930 /**
8931 * Removes an item from internal storage.
8932 *
8933 * @param integer objectID
8934 * @param string label
8935 */
8936 _removeItem: function(objectID, label) {
8937 delete this._data[objectID];
8938 },
8939
8940 /**
8941 * Returns the search input field.
8942 *
8943 * @return jQuery
8944 */
8945 getSearchInput: function() {
8946 return this._searchInput;
8947 }
8948 });
8949
8950 /**
8951 * Provides a generic sitemap.
8952 */
8953 WCF.Sitemap = Class.extend({
8954 /**
8955 * sitemap name cache
8956 * @var array
8957 */
8958 _cache: [ ],
8959
8960 /**
8961 * dialog overlay
8962 * @var jQuery
8963 */
8964 _dialog: null,
8965
8966 /**
8967 * initialization state
8968 * @var boolean
8969 */
8970 _didInit: false,
8971
8972 /**
8973 * action proxy
8974 * @var WCF.Action.Proxy
8975 */
8976 _proxy: null,
8977
8978 /**
8979 * Initializes the generic sitemap.
8980 */
8981 init: function() {
8982 $('#sitemap').click($.proxy(this._click, this));
8983
8984 this._cache = [ ];
8985 this._dialog = null;
8986 this._didInit = false;
8987 this._proxy = new WCF.Action.Proxy({
8988 success: $.proxy(this._success, this)
8989 });
8990 },
8991
8992 /**
8993 * Handles clicks on the sitemap icon.
8994 */
8995 _click: function() {
8996 if (this._dialog === null) {
8997 this._dialog = $('<div id="sitemapDialog" />').appendTo(document.body);
8998
8999 this._proxy.setOption('data', {
9000 actionName: 'getSitemap',
9001 className: 'wcf\\data\\sitemap\\SitemapAction'
9002 });
9003 this._proxy.sendRequest();
9004 }
9005 else {
9006 this._dialog.wcfDialog('open');
9007 }
9008 },
9009
9010 /**
9011 * Handles successful AJAX responses.
9012 *
9013 * @param object data
9014 * @param string textStatus
9015 * @param jQuery jqXHR
9016 */
9017 _success: function(data, textStatus, jqXHR) {
9018 if (this._didInit) {
9019 this._cache.push(data.returnValues.sitemapName);
9020
9021 this._dialog.find('#sitemap_' + data.returnValues.sitemapName).html(data.returnValues.template);
9022
9023 // redraw dialog
9024 this._dialog.wcfDialog('render');
9025 }
9026 else {
9027 // mark sitemap name as loaded
9028 this._cache.push(data.returnValues.sitemapName);
9029
9030 // insert sitemap template
9031 this._dialog.html(data.returnValues.template);
9032
9033 // bind event listener
9034 this._dialog.find('.sitemapNavigation').click($.proxy(this._navigate, this));
9035
9036 // select active item
9037 this._dialog.find('.tabMenuContainer').wcfTabs('select', 'sitemap_' + data.returnValues.sitemapName);
9038
9039 // show dialog
9040 this._dialog.wcfDialog({
9041 title: WCF.Language.get('wcf.page.sitemap')
9042 });
9043
9044 this._didInit = true;
9045 }
9046 },
9047
9048 /**
9049 * Navigates between different sitemaps.
9050 *
9051 * @param object event
9052 */
9053 _navigate: function(event) {
9054 var $sitemapName = $(event.currentTarget).data('sitemapName');
9055 if (WCF.inArray($sitemapName, this._cache)) {
9056 this._dialog.find('.tabMenuContainer').wcfTabs('select', 'sitemap_' + $sitemapName);
9057
9058 // redraw dialog
9059 this._dialog.wcfDialog('render');
9060 }
9061 else {
9062 this._proxy.setOption('data', {
9063 actionName: 'getSitemap',
9064 className: 'wcf\\data\\sitemap\\SitemapAction',
9065 parameters: {
9066 sitemapName: $sitemapName
9067 }
9068 });
9069 this._proxy.sendRequest();
9070 }
9071 }
9072 });
9073
9074 /**
9075 * Provides a language chooser.
9076 *
9077 * @param string containerID
9078 * @param string inputFieldID
9079 * @param integer languageID
9080 * @param object languages
9081 * @param object callback
9082 */
9083 WCF.Language.Chooser = Class.extend({
9084 /**
9085 * callback object
9086 * @var object
9087 */
9088 _callback: null,
9089
9090 /**
9091 * dropdown object
9092 * @var jQuery
9093 */
9094 _dropdown: null,
9095
9096 /**
9097 * input field
9098 * @var jQuery
9099 */
9100 _input: null,
9101
9102 /**
9103 * Initializes the language chooser.
9104 *
9105 * @param string containerID
9106 * @param string inputFieldID
9107 * @param integer languageID
9108 * @param object languages
9109 * @param object callback
9110 * @param boolean allowEmptyValue
9111 */
9112 init: function(containerID, inputFieldID, languageID, languages, callback, allowEmptyValue) {
9113 var $container = $('#' + containerID);
9114 if ($container.length != 1) {
9115 console.debug("[WCF.Language.Chooser] Invalid container id '" + containerID + "' given");
9116 return;
9117 }
9118
9119 // bind language id input
9120 this._input = $('#' + inputFieldID);
9121 if (!this._input.length) {
9122 this._input = $('<input type="hidden" name="' + inputFieldID + '" value="' + languageID + '" />').appendTo($container);
9123 }
9124
9125 // handle callback
9126 if (callback !== undefined) {
9127 if (!$.isFunction(callback)) {
9128 console.debug("[WCF.Language.Chooser] Given callback is invalid");
9129 return;
9130 }
9131
9132 this._callback = callback;
9133 }
9134
9135 // create language dropdown
9136 this._dropdown = $('<div class="dropdown" id="' + containerID + '-languageChooser" />').appendTo($container);
9137 $('<div class="dropdownToggle boxFlag box24" data-toggle="' + containerID + '-languageChooser"></div>').appendTo(this._dropdown);
9138 var $dropdownMenu = $('<ul class="dropdownMenu" />').appendTo(this._dropdown);
9139
9140 for (var $languageID in languages) {
9141 var $language = languages[$languageID];
9142 var $item = $('<li class="boxFlag"><a class="box24"><div class="framed"><img src="' + $language.iconPath + '" alt="" class="iconFlag" /></div> <div><h3>' + $language.languageName + '</h3></div></a></li>').appendTo($dropdownMenu);
9143 $item.data('languageID', $languageID).click($.proxy(this._click, this));
9144
9145 // update dropdown label
9146 if ($languageID == languageID) {
9147 var $html = $('' + $item.html());
9148 var $innerContent = $html.children().detach();
9149 this._dropdown.children('.dropdownToggle').empty().append($innerContent);
9150 }
9151 }
9152
9153 // allow an empty selection (e.g. using as language filter)
9154 if (allowEmptyValue) {
9155 $('<li class="dropdownDivider" />').appendTo($dropdownMenu);
9156 var $item = $('<li><a>' + WCF.Language.get('wcf.global.language.noSelection') + '</a></li>').data('languageID', 0).click($.proxy(this._click, this)).appendTo($dropdownMenu);
9157
9158 if (languageID === 0) {
9159 this._dropdown.children('.dropdownToggle').empty().append($item.html());
9160 }
9161 }
9162
9163 WCF.Dropdown.init();
9164 },
9165
9166 /**
9167 * Handles click events.
9168 *
9169 * @param object event
9170 */
9171 _click: function(event) {
9172 var $item = $(event.currentTarget);
9173 var $languageID = $item.data('languageID');
9174
9175 // update input field
9176 this._input.val($languageID);
9177
9178 // update dropdown label
9179 var $html = $('' + $item.html());
9180 var $innerContent = ($languageID === 0) ? $html : $html.children().detach();
9181 this._dropdown.children('.dropdownToggle').empty().append($innerContent);
9182
9183 // execute callback
9184 if (this._callback !== null) {
9185 this._callback($item);
9186 }
9187 }
9188 });
9189
9190 /**
9191 * Namespace for style related classes.
9192 */
9193 WCF.Style = { };
9194
9195 /**
9196 * Provides a visual style chooser.
9197 */
9198 WCF.Style.Chooser = Class.extend({
9199 /**
9200 * dialog overlay
9201 * @var jQuery
9202 */
9203 _dialog: null,
9204
9205 /**
9206 * action proxy
9207 * @var WCF.Action.Proxy
9208 */
9209 _proxy: null,
9210
9211 /**
9212 * Initializes the style chooser class.
9213 */
9214 init: function() {
9215 $('<li class="styleChooser"><a>' + WCF.Language.get('wcf.style.changeStyle') + '</a></li>').appendTo($('#footerNavigation > ul.navigationItems')).click($.proxy(this._showDialog, this));
9216
9217 this._proxy = new WCF.Action.Proxy({
9218 success: $.proxy(this._success, this)
9219 });
9220 },
9221
9222 /**
9223 * Displays the style chooser dialog.
9224 */
9225 _showDialog: function() {
9226 if (this._dialog === null) {
9227 this._dialog = $('<div id="styleChooser" />').hide().appendTo(document.body);
9228 this._loadDialog();
9229 }
9230 else {
9231 this._dialog.wcfDialog({
9232 title: WCF.Language.get('wcf.style.changeStyle')
9233 });
9234 }
9235 },
9236
9237 /**
9238 * Loads the style chooser dialog.
9239 */
9240 _loadDialog: function() {
9241 this._proxy.setOption('data', {
9242 actionName: 'getStyleChooser',
9243 className: 'wcf\\data\\style\\StyleAction'
9244 });
9245 this._proxy.sendRequest();
9246 },
9247
9248 /**
9249 * Handles successful AJAX requests.
9250 *
9251 * @param object data
9252 * @param string textStatus
9253 * @param jQuery jqXHR
9254 */
9255 _success: function(data, textStatus, jqXHR) {
9256 if (data.actionName === 'changeStyle') {
9257 window.location.reload();
9258 return;
9259 }
9260
9261 this._dialog.html(data.returnValues.template);
9262 this._dialog.find('li').addClass('pointer').click($.proxy(this._click, this));
9263
9264 this._showDialog();
9265 },
9266
9267 /**
9268 * Changes user style.
9269 *
9270 * @param object event
9271 */
9272 _click: function(event) {
9273 this._proxy.setOption('data', {
9274 actionName: 'changeStyle',
9275 className: 'wcf\\data\\style\\StyleAction',
9276 objectIDs: [ $(event.currentTarget).data('styleID') ]
9277 });
9278 this._proxy.sendRequest();
9279 }
9280 });
9281
9282 /**
9283 * Converts static user panel items into interactive dropdowns.
9284 *
9285 * @param string containerID
9286 */
9287 WCF.UserPanel = Class.extend({
9288 /**
9289 * target container
9290 * @var jQuery
9291 */
9292 _container: null,
9293
9294 /**
9295 * initialization state
9296 * @var boolean
9297 */
9298 _didLoad: false,
9299
9300 /**
9301 * original link element
9302 * @var jQuery
9303 */
9304 _link: null,
9305
9306 /**
9307 * language variable name for 'no items'
9308 * @var string
9309 */
9310 _noItems: '',
9311
9312 /**
9313 * reverts to original link if return values are empty
9314 * @var boolean
9315 */
9316 _revertOnEmpty: true,
9317
9318 /**
9319 * Initialites the WCF.UserPanel class.
9320 *
9321 * @param string containerID
9322 */
9323 init: function(containerID) {
9324 this._container = $('#' + containerID);
9325 this._didLoad = false;
9326 this._revertOnEmpty = true;
9327
9328 if (this._container.length != 1) {
9329 console.debug("[WCF.UserPanel] Unable to find container identfied by '" + containerID + "', aborting.");
9330 return;
9331 }
9332
9333 this._convert();
9334 },
9335
9336 /**
9337 * Converts link into an interactive dropdown menu.
9338 */
9339 _convert: function() {
9340 this._container.addClass('dropdown');
9341 this._link = this._container.children('a').remove();
9342
9343 var $button = $('<a class="dropdownToggle">' + this._link.html() + '</a>').appendTo(this._container).click($.proxy(this._click, this));
9344 var $dropdownMenu = $('<ul class="dropdownMenu" />').appendTo(this._container);
9345 $('<li class="jsDropdownPlaceholder"><span>' + WCF.Language.get('wcf.global.loading') + '</span></li>').appendTo($dropdownMenu);
9346
9347 this._addDefaultItems($dropdownMenu);
9348
9349 this._container.dblclick($.proxy(function() {
9350 window.location = this._link.attr('href');
9351 return false;
9352 }, this));
9353
9354 WCF.Dropdown.initDropdown($button, false);
9355 },
9356
9357 /**
9358 * Adds default items to dropdown menu.
9359 *
9360 * @param jQuery dropdownMenu
9361 */
9362 _addDefaultItems: function(dropdownMenu) { },
9363
9364 /**
9365 * Adds a dropdown divider.
9366 *
9367 * @param jQuery dropdownMenu
9368 */
9369 _addDivider: function(dropdownMenu) {
9370 $('<li class="dropdownDivider" />').appendTo(dropdownMenu);
9371 },
9372
9373 /**
9374 * Handles clicks on the dropdown item.
9375 */
9376 _click: function() {
9377 if (this._didLoad) {
9378 return;
9379 }
9380
9381 new WCF.Action.Proxy({
9382 autoSend: true,
9383 data: this._getParameters(),
9384 success: $.proxy(this._success, this)
9385 });
9386
9387 this._didLoad = true;
9388 },
9389
9390 /**
9391 * Returns a list of parameters for AJAX request.
9392 *
9393 * @return object
9394 */
9395 _getParameters: function() {
9396 return { };
9397 },
9398
9399 /**
9400 * Handles successful AJAX requests.
9401 *
9402 * @param object data
9403 * @param string textStatus
9404 * @param jQuery jqXHR
9405 */
9406 _success: function(data, textStatus, jqXHR) {
9407 var $dropdownMenu = WCF.Dropdown.getDropdownMenu(this._container.wcfIdentify());
9408 $dropdownMenu.children('.jsDropdownPlaceholder').remove();
9409
9410 if (data.returnValues && data.returnValues.template) {
9411 $('' + data.returnValues.template).prependTo($dropdownMenu);
9412
9413 // update badge
9414 var $badge = this._container.find('.badge');
9415 if (!$badge.length) {
9416 $badge = $('<span class="badge badgeInverse" />').appendTo(this._container.children('.dropdownToggle'));
9417 $badge.before(' ');
9418 }
9419 $badge.html(data.returnValues.totalCount);
9420
9421 this._after($dropdownMenu);
9422 }
9423 else {
9424 $('<li><span>' + WCF.Language.get(this._noItems) + '</span></li>').prependTo($dropdownMenu);
9425
9426 // remove badge
9427 this._container.find('.badge').remove();
9428 }
9429 },
9430
9431 /**
9432 * Execute actions after the dropdown menu has been populated.
9433 *
9434 * @param object dropdownMenu
9435 */
9436 _after: function(dropdownMenu) { }
9437 });
9438
9439 /**
9440 * WCF implementation for dialogs, based upon ideas by jQuery UI.
9441 */
9442 $.widget('ui.wcfDialog', {
9443 /**
9444 * close button
9445 * @var jQuery
9446 */
9447 _closeButton: null,
9448
9449 /**
9450 * dialog container
9451 * @var jQuery
9452 */
9453 _container: null,
9454
9455 /**
9456 * dialog content
9457 * @var jQuery
9458 */
9459 _content: null,
9460
9461 /**
9462 * modal overlay
9463 * @var jQuery
9464 */
9465 _overlay: null,
9466
9467 /**
9468 * plain html for title
9469 * @var string
9470 */
9471 _title: null,
9472
9473 /**
9474 * title bar
9475 * @var jQuery
9476 */
9477 _titlebar: null,
9478
9479 /**
9480 * dialog visibility state
9481 * @var boolean
9482 */
9483 _isOpen: false,
9484
9485 /**
9486 * option list
9487 * @var object
9488 */
9489 options: {
9490 // dialog
9491 autoOpen: true,
9492 closable: true,
9493 closeButtonLabel: null,
9494 closeConfirmMessage: null,
9495 closeViaModal: true,
9496 hideTitle: false,
9497 modal: true,
9498 title: '',
9499 zIndex: 400,
9500
9501 // event callbacks
9502 onClose: null,
9503 onShow: null
9504 },
9505
9506 /**
9507 * @see $.widget._createWidget()
9508 */
9509 _createWidget: function(options, element) {
9510 // ignore script tags
9511 if ($(element).getTagName() === 'script') {
9512 console.debug("[ui.wcfDialog] Ignored script tag");
9513 this.element = false;
9514 return null;
9515 }
9516
9517 $.Widget.prototype._createWidget.apply(this, arguments);
9518 },
9519
9520 /**
9521 * Initializes a new dialog.
9522 */
9523 _init: function() {
9524 if (this.options.autoOpen) {
9525 this.open();
9526 }
9527
9528 // act on resize
9529 $(window).resize($.proxy(this._resize, this));
9530 },
9531
9532 /**
9533 * Creates a new dialog instance.
9534 */
9535 _create: function() {
9536 if (this.options.closeButtonLabel === null) {
9537 this.options.closeButtonLabel = WCF.Language.get('wcf.global.button.close');
9538 }
9539
9540 // create dialog container
9541 this._container = $('<div class="dialogContainer" />').hide().css({ zIndex: this.options.zIndex }).appendTo(document.body);
9542 this._titlebar = $('<header class="dialogTitlebar" />').hide().appendTo(this._container);
9543 this._title = $('<span class="dialogTitle" />').hide().appendTo(this._titlebar);
9544 this._closeButton = $('<a class="dialogCloseButton jsTooltip" title="' + this.options.closeButtonLabel + '"><span /></a>').click($.proxy(this.close, this)).hide().appendTo(this._titlebar);
9545 this._content = $('<div class="dialogContent" />').appendTo(this._container);
9546
9547 this._setOption('title', this.options.title);
9548 this._setOption('closable', this.options.closable);
9549
9550 // move target element into content
9551 var $content = this.element.detach();
9552 this._content.html($content);
9553
9554 // create modal view
9555 if (this.options.modal) {
9556 this._overlay = $('#jsWcfDialogOverlay');
9557 if (!this._overlay.length) {
9558 this._overlay = $('<div id="jsWcfDialogOverlay" class="dialogOverlay" />').css({ height: '100%', zIndex: 399 }).hide().appendTo(document.body);
9559 }
9560
9561 if (this.options.closable && this.options.closeViaModal) {
9562 this._overlay.click($.proxy(this.close, this));
9563
9564 $(document).keyup($.proxy(function(event) {
9565 if (event.keyCode && event.keyCode === $.ui.keyCode.ESCAPE) {
9566 this.close();
9567 event.preventDefault();
9568 }
9569 }, this));
9570 }
9571 }
9572
9573 WCF.DOMNodeInsertedHandler.execute();
9574 },
9575
9576 /**
9577 * Sets the given option to the given value.
9578 * See the jQuery UI widget documentation for more.
9579 */
9580 _setOption: function(key, value) {
9581 this.options[key] = value;
9582
9583 if (key == 'hideTitle' || key == 'title') {
9584 if (!this.options.hideTitle && this.options.title != '') {
9585 this._title.html(this.options.title).show();
9586 } else {
9587 this._title.html('');
9588 }
9589 } else if (key == 'closable' || key == 'closeButtonLabel') {
9590 if (this.options.closable) {
9591 this._closeButton.attr('title', this.options.closeButtonLabel).show().find('span').html(this.options.closeButtonLabel);
9592
9593 WCF.DOMNodeInsertedHandler.execute();
9594 } else {
9595 this._closeButton.hide();
9596 }
9597 }
9598
9599 if ((!this.options.hideTitle && this.options.title != '') || this.options.closable) {
9600 this._titlebar.show();
9601 } else {
9602 this._titlebar.hide();
9603 }
9604
9605 return this;
9606 },
9607
9608 /**
9609 * Opens this dialog.
9610 */
9611 open: function() {
9612 // ignore script tags
9613 if (this.element === false) {
9614 return;
9615 }
9616
9617 if (this.isOpen()) {
9618 return;
9619 }
9620
9621 if (this._overlay !== null) {
9622 WCF.activeDialogs++;
9623
9624 if (WCF.activeDialogs === 1) {
9625 this._overlay.show();
9626 }
9627 }
9628
9629 this.render();
9630 this._isOpen = true;
9631 },
9632
9633 /**
9634 * Returns true if dialog is visible.
9635 *
9636 * @return boolean
9637 */
9638 isOpen: function() {
9639 return this._isOpen;
9640 },
9641
9642 /**
9643 * Closes this dialog.
9644 *
9645 * This function can be manually called, even if the dialog is set as not
9646 * closable by the user.
9647 *
9648 * @param object event
9649 */
9650 close: function(event) {
9651 if (!this.isOpen()) {
9652 return;
9653 }
9654
9655 if (this.options.closeConfirmMessage) {
9656 WCF.System.Confirmation.show(this.options.closeConfirmMessage, $.proxy(function(action) {
9657 if (action === 'confirm') {
9658 this._close();
9659 }
9660 }, this));
9661 }
9662 else {
9663 this._close();
9664 }
9665
9666 if (event !== undefined) {
9667 event.preventDefault();
9668 }
9669 },
9670
9671 /**
9672 * Handles dialog closing, should never be called directly.
9673 *
9674 * @see $.ui.wcfDialog.close()
9675 */
9676 _close: function() {
9677 this._isOpen = false;
9678 this._container.wcfFadeOut();
9679
9680 if (this._overlay !== null) {
9681 WCF.activeDialogs--;
9682
9683 if (WCF.activeDialogs === 0) {
9684 this._overlay.hide();
9685 }
9686 }
9687
9688 if (this.options.onClose !== null) {
9689 this.options.onClose();
9690 }
9691 },
9692
9693 /**
9694 * Renders dialog on resize if visible.
9695 */
9696 _resize: function() {
9697 if (this.isOpen()) {
9698 this.render();
9699 }
9700 },
9701
9702 /**
9703 * Renders this dialog, should be called whenever content is updated.
9704 */
9705 render: function() {
9706 // check if this if dialog was previously hidden and container is fixed
9707 // at 0px (mobile optimization), in this case scroll to top
9708 if (!this._container.is(':visible') && this._container.css('top') === '0px') {
9709 window.scrollTo(0, 0);
9710 }
9711
9712 // force dialog and it's contents to be visible
9713 this._container.show();
9714 this._content.children().show();
9715
9716 // remove fixed content dimensions for calculation
9717 this._content.css({
9718 height: 'auto',
9719 width: 'auto'
9720 });
9721
9722 // terminate concurrent rendering processes
9723 this._container.stop();
9724 this._content.stop();
9725
9726 // set dialog to be fully opaque, prevents weird bugs in WebKit
9727 this._container.show().css('opacity', 1.0);
9728
9729 // handle positioning of form submit controls
9730 var $heightDifference = 0;
9731 if (this._content.find('.formSubmit').length) {
9732 $heightDifference = this._content.find('.formSubmit').outerHeight();
9733
9734 this._content.addClass('dialogForm').css({ marginBottom: $heightDifference + 'px' });
9735 }
9736 else {
9737 this._content.removeClass('dialogForm').css({ marginBottom: '0px' });
9738 }
9739
9740 // force 800px or 90% width
9741 var $windowDimensions = $(window).getDimensions();
9742 if ($windowDimensions.width * 0.9 > 800) {
9743 this._container.css('maxWidth', '800px');
9744 }
9745
9746 // calculate dimensions
9747 var $containerDimensions = this._container.getDimensions('outer');
9748 var $contentDimensions = this._content.getDimensions();
9749
9750 // calculate maximum content height
9751 var $heightDifference = $containerDimensions.height - $contentDimensions.height;
9752 var $maximumHeight = $windowDimensions.height - $heightDifference - 120;
9753 this._content.css({ maxHeight: $maximumHeight + 'px' });
9754
9755 this._determineOverflow();
9756
9757 // calculate new dimensions
9758 $containerDimensions = this._container.getDimensions('outer');
9759
9760 // move container
9761 var $leftOffset = Math.round(($windowDimensions.width - $containerDimensions.width) / 2);
9762 var $topOffset = Math.round(($windowDimensions.height - $containerDimensions.height) / 2);
9763
9764 // place container at 20% height if possible
9765 var $desiredTopOffset = Math.round(($windowDimensions.height / 100) * 20);
9766 if ($desiredTopOffset < $topOffset) {
9767 $topOffset = $desiredTopOffset;
9768 }
9769
9770 // apply offset
9771 this._container.css({
9772 left: $leftOffset + 'px',
9773 top: $topOffset + 'px'
9774 });
9775
9776 // remove static dimensions
9777 this._content.css({
9778 height: 'auto',
9779 width: 'auto'
9780 });
9781
9782 if (!this.isOpen()) {
9783 // hide container again
9784 this._container.hide();
9785
9786 // fade in container
9787 this._container.wcfFadeIn($.proxy(function() {
9788 if (this.options.onShow !== null) {
9789 this.options.onShow();
9790 }
9791 }, this));
9792 }
9793 },
9794
9795 /**
9796 * Determines content overflow based upon static dimensions.
9797 */
9798 _determineOverflow: function() {
9799 var $max = $(window).getDimensions();
9800 var $maxHeight = this._content.css('maxHeight');
9801 this._content.css('maxHeight', 'none');
9802 var $dialog = this._container.getDimensions('outer');
9803
9804 var $overflow = 'visible';
9805 if (($max.height * 0.8 < $dialog.height) || ($max.width * 0.8 < $dialog.width)) {
9806 $overflow = 'auto';
9807 }
9808
9809 this._content.css('overflow', $overflow);
9810 this._content.css('maxHeight', $maxHeight);
9811
9812 if ($overflow === 'visible') {
9813 // content may already overflow, even though the overall height is still below the threshold
9814 var $contentHeight = 0;
9815 this._content.children().each(function(index, child) {
9816 $contentHeight += $(child).outerHeight();
9817 });
9818
9819 if (this._content.height() < $contentHeight) {
9820 this._content.css('overflow', 'auto');
9821 }
9822 }
9823 },
9824
9825 /**
9826 * Returns calculated content dimensions.
9827 *
9828 * @param integer maximumHeight
9829 * @return object
9830 */
9831 _getContentDimensions: function(maximumHeight) {
9832 var $contentDimensions = this._content.getDimensions();
9833
9834 // set height to maximum height if exceeded
9835 if (maximumHeight && $contentDimensions.height > maximumHeight) {
9836 $contentDimensions.height = maximumHeight;
9837 }
9838
9839 return $contentDimensions;
9840 }
9841 });
9842
9843 /**
9844 * Provides a slideshow for lists.
9845 */
9846 $.widget('ui.wcfSlideshow', {
9847 /**
9848 * button list object
9849 * @var jQuery
9850 */
9851 _buttonList: null,
9852
9853 /**
9854 * number of items
9855 * @var integer
9856 */
9857 _count: 0,
9858
9859 /**
9860 * item index
9861 * @var integer
9862 */
9863 _index: 0,
9864
9865 /**
9866 * item list object
9867 * @var jQuery
9868 */
9869 _itemList: null,
9870
9871 /**
9872 * list of items
9873 * @var jQuery
9874 */
9875 _items: null,
9876
9877 /**
9878 * timer object
9879 * @var WCF.PeriodicalExecuter
9880 */
9881 _timer: null,
9882
9883 /**
9884 * list item width
9885 * @var integer
9886 */
9887 _width: 0,
9888
9889 /**
9890 * list of options
9891 * @var object
9892 */
9893 options: {
9894 /* enables automatic cycling of items */
9895 cycle: true,
9896 /* cycle interval in seconds */
9897 cycleInterval: 5,
9898 /* gap between items in pixels */
9899 itemGap: 50,
9900 },
9901
9902 /**
9903 * Creates a new instance of ui.wcfSlideshow.
9904 */
9905 _create: function() {
9906 this._itemList = this.element.children('ul');
9907 this._items = this._itemList.children('li');
9908 this._count = this._items.length;
9909 this._index = 0;
9910
9911 if (this._count > 1) {
9912 this._initSlideshow();
9913 }
9914 },
9915
9916 /**
9917 * Initializes the slideshow.
9918 */
9919 _initSlideshow: function() {
9920 // calculate item dimensions
9921 var $itemHeight = $(this._items.get(0)).outerHeight();
9922 this._items.addClass('slideshowItem');
9923 this._width = this.element.css('height', $itemHeight).innerWidth();
9924 this._itemList.addClass('slideshowItemList').css('left', 0);
9925
9926 this._items.each($.proxy(function(index, item) {
9927 $(item).show().css({
9928 height: $itemHeight,
9929 left: ((this._width + this.options.itemGap) * index),
9930 width: this._width
9931 });
9932 }, this));
9933
9934 this.element.css({
9935 height: $itemHeight,
9936 width: this._width
9937 }).hover($.proxy(this._hoverIn, this), $.proxy(this._hoverOut, this));
9938
9939 // create toggle buttons
9940 this._buttonList = $('<ul class="slideshowButtonList" />').appendTo(this.element);
9941 for (var $i = 0; $i < this._count; $i++) {
9942 var $link = $('<li><a><span class="icon icon16 icon-circle" /></a></li>').data('index', $i).click($.proxy(this._click, this)).appendTo(this._buttonList);
9943 if ($i == 0) {
9944 $link.find('.icon').addClass('active');
9945 }
9946 }
9947
9948 this._resetTimer();
9949
9950 $(window).resize($.proxy(this._resize, this));
9951 },
9952
9953 /**
9954 * Handles browser resizing
9955 */
9956 _resize: function() {
9957 this._width = this.element.css('width', 'auto').innerWidth();
9958 this._items.each($.proxy(function(index, item) {
9959 $(item).css({
9960 left: ((this._width + this.options.itemGap) * index),
9961 width: this._width
9962 });
9963 }, this));
9964
9965 this._index--;
9966 this.moveTo(null);
9967 },
9968
9969 /**
9970 * Disables cycling while hovering.
9971 */
9972 _hoverIn: function() {
9973 if (this._timer !== null) {
9974 this._timer.stop();
9975 }
9976 },
9977
9978 /**
9979 * Enables cycling after mouse out.
9980 */
9981 _hoverOut: function() {
9982 this._resetTimer();
9983 },
9984
9985 /**
9986 * Resets cycle timer.
9987 */
9988 _resetTimer: function() {
9989 if (!this.options.cycle) {
9990 return;
9991 }
9992
9993 if (this._timer !== null) {
9994 this._timer.stop();
9995 }
9996
9997 var self = this;
9998 this._timer = new WCF.PeriodicalExecuter(function() {
9999 self.moveTo(null);
10000 }, this.options.cycleInterval * 1000);
10001 },
10002
10003 /**
10004 * Handles clicks on the select buttons.
10005 *
10006 * @param object event
10007 */
10008 _click: function(event) {
10009 this.moveTo($(event.currentTarget).data('index'));
10010
10011 this._resetTimer();
10012 },
10013
10014 /**
10015 * Moves to a specified item index, NULL will move to the next item in list.
10016 *
10017 * @param integer index
10018 */
10019 moveTo: function(index) {
10020 this._index = (index === null) ? this._index + 1 : index;
10021 if (this._index == this._count) {
10022 this._index = 0;
10023 }
10024
10025 $(this._buttonList.find('.icon').removeClass('active').get(this._index)).addClass('active');
10026 this._itemList.css('left', this._index * (this._width + this.options.itemGap) * -1);
10027
10028 this._trigger('moveTo', null, { index: this._index });
10029 },
10030
10031 /**
10032 * Returns item by index or null if index is invalid.
10033 *
10034 * @return jQuery
10035 */
10036 getItem: function(index) {
10037 if (this._items[index]) {
10038 return this._items[index];
10039 }
10040
10041 return null;
10042 }
10043 });
10044
10045 /**
10046 * Custom tab menu implementation for WCF.
10047 */
10048 $.widget('ui.wcfTabs', $.ui.tabs, {
10049 /**
10050 * Workaround for ids containing a dot ".", until jQuery UI devs learn
10051 * to properly escape ids ... (it took 18 months until they finally
10052 * fixed it!)
10053 *
10054 * @see http://bugs.jqueryui.com/ticket/4681
10055 * @see $.ui.tabs.prototype._sanitizeSelector()
10056 */
10057 _sanitizeSelector: function(hash) {
10058 return hash.replace(/([:\.])/g, '\\$1');
10059 },
10060
10061 /**
10062 * @see $.ui.tabs.prototype.select()
10063 */
10064 select: function(index) {
10065 if (!$.isNumeric(index)) {
10066 // panel identifier given
10067 this.panels.each(function(i, panel) {
10068 if ($(panel).wcfIdentify() === index) {
10069 index = i;
10070 return false;
10071 }
10072 });
10073
10074 // unable to identify panel
10075 if (!$.isNumeric(index)) {
10076 console.debug("[ui.wcfTabs] Unable to find panel identified by '" + index + "', aborting.");
10077 return;
10078 }
10079 }
10080
10081 this._setOption('active', index);
10082 },
10083
10084 /**
10085 * Selects a specific tab by triggering the 'click' event.
10086 *
10087 * @param string tabIdentifier
10088 */
10089 selectTab: function(tabIdentifier) {
10090 tabIdentifier = '#' + tabIdentifier;
10091
10092 this.anchors.each(function(index, anchor) {
10093 var $anchor = $(anchor);
10094 if ($anchor.prop('hash') === tabIdentifier) {
10095 $anchor.trigger('click');
10096 return false;
10097 }
10098 });
10099 },
10100
10101 /**
10102 * Returns the currently selected tab index.
10103 *
10104 * @return integer
10105 */
10106 getCurrentIndex: function() {
10107 return this.lis.index(this.lis.filter('.ui-tabs-selected'));
10108 },
10109
10110 /**
10111 * Returns true if identifier is used by an anchor.
10112 *
10113 * @param string identifier
10114 * @param boolean isChildren
10115 * @return boolean
10116 */
10117 hasAnchor: function(identifier, isChildren) {
10118 var $matches = false;
10119
10120 this.anchors.each(function(index, anchor) {
10121 var $href = $(anchor).attr('href');
10122 if (/#.+/.test($href)) {
10123 // split by anchor
10124 var $parts = $href.split('#', 2);
10125 if (isChildren) {
10126 $parts = $parts[1].split('-', 2);
10127 }
10128
10129 if ($parts[1] === identifier) {
10130 $matches = true;
10131
10132 // terminate loop
10133 return false;
10134 }
10135 }
10136 });
10137
10138 return $matches;
10139 },
10140
10141 /**
10142 * Shows default tab.
10143 */
10144 revertToDefault: function() {
10145 var $active = this.element.data('active');
10146 if (!$active || $active === '') $active = 0;
10147
10148 this.select($active);
10149 },
10150
10151 /**
10152 * @see $.ui.tabs.prototype._processTabs()
10153 */
10154 _processTabs: function() {
10155 var that = this;
10156
10157 this.tablist = this._getList()
10158 .addClass( "ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all" )
10159 .attr( "role", "tablist" );
10160
10161 this.tabs = this.tablist.find( "> li:has(a[href])" )
10162 .addClass( "ui-state-default ui-corner-top" )
10163 .attr({
10164 role: "tab",
10165 tabIndex: -1
10166 });
10167
10168 this.anchors = this.tabs.map(function() {
10169 return $( "a", this )[ 0 ];
10170 })
10171 .addClass( "ui-tabs-anchor" )
10172 .attr({
10173 role: "presentation",
10174 tabIndex: -1
10175 });
10176
10177 this.panels = $();
10178
10179 this.anchors.each(function( i, anchor ) {
10180 var selector, panel,
10181 anchorId = $( anchor ).uniqueId().attr( "id" ),
10182 tab = $( anchor ).closest( "li" ),
10183 originalAriaControls = tab.attr( "aria-controls" );
10184
10185 // inline tab
10186 selector = anchor.hash;
10187 panel = that.element.find( that._sanitizeSelector( selector ) );
10188
10189 if ( panel.length) {
10190 that.panels = that.panels.add( panel );
10191 }
10192 if ( originalAriaControls ) {
10193 tab.data( "ui-tabs-aria-controls", originalAriaControls );
10194 }
10195 tab.attr({
10196 "aria-controls": selector.substring( 1 ),
10197 "aria-labelledby": anchorId
10198 });
10199 panel.attr( "aria-labelledby", anchorId );
10200 });
10201
10202 this.panels
10203 .addClass( "ui-tabs-panel ui-widget-content ui-corner-bottom" )
10204 .attr( "role", "tabpanel" );
10205 },
10206
10207 /**
10208 * @see $.ui.tabs.prototype.load()
10209 */
10210 load: function( index, event ) {
10211 return;
10212 }
10213 });
10214
10215 /**
10216 * jQuery widget implementation of the wcf pagination.
10217 */
10218 $.widget('ui.wcfPages', {
10219 SHOW_LINKS: 11,
10220 SHOW_SUB_LINKS: 20,
10221
10222 options: {
10223 // vars
10224 activePage: 1,
10225 maxPage: 1,
10226
10227 // language
10228 // we use options here instead of language variables, because the paginator is not only usable with pages
10229 nextPage: null,
10230 previousPage: null
10231 },
10232
10233 /**
10234 * Creates the pages widget.
10235 */
10236 _create: function() {
10237 if (this.options.nextPage === null) this.options.nextPage = WCF.Language.get('wcf.global.page.next');
10238 if (this.options.previousPage === null) this.options.previousPage = WCF.Language.get('wcf.global.page.previous');
10239
10240 this.element.addClass('pageNavigation');
10241
10242 this._render();
10243 },
10244
10245 /**
10246 * Destroys the pages widget.
10247 */
10248 destroy: function() {
10249 $.Widget.prototype.destroy.apply(this, arguments);
10250
10251 this.element.children().remove();
10252 },
10253
10254 /**
10255 * Renders the pages widget.
10256 */
10257 _render: function() {
10258 // only render if we have more than 1 page
10259 if (!this.options.disabled && this.options.maxPage > 1) {
10260 var $hasHiddenPages = false;
10261
10262 // make sure pagination is visible
10263 if (this.element.hasClass('hidden')) {
10264 this.element.removeClass('hidden');
10265 }
10266 this.element.show();
10267
10268 this.element.children().remove();
10269
10270 var $pageList = $('<ul />');
10271 this.element.append($pageList);
10272
10273 var $previousElement = $('<li class="button skip" />');
10274 $pageList.append($previousElement);
10275
10276 if (this.options.activePage > 1) {
10277 var $previousLink = $('<a' + ((this.options.previousPage != null) ? (' title="' + this.options.previousPage + '"') : ('')) + '></a>');
10278 $previousElement.append($previousLink);
10279 this._bindSwitchPage($previousLink, this.options.activePage - 1);
10280
10281 var $previousImage = $('<span class="icon icon16 icon-double-angle-left" />');
10282 $previousLink.append($previousImage);
10283 }
10284 else {
10285 var $previousImage = $('<span class="icon icon16 icon-double-angle-left" />');
10286 $previousElement.append($previousImage);
10287 $previousElement.addClass('disabled').removeClass('button');
10288 $previousImage.addClass('disabled');
10289 }
10290
10291 // add first page
10292 $pageList.append(this._renderLink(1));
10293
10294 // calculate page links
10295 var $maxLinks = this.SHOW_LINKS - 4;
10296 var $linksBefore = this.options.activePage - 2;
10297 if ($linksBefore < 0) $linksBefore = 0;
10298 var $linksAfter = this.options.maxPage - (this.options.activePage + 1);
10299 if ($linksAfter < 0) $linksAfter = 0;
10300 if (this.options.activePage > 1 && this.options.activePage < this.options.maxPage) $maxLinks--;
10301
10302 var $half = $maxLinks / 2;
10303 var $left = this.options.activePage;
10304 var $right = this.options.activePage;
10305 if ($left < 1) $left = 1;
10306 if ($right < 1) $right = 1;
10307 if ($right > this.options.maxPage - 1) $right = this.options.maxPage - 1;
10308
10309 if ($linksBefore >= $half) {
10310 $left -= $half;
10311 }
10312 else {
10313 $left -= $linksBefore;
10314 $right += $half - $linksBefore;
10315 }
10316
10317 if ($linksAfter >= $half) {
10318 $right += $half;
10319 }
10320 else {
10321 $right += $linksAfter;
10322 $left -= $half - $linksAfter;
10323 }
10324
10325 $right = Math.ceil($right);
10326 $left = Math.ceil($left);
10327 if ($left < 1) $left = 1;
10328 if ($right > this.options.maxPage) $right = this.options.maxPage;
10329
10330 // left ... links
10331 if ($left > 1) {
10332 if ($left - 1 < 2) {
10333 $pageList.append(this._renderLink(2));
10334 }
10335 else {
10336 $('<li class="button jumpTo"><a title="' + WCF.Language.get('wcf.global.page.jumpTo') + '" class="jsTooltip">...</a></li>').appendTo($pageList);
10337 $hasHiddenPages = true;
10338 }
10339 }
10340
10341 // visible links
10342 for (var $i = $left + 1; $i < $right; $i++) {
10343 $pageList.append(this._renderLink($i));
10344 }
10345
10346 // right ... links
10347 if ($right < this.options.maxPage) {
10348 if (this.options.maxPage - $right < 2) {
10349 $pageList.append(this._renderLink(this.options.maxPage - 1));
10350 }
10351 else {
10352 $('<li class="button jumpTo"><a title="' + WCF.Language.get('wcf.global.page.jumpTo') + '" class="jsTooltip">...</a></li>').appendTo($pageList);
10353 $hasHiddenPages = true;
10354 }
10355 }
10356
10357 // add last page
10358 $pageList.append(this._renderLink(this.options.maxPage));
10359
10360 // add next button
10361 var $nextElement = $('<li class="button skip" />');
10362 $pageList.append($nextElement);
10363
10364 if (this.options.activePage < this.options.maxPage) {
10365 var $nextLink = $('<a' + ((this.options.nextPage != null) ? (' title="' + this.options.nextPage + '"') : ('')) + '></a>');
10366 $nextElement.append($nextLink);
10367 this._bindSwitchPage($nextLink, this.options.activePage + 1);
10368
10369 var $nextImage = $('<span class="icon icon16 icon-double-angle-right" />');
10370 $nextLink.append($nextImage);
10371 }
10372 else {
10373 var $nextImage = $('<span class="icon icon16 icon-double-angle-right" />');
10374 $nextElement.append($nextImage);
10375 $nextElement.addClass('disabled').removeClass('button');
10376 $nextImage.addClass('disabled');
10377 }
10378
10379 if ($hasHiddenPages) {
10380 $pageList.data('pages', this.options.maxPage);
10381 WCF.System.PageNavigation.init('#' + $pageList.wcfIdentify(), $.proxy(function(pageNo) {
10382 this.switchPage(pageNo);
10383 }, this));
10384 }
10385 }
10386 else {
10387 // otherwise hide the paginator if not already hidden
10388 this.element.hide();
10389 }
10390 },
10391
10392 /**
10393 * Renders a page link.
10394 *
10395 * @parameter integer page
10396 * @return jQuery
10397 */
10398 _renderLink: function(page, lineBreak) {
10399 var $pageElement = $('<li class="button"></li>');
10400 if (lineBreak != undefined && lineBreak) {
10401 $pageElement.addClass('break');
10402 }
10403 if (page != this.options.activePage) {
10404 var $pageLink = $('<a>' + WCF.String.addThousandsSeparator(page) + '</a>');
10405 $pageElement.append($pageLink);
10406 this._bindSwitchPage($pageLink, page);
10407 }
10408 else {
10409 $pageElement.addClass('active');
10410 var $pageSubElement = $('<span>' + WCF.String.addThousandsSeparator(page) + '</span>');
10411 $pageElement.append($pageSubElement);
10412 }
10413
10414 return $pageElement;
10415 },
10416
10417 /**
10418 * Binds the 'click'-event for the page switching to the given element.
10419 *
10420 * @parameter $(element) element
10421 * @paremeter integer page
10422 */
10423 _bindSwitchPage: function(element, page) {
10424 var $self = this;
10425 element.click(function() {
10426 $self.switchPage(page);
10427 });
10428 },
10429
10430 /**
10431 * Switches to the given page
10432 *
10433 * @parameter Event event
10434 * @parameter integer page
10435 */
10436 switchPage: function(page) {
10437 this._setOption('activePage', page);
10438 },
10439
10440 /**
10441 * Sets the given option to the given value.
10442 * See the jQuery UI widget documentation for more.
10443 */
10444 _setOption: function(key, value) {
10445 if (key == 'activePage') {
10446 if (value != this.options[key] && value > 0 && value <= this.options.maxPage) {
10447 // you can prevent the page switching by returning false or by event.preventDefault()
10448 // in a shouldSwitch-callback. e.g. if an AJAX request is already running.
10449 var $result = this._trigger('shouldSwitch', undefined, {
10450 nextPage: value
10451 });
10452
10453 if ($result || $result !== undefined) {
10454 this.options[key] = value;
10455 this._render();
10456 this._trigger('switched', undefined, {
10457 activePage: value
10458 });
10459 }
10460 else {
10461 this._trigger('notSwitched', undefined, {
10462 activePage: value
10463 });
10464 }
10465 }
10466 }
10467 else {
10468 this.options[key] = value;
10469
10470 if (key == 'disabled') {
10471 if (value) {
10472 this.element.children().remove();
10473 }
10474 else {
10475 this._render();
10476 }
10477 }
10478 else if (key == 'maxPage') {
10479 this._render();
10480 }
10481 }
10482
10483 return this;
10484 },
10485
10486 /**
10487 * Start input of pagenumber
10488 *
10489 * @parameter Event event
10490 */
10491 _startInput: function(event) {
10492 // hide a-tag
10493 var $childLink = $(event.currentTarget);
10494 if (!$childLink.is('a')) $childLink = $childLink.parent('a');
10495
10496 $childLink.hide();
10497
10498 // show input-tag
10499 var $childInput = $childLink.parent('li').children('input')
10500 .css('display', 'block')
10501 .val('');
10502
10503 $childInput.focus();
10504 },
10505
10506 /**
10507 * Stops input of pagenumber
10508 *
10509 * @parameter Event event
10510 */
10511 _stopInput: function(event) {
10512 // hide input-tag
10513 var $childInput = $(event.currentTarget);
10514 $childInput.css('display', 'none');
10515
10516 // show a-tag
10517 var $childContainer = $childInput.parent('li');
10518 if ($childContainer != undefined && $childContainer != null) {
10519 $childContainer.children('a').show();
10520 }
10521 },
10522
10523 /**
10524 * Handles input of pagenumber
10525 *
10526 * @parameter Event event
10527 */
10528 _handleInput: function(event) {
10529 var $ie7 = ($.browser.msie && $.browser.version == '7.0');
10530 if (event.type != 'keyup' || $ie7) {
10531 if (!$ie7 || ((event.which == 13 || event.which == 27) && event.type == 'keyup')) {
10532 if (event.which == 13) {
10533 this.switchPage(parseInt($(event.currentTarget).val()));
10534 }
10535
10536 if (event.which == 13 || event.which == 27) {
10537 this._stopInput(event);
10538 event.stopPropagation();
10539 }
10540 }
10541 }
10542 }
10543 });
10544
10545 /**
10546 * Namespace for category related classes.
10547 */
10548 WCF.Category = { };
10549
10550 /**
10551 * Handles selection of categories.
10552 */
10553 WCF.Category.NestedList = Class.extend({
10554 /**
10555 * list of categories
10556 * @var object
10557 */
10558 _categories: { },
10559
10560 /**
10561 * Initializes the WCF.Category.NestedList object.
10562 */
10563 init: function() {
10564 var self = this;
10565 $('.jsCategory').each(function(index, category) {
10566 var $category = $(category).data('parentCategoryID', null).change($.proxy(self._updateSelection, self));
10567 self._categories[$category.val()] = $category;
10568
10569 // find child categories
10570 var $childCategoryIDs = [ ];
10571 $category.parents('li').find('.jsChildCategory').each(function(innerIndex, childCategory) {
10572 var $childCategory = $(childCategory).data('parentCategoryID', $category.val()).change($.proxy(self._updateSelection, self));
10573 self._categories[$childCategory.val()] = $childCategory;
10574 $childCategoryIDs.push($childCategory.val());
10575
10576 if ($childCategory.is(':checked')) {
10577 $category.prop('checked', 'checked');
10578 }
10579 });
10580
10581 $category.data('childCategoryIDs', $childCategoryIDs);
10582 });
10583 },
10584
10585 /**
10586 * Updates selection of categories.
10587 *
10588 * @param object event
10589 */
10590 _updateSelection: function(event) {
10591 var $category = $(event.currentTarget);
10592 var $parentCategoryID = $category.data('parentCategoryID');
10593
10594 if ($category.is(':checked')) {
10595 // child category
10596 if ($parentCategoryID !== null) {
10597 // mark parent category as checked
10598 this._categories[$parentCategoryID].prop('checked', 'checked');
10599 }
10600 }
10601 else {
10602 // top-level category
10603 if ($parentCategoryID === null) {
10604 // unmark all child categories
10605 var $childCategoryIDs = $category.data('childCategoryIDs');
10606 for (var $i = 0, $length = $childCategoryIDs.length; $i < $length; $i++) {
10607 this._categories[$childCategoryIDs[$i]].prop('checked', false);
10608 }
10609 }
10610 }
10611 }
10612 });
10613
10614 /**
10615 * Encapsulate eval() within an own function to prevent problems
10616 * with optimizing and minifiny JS.
10617 *
10618 * @param mixed expression
10619 * @returns mixed
10620 */
10621 function wcfEval(expression) {
10622 return eval(expression);
10623 }