WCF.Action.Delete overhaul
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WCF.js
1 /**
2 * Class and function collection for WCF
3 *
4 * @author Markus Bartz, Tim Düsterhus, Alexander Ebert, Matthias Schmidt
5 * @copyright 2001-2011 WoltLab GmbH
6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7 */
8
9 (function() {
10 // store original implementation
11 var $jQueryData = jQuery.fn.data;
12
13 /**
14 * Override jQuery.fn.data() to support custom 'ID' suffix which will
15 * be translated to '-id' at runtime.
16 *
17 * @see jQuery.fn.data()
18 */
19 jQuery.fn.data = function(key, value) {
20 if (key && key.match(/ID$/)) {
21 arguments[0] = key.replace(/ID$/, '-id');
22 }
23
24 // call jQuery's own data method
25 var $data = $jQueryData.apply(this, arguments);
26
27 // handle .data() call without arguments
28 if (key === undefined) {
29 for (var $key in $data) {
30 if ($key.match(/Id$/)) {
31 $data[$key.replace(/Id$/, 'ID')] = $data[$key];
32 delete $data[$key];
33 }
34 }
35 }
36
37 return $data;
38 };
39 })();
40
41 /**
42 * Simple JavaScript Inheritance
43 * By John Resig http://ejohn.org/
44 * MIT Licensed.
45 */
46 // Inspired by base2 and Prototype
47 (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;};})();
48
49 /**
50 * Provides a hashCode() method for strings, similar to Java's String.hashCode().
51 *
52 * @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
53 */
54 String.prototype.hashCode = function() {
55 var $char;
56 var $hash = 0;
57
58 if (this.length) {
59 for (var $i = 0, $length = this.length; $i < $length; $i++) {
60 $char = this.charCodeAt($i);
61 $hash = (($hash << 5) - $hash) + $char;
62 $hash = $hash & $hash; // convert to 32bit integer
63 }
64 }
65
66 return $hash;
67 };
68
69 /**
70 * Initialize WCF namespace
71 */
72 var WCF = {};
73
74 /**
75 * Extends jQuery with additional methods.
76 */
77 $.extend(true, {
78 /**
79 * Removes the given value from the given array and returns the array.
80 *
81 * @param array array
82 * @param mixed element
83 * @return array
84 */
85 removeArrayValue: function(array, value) {
86 return $.grep(array, function(element, index) {
87 return value !== element;
88 });
89 },
90
91 /**
92 * Escapes an ID to work with jQuery selectors.
93 *
94 * @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
95 * @param string id
96 * @return string
97 */
98 wcfEscapeID: function(id) {
99 return id.replace(/(:|\.)/g, '\\$1');
100 },
101
102 /**
103 * Returns true if given ID exists within DOM.
104 *
105 * @param string id
106 * @return boolean
107 */
108 wcfIsset: function(id) {
109 return !!$('#' + $.wcfEscapeID(id)).length;
110 },
111
112 /**
113 * Returns the length of an object.
114 *
115 * @param object targetObject
116 * @return integer
117 */
118 getLength: function(targetObject) {
119 var $length = 0;
120
121 for (var $key in targetObject) {
122 if (targetObject.hasOwnProperty($key)) {
123 $length++;
124 }
125 }
126
127 return $length;
128 }
129 });
130
131 /**
132 * Extends jQuery's chainable methods.
133 */
134 $.fn.extend({
135 /**
136 * Returns tag name of current jQuery element.
137 *
138 * @returns string
139 */
140 getTagName: function() {
141 return this.get(0).tagName.toLowerCase();
142 },
143
144 /**
145 * Returns the dimensions for current element.
146 *
147 * @see http://api.jquery.com/hidden-selector/
148 * @param string type
149 * @return object
150 */
151 getDimensions: function(type) {
152 var dimensions = css = {};
153 var wasHidden = false;
154
155 // show element to retrieve dimensions and restore them later
156 if (this.is(':hidden')) {
157 css = {
158 display: this.css('display'),
159 visibility: this.css('visibility')
160 };
161
162 wasHidden = true;
163
164 this.css({
165 display: 'block',
166 visibility: 'hidden'
167 });
168 }
169
170 switch (type) {
171 case 'inner':
172 dimensions = {
173 height: this.innerHeight(),
174 width: this.innerWidth()
175 };
176 break;
177
178 case 'outer':
179 dimensions = {
180 height: this.outerHeight(),
181 width: this.outerWidth()
182 };
183 break;
184
185 default:
186 dimensions = {
187 height: this.height(),
188 width: this.width()
189 };
190 break;
191 }
192
193 // restore previous settings
194 if (wasHidden) {
195 this.css(css);
196 }
197
198 return dimensions;
199 },
200
201 /**
202 * Returns the offsets for current element, defaults to position
203 * relative to document.
204 *
205 * @see http://api.jquery.com/hidden-selector/
206 * @param string type
207 * @return object
208 */
209 getOffsets: function(type) {
210 var offsets = css = {};
211 var wasHidden = false;
212
213 // show element to retrieve dimensions and restore them later
214 if (this.is(':hidden')) {
215 css = {
216 display: this.css('display'),
217 visibility: this.css('visibility')
218 };
219
220 wasHidden = true;
221
222 this.css({
223 display: 'block',
224 visibility: 'hidden'
225 });
226 }
227
228 switch (type) {
229 case 'offset':
230 offsets = this.offset();
231 break;
232
233 case 'position':
234 default:
235 offsets = this.position();
236 break;
237 }
238
239 // restore previous settings
240 if (wasHidden) {
241 this.css(css);
242 }
243
244 return offsets;
245 },
246
247 /**
248 * Changes element's position to 'absolute' or 'fixed' while maintaining it's
249 * current position relative to viewport. Optionally removes element from
250 * current DOM-node and moving it into body-element (useful for drag & drop)
251 *
252 * @param boolean rebase
253 * @return object
254 */
255 makePositioned: function(position, rebase) {
256 if (position != 'absolute' && position != 'fixed') {
257 position = 'absolute';
258 }
259
260 var $currentPosition = this.getOffsets('position');
261 this.css({
262 position: position,
263 left: $currentPosition.left,
264 margin: 0,
265 top: $currentPosition.top
266 });
267
268 if (rebase) {
269 this.remove().appentTo('body');
270 }
271
272 return this;
273 },
274
275 /**
276 * Disables a form element.
277 *
278 * @return jQuery
279 */
280 disable: function() {
281 return this.attr('disabled', 'disabled');
282 },
283
284 /**
285 * Enables a form element.
286 *
287 * @return jQuery
288 */
289 enable: function() {
290 return this.removeAttr('disabled');
291 },
292
293 /**
294 * Returns the element's id. If none is set, a random unique
295 * ID will be assigned.
296 *
297 * @return string
298 */
299 wcfIdentify: function() {
300 if (!this.attr('id')) {
301 this.attr('id', WCF.getRandomID());
302 }
303
304 return this.attr('id');
305 },
306
307 /**
308 * Returns the caret position of current element. If the element
309 * does not equal input[type=text], input[type=password] or
310 * textarea, -1 is returned.
311 *
312 * @return integer
313 */
314 getCaret: function() {
315 if (this.getTagName() == 'input') {
316 if (this.attr('type') != 'text' && this.attr('type') != 'password') {
317 return -1;
318 }
319 }
320 else if (this.getTagName() != 'textarea') {
321 return -1;
322 }
323
324 var $position = 0;
325 var $element = this.get(0);
326 if (document.selection) { // IE 8
327 // set focus to enable caret on this element
328 this.focus();
329
330 var $selection = document.selection.createRange();
331 $selection.moveStart('character', -this.val().length);
332 $position = $selection.text.length;
333 }
334 else if ($element.selectionStart || $element.selectionStart == '0') { // Opera, Chrome, Firefox, Safari, IE 9+
335 $position = parseInt($element.selectionStart);
336 }
337
338 return $position;
339 },
340
341 /**
342 * Sets the caret position of current element. If the element
343 * does not equal input[type=text], input[type=password] or
344 * textarea, false is returned.
345 *
346 * @param integer position
347 * @return boolean
348 */
349 setCaret: function (position) {
350 if (this.getTagName() == 'input') {
351 if (this.attr('type') != 'text' && this.attr('type') != 'password') {
352 return false;
353 }
354 }
355 else if (this.getTagName() != 'textarea') {
356 return false;
357 }
358
359 var $element = this.get(0);
360
361 // set focus to enable caret on this element
362 this.focus();
363 if (document.selection) { // IE 8
364 var $selection = document.selection.createRange();
365 $selection.moveStart('character', position);
366 $selection.moveEnd('character', 0);
367 $selection.select();
368 }
369 else if ($element.selectionStart || $element.selectionStart == '0') { // Opera, Chrome, Firefox, Safari, IE 9+
370 $element.selectionStart = position;
371 $element.selectionEnd = position;
372 }
373
374 return true;
375 },
376
377 /**
378 * Shows an element by sliding and fading it into viewport.
379 *
380 * @param string direction
381 * @param object callback
382 * @param integer duration
383 * @returns jQuery
384 */
385 wcfDropIn: function(direction, callback, duration) {
386 if (!direction) direction = 'up';
387 if (!duration || !parseInt(duration)) duration = 200;
388
389 return this.show(WCF.getEffect(this.getTagName(), 'drop'), { direction: direction }, duration, callback);
390 },
391
392 /**
393 * Hides an element by sliding and fading it out the viewport.
394 *
395 * @param string direction
396 * @param object callback
397 * @param integer duration
398 * @returns jQuery
399 */
400 wcfDropOut: function(direction, callback, duration) {
401 if (!direction) direction = 'down';
402 if (!duration || !parseInt(duration)) duration = 200;
403
404 return this.hide(WCF.getEffect(this.getTagName(), 'drop'), { direction: direction }, duration, callback);
405 },
406
407 /**
408 * Shows an element by blinding it up.
409 *
410 * @param string direction
411 * @param object callback
412 * @param integer duration
413 * @returns jQuery
414 */
415 wcfBlindIn: function(direction, callback, duration) {
416 if (!direction) direction = 'vertical';
417 if (!duration || !parseInt(duration)) duration = 200;
418
419 return this.show(WCF.getEffect(this.getTagName(), 'blind'), { direction: direction }, duration, callback);
420 },
421
422 /**
423 * Hides an element by blinding it down.
424 *
425 * @param string direction
426 * @param object callback
427 * @param integer duration
428 * @returns jQuery
429 */
430 wcfBlindOut: function(direction, callback, duration) {
431 if (!direction) direction = 'vertical';
432 if (!duration || !parseInt(duration)) duration = 200;
433
434 return this.hide(WCF.getEffect(this.getTagName(), 'blind'), { direction: direction }, duration, callback);
435 },
436
437 /**
438 * Highlights an element.
439 *
440 * @param object options
441 * @param object callback
442 * @returns jQuery
443 */
444 wcfHighlight: function(options, callback) {
445 return this.effect('highlight', options, 600, callback);
446 },
447
448 /**
449 * Shows an element by fading it in.
450 *
451 * @param object callback
452 * @param integer duration
453 * @returns jQuery
454 */
455 wcfFadeIn: function(callback, duration) {
456 if (!duration || !parseInt(duration)) duration = 200;
457
458 return this.show(WCF.getEffect(this.getTagName(), 'fade'), { }, duration, callback);
459 },
460
461 /**
462 * Hides an element by fading it out.
463 *
464 * @param object callback
465 * @param integer duration
466 * @returns jQuery
467 */
468 wcfFadeOut: function(callback, duration) {
469 if (!duration || !parseInt(duration)) duration = 200;
470
471 return this.hide(WCF.getEffect(this.getTagName(), 'fade'), { }, duration, callback);
472 }
473 });
474
475 /**
476 * WoltLab Community Framework core methods
477 */
478 $.extend(WCF, {
479 /**
480 * count of active dialogs
481 * @var integer
482 */
483 activeDialogs: 0,
484
485 /**
486 * Counter for dynamic element id's
487 *
488 * @var integer
489 */
490 _idCounter: 0,
491
492 /**
493 * Shows a modal dialog with a built-in AJAX-loader.
494 *
495 * @param string dialogID
496 * @param boolean resetDialog
497 * @return jQuery
498 */
499 showAJAXDialog: function(dialogID, resetDialog) {
500 if (!dialogID) {
501 dialogID = this.getRandomID();
502 }
503
504 if (!$.wcfIsset(dialogID)) {
505 $('<div id="' + dialogID + '"></div>').appendTo(document.body);
506 }
507
508 var dialog = $('#' + $.wcfEscapeID(dialogID));
509
510 if (resetDialog) {
511 dialog.empty();
512 }
513
514 var dialogOptions = arguments[2] || {};
515 dialogOptions.ajax = true;
516
517 dialog.wcfDialog(dialogOptions);
518
519 return dialog;
520 },
521
522 /**
523 * Shows a modal dialog.
524 *
525 * @param string dialogID
526 */
527 showDialog: function(dialogID) {
528 // we cannot work with a non-existant dialog, if you wish to
529 // load content via AJAX, see showAJAXDialog() instead
530 if (!$.wcfIsset(dialogID)) return;
531
532 var $dialog = $('#' + $.wcfEscapeID(dialogID));
533
534 var dialogOptions = arguments[1] || {};
535 $dialog.wcfDialog(dialogOptions);
536 },
537
538 /**
539 * Returns a dynamically created id.
540 *
541 * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/dom/dom.js#L1789
542 * @return string
543 */
544 getRandomID: function() {
545 var $elementID = '';
546
547 do {
548 $elementID = 'wcf' + this._idCounter++;
549 }
550 while ($.wcfIsset($elementID));
551
552 return $elementID;
553 },
554
555 /**
556 * Wrapper for $.inArray which returns boolean value instead of
557 * index value, similar to PHP's in_array().
558 *
559 * @param mixed needle
560 * @param array haystack
561 * @return boolean
562 */
563 inArray: function(needle, haystack) {
564 return ($.inArray(needle, haystack) != -1);
565 },
566
567 /**
568 * Adjusts effect for partially supported elements.
569 *
570 * @param object object
571 * @param string effect
572 * @return string
573 */
574 getEffect: function(tagName, effect) {
575 // most effects are not properly supported on table rows, use highlight instead
576 if (tagName == 'tr') {
577 return 'highlight';
578 }
579
580 return effect;
581 }
582 });
583
584 /**
585 * Dropdown API
586 */
587 WCF.Dropdown = {
588 /**
589 * list of callbacks
590 * @var object
591 */
592 _callbacks: { },
593
594 /**
595 * initialization state
596 * @var boolean
597 */
598 _didInit: false,
599
600 /**
601 * list of registered dropdowns
602 * @var object
603 */
604 _dropdowns: { },
605
606 /**
607 * Initializes dropdowns.
608 */
609 init: function() {
610 var $userPanelHeight = $('#topMenu').outerHeight();
611 var self = this;
612 $('.dropdownToggle').each(function(index, button) {
613 var $button = $(button);
614 if ($button.data('target')) {
615 return true;
616 }
617
618 var $dropdown = $button.parents('.dropdown');
619 if (!$dropdown.length) {
620 // broken dropdown, ignore
621 return true;
622 }
623
624 var $containerID = $dropdown.wcfIdentify();
625 if (!self._dropdowns[$containerID]) {
626 $button.click($.proxy(self._toggle, self));
627 self._dropdowns[$containerID] = $dropdown;
628
629 var $dropdownHeight = $dropdown.outerHeight();
630 var $top = $dropdownHeight + 7;
631 if ($dropdown.parents('#topMenu').length) {
632 // fix calculation for user panel (elements may be shorter than they appear)
633 $top = $userPanelHeight;
634 }
635
636 // calculate top offset for menu
637 $button.next('.dropdownMenu').css({
638 top: $top + 'px'
639 });
640 }
641
642 $button.data('target', $containerID);
643 });
644
645 if (!this._didInit) {
646 this._didInit = true;
647
648 WCF.CloseOverlayHandler.addCallback('WCF.Dropdown', $.proxy(this._closeAll, this));
649 WCF.DOMNodeInsertedHandler.addCallback('WCF.Dropdown', $.proxy(this.init, this));
650 }
651 },
652
653 /**
654 * Registers a callback notified upon dropdown state change.
655 *
656 * @param string identifier
657 * @var object callback
658 */
659 registerCallback: function(identifier, callback) {
660 if (!$.isFunction(callback)) {
661 console.debug("[WCF.Dropdown] Callback for '" + identifier + "' is invalid");
662 return false;
663 }
664
665 if (!this._callbacks[identifier]) {
666 this._callbacks[identifier] = [ ];
667 }
668
669 this._callbacks[identifier].push(callback);
670 },
671
672 /**
673 * Toggles a dropdown.
674 *
675 * @param object event
676 */
677 _toggle: function(event) {
678 var $targetID = $(event.currentTarget).data('target');
679
680 // close all dropdowns
681 for (var $containerID in this._dropdowns) {
682 var $dropdown = this._dropdowns[$containerID];
683 if ($dropdown.hasClass('dropdownOpen')) {
684 $dropdown.removeClass('dropdownOpen');
685 this._notifyCallbacks($dropdown, 'close');
686 }
687 else if ($containerID === $targetID) {
688 // fix top offset
689 var $dropdownMenu = $dropdown.find('.dropdownMenu');
690 if ($dropdownMenu.css('top') === '7px') {
691 $dropdownMenu.css({
692 top: $dropdown.outerHeight() + 7
693 });
694 }
695
696 $dropdown.addClass('dropdownOpen');
697 this._notifyCallbacks($dropdown, 'open');
698
699 this.setAlignment($dropdown);
700 }
701 }
702
703 event.stopPropagation();
704 return false;
705 },
706
707 /**
708 * Sets alignment for dropdown.
709 *
710 * @param jQuery dropdown
711 * @param jQuery dropdownMenu
712 */
713 setAlignment: function(dropdown, dropdownMenu) {
714 var $dropdownMenu = (dropdown) ? dropdown.find('.dropdownMenu:eq(0)') : dropdownMenu;
715
716 // calculate if dropdown should be right-aligned if there is not enough space
717 var $dimensions = $dropdownMenu.getDimensions('outer');
718 var $offsets = $dropdownMenu.getOffsets('offset');
719 var $windowWidth = $(window).width();
720
721 if (($offsets.left + $dimensions.width) > $windowWidth) {
722 $dropdownMenu.css({
723 left: 'auto',
724 right: '0px'
725 }).addClass('dropdownArrowRight');
726 }
727 else if ($dropdownMenu.css('right') != '0px') {
728 $dropdownMenu.css({
729 left: '0px',
730 right: 'auto'
731 }).removeClass('dropdownArrowRight');
732 }
733 },
734
735 /**
736 * Closes all dropdowns.
737 */
738 _closeAll: function() {
739 for (var $containerID in this._dropdowns) {
740 var $dropdown = this._dropdowns[$containerID];
741 if ($dropdown.hasClass('dropdownOpen')) {
742 $dropdown.removeClass('dropdownOpen');
743
744 this._notifyCallbacks($dropdown, 'close');
745 }
746 }
747 },
748
749 /**
750 * Closes a dropdown without notifying callbacks.
751 *
752 * @param string containerID
753 */
754 close: function(containerID) {
755 if (!this._dropdowns[containerID]) {
756 return;
757 }
758
759 this._dropdowns[containerID].removeClass('open');
760 },
761
762 /**
763 * Notifies callbacks.
764 *
765 * @param jQuery dropdown
766 * @param string action
767 */
768 _notifyCallbacks: function(dropdown, action) {
769 var $containerID = dropdown.wcfIdentify();
770 if (!this._callbacks[$containerID]) {
771 return;
772 }
773
774 for (var $i = 0, $length = this._callbacks[$containerID].length; $i < $length; $i++) {
775 this._callbacks[$containerID][$i](dropdown, action);
776 }
777 }
778 };
779
780 /**
781 * Clipboard API
782 */
783 WCF.Clipboard = {
784 /**
785 * action proxy object
786 * @var WCF.Action.Proxy
787 */
788 _actionProxy: null,
789
790 /**
791 * action objects
792 * @var object
793 */
794 _actionObjects: {},
795
796 /**
797 * list of clipboard containers
798 * @var jQuery
799 */
800 _container: null,
801
802 /**
803 * container meta data
804 * @var object
805 */
806 _containerData: { },
807
808 /**
809 * user has marked items
810 * @var boolean
811 */
812 _hasMarkedItems: false,
813
814 /**
815 * list of ids of marked objects
816 * @var array
817 */
818 _markedObjectIDs: [],
819
820 /**
821 * current page
822 * @var string
823 */
824 _page: '',
825
826 /**
827 * proxy object
828 * @var WCF.Action.Proxy
829 */
830 _proxy: null,
831
832 /**
833 * list of elements already tracked for clipboard actions
834 * @var object
835 */
836 _trackedElements: { },
837
838 /**
839 * Initializes the clipboard API.
840 */
841 init: function(page, hasMarkedItems, actionObjects) {
842 this._page = page;
843 this._actionObjects = actionObjects;
844 if (!actionObjects) {
845 this._actionObjects = {};
846 }
847 if (hasMarkedItems) {
848 this._hasMarkedItems = true;
849 }
850
851 this._actionProxy = new WCF.Action.Proxy({
852 success: $.proxy(this._actionSuccess, this),
853 url: 'index.php/ClipboardProxy/?t=' + SECURITY_TOKEN + SID_ARG_2ND
854 });
855
856 this._proxy = new WCF.Action.Proxy({
857 success: $.proxy(this._success, this),
858 url: 'index.php/Clipboard/?t=' + SECURITY_TOKEN + SID_ARG_2ND
859 });
860
861 // init containers first
862 this._containers = $('.jsClipboardContainer').each($.proxy(function(index, container) {
863 this._initContainer(container);
864 }, this));
865
866 // loads marked items
867 if (this._hasMarkedItems) {
868 this._loadMarkedItems();
869 }
870
871 var self = this;
872 WCF.DOMNodeInsertedHandler.addCallback('WCF.Clipboard', function() {
873 self._containers = $('.jsClipboardContainer').each($.proxy(function(index, container) {
874 self._initContainer(container);
875 }, self));
876 });
877 },
878
879 /**
880 * Loads marked items on init.
881 */
882 _loadMarkedItems: function() {
883 new WCF.Action.Proxy({
884 autoSend: true,
885 data: {
886 containerData: this._containerData,
887 pageClassName: this._page
888 },
889 success: $.proxy(this._loadMarkedItemsSuccess, this),
890 url: 'index.php/ClipboardLoadMarkedItems/?t=' + SECURITY_TOKEN + SID_ARG_2ND
891 });
892 },
893
894 /**
895 * Reloads the list of marked items.
896 */
897 reload: function() {
898 this._loadMarkedItems();
899 },
900
901 /**
902 * Marks all returned items as marked
903 *
904 * @param object data
905 * @param string textStatus
906 * @param jQuery jqXHR
907 */
908 _loadMarkedItemsSuccess: function(data, textStatus, jqXHR) {
909 this._resetMarkings();
910
911 for (var $typeName in data.markedItems) {
912 var $objectData = data.markedItems[$typeName];
913 for (var $i in $objectData) {
914 this._markedObjectIDs.push($objectData[$i]);
915 }
916
917 // loop through all containers
918 this._containers.each($.proxy(function(index, container) {
919 var $container = $(container);
920
921 // typeName does not match, continue
922 if ($container.data('type') != $typeName) {
923 return true;
924 }
925
926 // mark items as marked
927 $container.find('input.jsClipboardItem').each($.proxy(function(innerIndex, item) {
928 var $item = $(item);
929 if (WCF.inArray($item.data('objectID'), this._markedObjectIDs)) {
930 $item.attr('checked', 'checked');
931
932 // add marked class for element container
933 $item.parents('.jsClipboardObject').addClass('jsMarked');
934 }
935 }, this));
936
937 // check if there is a markAll-checkbox
938 $container.find('input.jsClipboardMarkAll').each(function(innerIndex, markAll) {
939 var $allItemsMarked = true;
940
941 $container.find('input.jsClipboardItem').each(function(itemIndex, item) {
942 var $item = $(item);
943 if (!$item.attr('checked')) {
944 $allItemsMarked = false;
945 }
946 });
947
948 if ($allItemsMarked) {
949 $(markAll).attr('checked', 'checked');
950 }
951 });
952 }, this));
953 }
954
955 // call success method to build item list editors
956 this._success(data, textStatus, jqXHR);
957 },
958
959 /**
960 * Resets all checkboxes.
961 */
962 _resetMarkings: function() {
963 this._containers.each(function(index, container) {
964 var $container = $(container);
965
966 $container.find('input.jsClipboardItem, input.jsClipboardMarkAll').removeAttr('checked');
967 $container.find('.jsClipboardObject').removeClass('jsMarked');
968 });
969 },
970
971 /**
972 * Initializes a clipboard container.
973 *
974 * @param object container
975 */
976 _initContainer: function(container) {
977 var $container = $(container);
978 var $containerID = $container.wcfIdentify();
979
980 if (!this._trackedElements[$containerID]) {
981 $container.find('.jsClipboardMarkAll').data('hasContainer', $containerID).click($.proxy(this._markAll, this));
982
983 this._containerData[$container.data('type')] = {};
984 $.each($container.data(), $.proxy(function(index, element) {
985 if (index.match(/^type(.+)/)) {
986 this._containerData[$container.data('type')][WCF.String.lcfirst(index.replace(/^type/, ''))] = element;
987 }
988 }, this));
989
990 this._trackedElements[$containerID] = [ ];
991 }
992
993 // track individual checkboxes
994 $container.find('input.jsClipboardItem').each($.proxy(function(index, input) {
995 var $input = $(input);
996 var $inputID = $input.wcfIdentify();
997
998 if (!WCF.inArray($inputID, this._trackedElements[$containerID])) {
999 this._trackedElements[$containerID].push($inputID);
1000
1001 $input.data('hasContainer', $containerID).click($.proxy(this._click, this));
1002 }
1003 }, this));
1004 },
1005
1006 /**
1007 * Processes change checkbox state.
1008 *
1009 * @param object event
1010 */
1011 _click: function(event) {
1012 var $item = $(event.target);
1013 var $objectID = $item.data('objectID');
1014 var $isMarked = ($item.attr('checked')) ? true : false;
1015 var $objectIDs = [ $objectID ];
1016
1017 if ($isMarked) {
1018 this._markedObjectIDs.push($objectID);
1019 $item.parents('.jsClipboardObject').addClass('jsMarked');
1020 }
1021 else {
1022 this._markedObjectIDs = $.removeArrayValue(this._markedObjectIDs, $objectID);
1023 $item.parents('.jsClipboardObject').removeClass('jsMarked');
1024 }
1025
1026 // item is part of a container
1027 if ($item.data('hasContainer')) {
1028 var $container = $('#' + $item.data('hasContainer'));
1029 var $type = $container.data('type');
1030
1031 // check if all items are marked
1032 var $markedAll = true;
1033 $container.find('input.jsClipboardItem').each(function(index, containerItem) {
1034 var $containerItem = $(containerItem);
1035 if (!$containerItem.attr('checked')) {
1036 $markedAll = false;
1037 }
1038 });
1039
1040 // simulate a ticked 'markAll' checkbox
1041 $container.find('.jsClipboardMarkAll').each(function(index, markAll) {
1042 if ($markedAll) {
1043 $(markAll).attr('checked', 'checked');
1044 }
1045 else {
1046 $(markAll).removeAttr('checked');
1047 }
1048 });
1049 }
1050 else {
1051 // standalone item
1052 var $type = $item.data('type');
1053 }
1054
1055 this._saveState($type, $objectIDs, $isMarked);
1056 },
1057
1058 /**
1059 * Marks all associated clipboard items as checked.
1060 *
1061 * @param object event
1062 */
1063 _markAll: function(event) {
1064 var $item = $(event.target);
1065 var $objectIDs = [ ];
1066 var $isMarked = true;
1067
1068 // if markAll object is a checkbox, allow toggling
1069 if ($item.getTagName() == 'input') {
1070 $isMarked = $item.attr('checked');
1071 }
1072
1073 // handle item containers
1074 if ($item.data('hasContainer')) {
1075 var $container = $('#' + $item.data('hasContainer'));
1076 var $type = $container.data('type');
1077
1078 // toggle state for all associated items
1079 $container.find('input.jsClipboardItem').each($.proxy(function(index, containerItem) {
1080 var $containerItem = $(containerItem);
1081 var $objectID = $containerItem.data('objectID');
1082 if ($isMarked) {
1083 if (!$containerItem.attr('checked')) {
1084 $containerItem.attr('checked', 'checked');
1085 this._markedObjectIDs.push($objectID);
1086 $objectIDs.push($objectID);
1087 }
1088 }
1089 else {
1090 if ($containerItem.attr('checked')) {
1091 $containerItem.removeAttr('checked');
1092 this._markedObjectIDs = $.removeArrayValue(this._markedObjectIDs, $objectID);
1093 $objectIDs.push($objectID);
1094 }
1095 }
1096 }, this));
1097
1098 if ($isMarked) {
1099 $container.find('.jsClipboardObject').addClass('jsMarked');
1100 }
1101 else {
1102 $container.find('.jsClipboardObject').removeClass('jsMarked');
1103 }
1104 }
1105
1106 // save new status
1107 this._saveState($type, $objectIDs, $isMarked);
1108 },
1109
1110 /**
1111 * Saves clipboard item state.
1112 *
1113 * @param string type
1114 * @param array objectIDs
1115 * @param boolean isMarked
1116 */
1117 _saveState: function(type, objectIDs, isMarked) {
1118 this._proxy.setOption('data', {
1119 action: (isMarked) ? 'mark' : 'unmark',
1120 containerData: this._containerData,
1121 objectIDs: objectIDs,
1122 pageClassName: this._page,
1123 type: type
1124 });
1125 this._proxy.sendRequest();
1126 },
1127
1128 /**
1129 * Updates editor options.
1130 *
1131 * @param object data
1132 * @param string textStatus
1133 * @param jQuery jqXHR
1134 */
1135 _success: function(data, textStatus, jqXHR) {
1136 // clear all editors first
1137 var $containers = {};
1138 $('.jsClipboardEditor').each(function(index, container) {
1139 var $container = $(container);
1140 var $types = eval($container.data('types'));
1141 for (var $i = 0, $length = $types.length; $i < $length; $i++) {
1142 var $typeName = $types[$i];
1143 $containers[$typeName] = $container;
1144 }
1145
1146 var $containerID = $container.wcfIdentify();
1147 WCF.CloseOverlayHandler.removeCallback($containerID);
1148
1149 $container.empty();
1150 });
1151
1152 // do not build new editors
1153 if (!data.items) return;
1154
1155 // rebuild editors
1156 for (var $typeName in data.items) {
1157 if (!$containers[$typeName]) {
1158 continue;
1159 }
1160
1161 // create container
1162 var $container = $containers[$typeName];
1163 var $list = $container.children('ul');
1164 if ($list.length == 0) {
1165 $list = $('<ul class="dropdown"></ul>').appendTo($container);
1166 }
1167
1168 var $editor = data.items[$typeName];
1169 var $label = $('<li><span class="dropdownToggle button">' + $editor.label + '</span></li>').appendTo($list);
1170 var $itemList = $('<ol class="dropdownMenu"></ol>').appendTo($label);
1171
1172 $label.click(function() { $list.toggleClass('dropdownOpen'); });
1173
1174 // create editor items
1175 for (var $itemIndex in $editor.items) {
1176 var $item = $editor.items[$itemIndex];
1177
1178 if ($item.actionName === 'unmarkAll') {
1179 $('<li class="dropdownDivider" />').appendTo($itemList);
1180 }
1181
1182 var $listItem = $('<li><span>' + $item.label + '</span></li>').appendTo($itemList);
1183 $listItem.data('objectType', $typeName);
1184 $listItem.data('actionName', $item.actionName).data('parameters', $item.parameters);
1185 $listItem.data('internalData', $item.internalData).data('url', $item.url).data('type', $typeName);
1186
1187 // bind event
1188 $listItem.click($.proxy(this._executeAction, this));
1189 }
1190
1191 // block click event
1192 $container.click(function(event) {
1193 event.stopPropagation();
1194 });
1195
1196 // register event handler
1197 var $containerID = $container.wcfIdentify();
1198 WCF.CloseOverlayHandler.addCallback($containerID, $.proxy(this._closeLists, this));
1199 }
1200 },
1201
1202 /**
1203 * Closes the clipboard editor item list.
1204 */
1205 _closeLists: function() {
1206 $('.jsClipboardEditor ul').removeClass('dropdownOpen');
1207 },
1208
1209 /**
1210 * Executes a clipboard editor item action.
1211 *
1212 * @param object event
1213 */
1214 _executeAction: function(event) {
1215 var $listItem = $(event.currentTarget);
1216 var $url = $listItem.data('url');
1217 if ($url) {
1218 window.location.href = $url;
1219 }
1220
1221 if ($listItem.data('parameters').className && $listItem.data('parameters').actionName) {
1222 if ($listItem.data('parameters').actionName === 'unmarkAll' || $listItem.data('parameters').objectIDs) {
1223 var $confirmMessage = $listItem.data('internalData')['confirmMessage'];
1224 if ($confirmMessage) {
1225 var $template = $listItem.data('internalData')['template'];
1226 if ($template) $template = $($template);
1227
1228 WCF.System.Confirmation.show($confirmMessage, $.proxy(function(action) {
1229 if (action === 'confirm') {
1230 var $data = { };
1231
1232 if ($template && $template.length) {
1233 $('#wcfSystemConfirmationContent').find('input, select, textarea').each(function(index, item) {
1234 var $item = $(item);
1235 $data[$item.prop('name')] = $item.val();
1236 });
1237 }
1238
1239 this._executeAJAXActions($listItem, $data);
1240 }
1241 }, this), '', $template);
1242 }
1243 else {
1244 this._executeAJAXActions($listItem, { });
1245 }
1246 }
1247 }
1248
1249 // fire event
1250 $listItem.trigger('clipboardAction', [ $listItem.data('type'), $listItem.data('actionName'), $listItem.data('parameters') ]);
1251 },
1252
1253 /**
1254 * Executes the AJAX actions for the given editor list item.
1255 *
1256 * @param jQuery listItem
1257 * @param object data
1258 */
1259 _executeAJAXActions: function(listItem, data) {
1260 data = data || { };
1261 var $objectIDs = [];
1262 if (listItem.data('parameters').actionName !== 'unmarkAll') {
1263 $.each(listItem.data('parameters').objectIDs, function(index, objectID) {
1264 $objectIDs.push(parseInt(objectID));
1265 });
1266 }
1267
1268 var $parameters = {
1269 data: data,
1270 containerData: this._containerData[listItem.data('type')]
1271 };
1272 var $__parameters = listItem.data('internalData')['parameters'];
1273 if ($__parameters !== undefined) {
1274 for (var $key in $__parameters) {
1275 $parameters[$key] = $__parameters[$key];
1276 }
1277 }
1278
1279 new WCF.Action.Proxy({
1280 autoSend: true,
1281 data: {
1282 actionName: listItem.data('parameters').actionName,
1283 className: listItem.data('parameters').className,
1284 objectIDs: $objectIDs,
1285 parameters: $parameters
1286 },
1287 success: $.proxy(function(data) {
1288 if (listItem.data('parameters').actionName !== 'unmarkAll') {
1289 listItem.trigger('clipboardActionResponse', [ data, listItem.data('type'), listItem.data('actionName'), listItem.data('parameters') ]);
1290 }
1291
1292 this._loadMarkedItems();
1293 }, this)
1294 });
1295
1296 if (this._actionObjects[listItem.data('objectType')] && this._actionObjects[listItem.data('objectType')][listItem.data('parameters').actionName]) {
1297 this._actionObjects[listItem.data('objectType')][listItem.data('parameters').actionName].triggerEffect($objectIDs);
1298 }
1299 },
1300
1301 /**
1302 * Sends a clipboard proxy request.
1303 *
1304 * @param object item
1305 */
1306 sendRequest: function(item) {
1307 var $item = $(item);
1308
1309 this._actionProxy.setOption('data', {
1310 parameters: $item.data('parameters'),
1311 typeName: $item.data('type')
1312 });
1313 this._actionProxy.sendRequest();
1314 }
1315 };
1316
1317 /**
1318 * Provides a simple call for periodical executed functions. Based upon
1319 * ideas by Prototype's PeriodicalExecuter.
1320 *
1321 * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/periodical_executer.js
1322 * @param function callback
1323 * @param integer delay
1324 */
1325 WCF.PeriodicalExecuter = Class.extend({
1326 /**
1327 * callback for each execution cycle
1328 * @var object
1329 */
1330 _callback: null,
1331
1332 /**
1333 * interval id
1334 * @var integer
1335 */
1336 _intervalID: null,
1337
1338 /**
1339 * execution state
1340 * @var boolean
1341 */
1342 _isExecuting: false,
1343
1344 /**
1345 * Initializes a periodical executer.
1346 *
1347 * @param function callback
1348 * @param integer delay
1349 */
1350 init: function(callback, delay) {
1351 if (!$.isFunction(callback)) {
1352 console.debug('[WCF.PeriodicalExecuter] Given callback is invalid, aborting.');
1353 return;
1354 }
1355
1356 this._callback = callback;
1357 this._intervalID = setInterval($.proxy(this._execute, this), delay);
1358 },
1359
1360 /**
1361 * Executes callback.
1362 */
1363 _execute: function() {
1364 if (!this._isExecuting) {
1365 try {
1366 this._isExecuting = true;
1367 this._callback(this);
1368 this._isExecuting = false;
1369 }
1370 catch (e) {
1371 this._isExecuting = false;
1372 throw e;
1373 }
1374 }
1375 },
1376
1377 /**
1378 * Terminates loop.
1379 */
1380 stop: function() {
1381 if (!this._intervalID) {
1382 return;
1383 }
1384
1385 clearInterval(this._intervalID);
1386 }
1387 });
1388
1389 /**
1390 * Namespace for AJAXProxies
1391 */
1392 WCF.Action = {};
1393
1394 /**
1395 * Basic implementation for AJAX-based proxyies
1396 *
1397 * @param object options
1398 */
1399 WCF.Action.Proxy = Class.extend({
1400 /**
1401 * count of active requests
1402 * @var integer
1403 */
1404 _activeRequests: 0,
1405
1406 /**
1407 * loading overlay
1408 * @var jQuery
1409 */
1410 _loadingOverlay: null,
1411
1412 /**
1413 * loading overlay state
1414 * @var boolean
1415 */
1416 _loadingOverlayVisible: false,
1417
1418 /**
1419 * timer for overlay activity
1420 * @var integer
1421 */
1422 _loadingOverlayVisibleTimer: 0,
1423
1424 /**
1425 * suppresses errors
1426 * @var boolean
1427 */
1428 _suppressErrors: false,
1429
1430 /**
1431 * Initializes AJAXProxy.
1432 *
1433 * @param object options
1434 */
1435 init: function(options) {
1436 // initialize default values
1437 this.options = $.extend(true, {
1438 autoSend: false,
1439 data: { },
1440 after: null,
1441 init: null,
1442 failure: null,
1443 showLoadingOverlay: true,
1444 success: null,
1445 type: 'POST',
1446 url: 'index.php/AJAXProxy/?t=' + SECURITY_TOKEN + SID_ARG_2ND
1447 }, options);
1448
1449 this.confirmationDialog = null;
1450 this.loading = null;
1451 this._suppressErrors = false;
1452
1453 // send request immediately after initialization
1454 if (this.options.autoSend) {
1455 this.sendRequest();
1456 }
1457
1458 var self = this;
1459 $(window).on('beforeunload', function() { self._suppressErrors = true; });
1460 },
1461
1462 /**
1463 * Sends an AJAX request.
1464 */
1465 sendRequest: function() {
1466 this._init();
1467
1468 $.ajax({
1469 data: this.options.data,
1470 dataType: 'json',
1471 type: this.options.type,
1472 url: this.options.url,
1473 success: $.proxy(this._success, this),
1474 error: $.proxy(this._failure, this)
1475 });
1476 },
1477
1478 /**
1479 * Fires before request is send, displays global loading status.
1480 */
1481 _init: function() {
1482 if ($.isFunction(this.options.init)) {
1483 this.options.init(this);
1484 }
1485
1486 this._activeRequests++;
1487
1488 if (this.options.showLoadingOverlay) {
1489 this._showLoadingOverlay();
1490 }
1491 },
1492
1493 /**
1494 * Displays the loading overlay if not already visible due to an active request.
1495 */
1496 _showLoadingOverlay: function() {
1497 // create loading overlay on first run
1498 if (this._loadingOverlay === null) {
1499 this._loadingOverlay = $('<div class="spinner"><img src="' + WCF.Icon.get('wcf.icon.loading') + '" alt="" class="icon48" /> <span>' + WCF.Language.get('wcf.global.loading') + '</span></div>').hide().appendTo($('body'));
1500 }
1501
1502 // fade in overlay
1503 if (!this._loadingOverlayVisible) {
1504 this._loadingOverlayVisible = true;
1505 this._loadingOverlay.stop(true, true).fadeIn(100, $.proxy(function() {
1506 new WCF.PeriodicalExecuter($.proxy(this._hideLoadingOverlay, this), 100);
1507 }, this));
1508 }
1509 },
1510
1511 /**
1512 * Hides loading overlay if no requests are active and the timer reached at least 1 second.
1513 *
1514 * @param object pe
1515 */
1516 _hideLoadingOverlay: function(pe) {
1517 this._loadingOverlayVisibleTimer += 100;
1518
1519 if (this._activeRequests == 0 && this._loadingOverlayVisibleTimer >= 100) {
1520 this._loadingOverlayVisible = false;
1521 this._loadingOverlayVisibleTimer = 0;
1522 pe.stop();
1523
1524 this._loadingOverlay.fadeOut(100);
1525 }
1526 },
1527
1528 /**
1529 * Handles AJAX errors.
1530 *
1531 * @param object jqXHR
1532 * @param string textStatus
1533 * @param string errorThrown
1534 */
1535 _failure: function(jqXHR, textStatus, errorThrown) {
1536 try {
1537 var data = $.parseJSON(jqXHR.responseText);
1538
1539 // call child method if applicable
1540 var $showError = true;
1541 if ($.isFunction(this.options.failure)) {
1542 $showError = this.options.failure(jqXHR, textStatus, errorThrown, jqXHR.responseText);
1543 }
1544
1545 if (!this._suppressErrors && $showError !== false) {
1546 $('<div class="ajaxDebugMessage"><p>' + data.message + '</p><p>Stacktrace:</p><p>' + data.stacktrace + '</p></div>').wcfDialog({ title: WCF.Language.get('wcf.global.error.title') });
1547 }
1548 }
1549 // failed to parse JSON
1550 catch (e) {
1551 // call child method if applicable
1552 var $showError = true;
1553 if ($.isFunction(this.options.failure)) {
1554 $showError = this.options.failure(jqXHR, textStatus, errorThrown, jqXHR.responseText);
1555 }
1556
1557 if (!this._suppressErrors && $showError !== false) {
1558 $('<div class="ajaxDebugMessage"><p>' + jqXHR.responseText + '</p></div>').wcfDialog({ title: WCF.Language.get('wcf.global.error.title') });
1559 }
1560 }
1561
1562 this._after();
1563 },
1564
1565 /**
1566 * Handles successful AJAX requests.
1567 *
1568 * @param object data
1569 * @param string textStatus
1570 * @param object jqXHR
1571 */
1572 _success: function(data, textStatus, jqXHR) {
1573 // enable DOMNodeInserted event
1574 WCF.DOMNodeInsertedHandler.enable();
1575
1576 // call child method if applicable
1577 if ($.isFunction(this.options.success)) {
1578 this.options.success(data, textStatus, jqXHR);
1579 }
1580
1581 this._after();
1582 },
1583
1584 /**
1585 * Fires after an AJAX request, hides global loading status.
1586 */
1587 _after: function() {
1588 if ($.isFunction(this.options.after)) {
1589 this.options.after();
1590 }
1591
1592 this._activeRequests--;
1593
1594 // disable DOMNodeInserted event
1595 WCF.DOMNodeInsertedHandler.disable();
1596 },
1597
1598 /**
1599 * Sets options, MUST be used to set parameters before sending request
1600 * if calling from child classes.
1601 *
1602 * @param string optionName
1603 * @param mixed optionData
1604 */
1605 setOption: function(optionName, optionData) {
1606 this.options[optionName] = optionData;
1607 },
1608
1609 /**
1610 * Displays a spinner image for given element.
1611 *
1612 * @param jQuery element
1613 */
1614 showSpinner: function(element) {
1615 element = $(element);
1616
1617 if (element.getTagName() !== 'img') {
1618 console.debug('The given element is not an image, aborting.');
1619 return;
1620 }
1621
1622 // force element dimensions
1623 element.attr('width', element.attr('width'));
1624 element.attr('height', element.attr('height'));
1625
1626 // replace image
1627 element.attr('src', WCF.Icon.get('wcf.global.loading'));
1628 }
1629 });
1630
1631 /**
1632 * Basic implementation for simple proxy access using bound elements.
1633 *
1634 * @param object options
1635 * @param object callbacks
1636 */
1637 WCF.Action.SimpleProxy = Class.extend({
1638 /**
1639 * Initializes SimpleProxy.
1640 *
1641 * @param object options
1642 * @param object callbacks
1643 */
1644 init: function(options, callbacks) {
1645 /**
1646 * action-specific options
1647 */
1648 this.options = $.extend(true, {
1649 action: '',
1650 className: '',
1651 elements: null,
1652 eventName: 'click'
1653 }, options);
1654
1655 /**
1656 * proxy-specific options
1657 */
1658 this.callbacks = $.extend(true, {
1659 after: null,
1660 failure: null,
1661 init: null,
1662 success: null
1663 }, callbacks);
1664
1665 if (!this.options.elements) return;
1666
1667 // initialize proxy
1668 this.proxy = new WCF.Action.Proxy(this.callbacks);
1669
1670 // bind event listener
1671 this.options.elements.each($.proxy(function(index, element) {
1672 $(element).bind(this.options.eventName, $.proxy(this._handleEvent, this));
1673 }, this));
1674 },
1675
1676 /**
1677 * Handles event actions.
1678 *
1679 * @param object event
1680 */
1681 _handleEvent: function(event) {
1682 this.proxy.setOption('data', {
1683 actionName: this.options.action,
1684 className: this.options.className,
1685 objectIDs: [ $(event.target).data('objectID') ]
1686 });
1687
1688 this.proxy.sendRequest();
1689 }
1690 });
1691
1692 /**
1693 * Basic implementation for AJAXProxy-based deletion.
1694 *
1695 * @param string className
1696 * @param string containerSelector
1697 */
1698 WCF.Action.Delete = Class.extend({
1699 /**
1700 * action class name
1701 * @var string
1702 */
1703 _className: '',
1704
1705 /**
1706 * container selector
1707 * @var string
1708 */
1709 _containerSelector: '',
1710
1711 /**
1712 * list of known container ids
1713 * @var array<string>
1714 */
1715 _containers: [ ],
1716
1717 /**
1718 * Initializes 'delete'-Proxy.
1719 *
1720 * @param string className
1721 * @param string containerSelector
1722 */
1723 init: function(className, containerSelector) {
1724 this._containerSelector = containerSelector;
1725 this._className = className;
1726 this.proxy = new WCF.Action.Proxy({
1727 success: $.proxy(this._success, this)
1728 });
1729
1730 this._initElements();
1731
1732 WCF.DOMNodeInsertedHandler.addCallback('WCF.Action.Delete' + this._className.hashCode(), $.proxy(this._initElements, this));
1733 },
1734
1735 /**
1736 * Initializes available element containers.
1737 */
1738 _initElements: function() {
1739 var self = this;
1740 $(this._containerSelector).each(function(index, container) {
1741 var $container = $(container);
1742 var $containerID = $container.wcfIdentify();
1743
1744 if (!WCF.inArray($containerID, self._containers)) {
1745 self._containers.push($containerID);
1746 $container.find('.jsDeleteButton').click($.proxy(self._click, self));
1747 }
1748 });
1749 },
1750
1751 /**
1752 * Sends AJAX request.
1753 *
1754 * @param object event
1755 */
1756 _click: function(event) {
1757 var $target = $(event.currentTarget);
1758
1759 if ($target.data('confirmMessage')) {
1760 WCF.System.Confirmation.show($target.data('confirmMessage'), $.proxy(this._execute, this), { target: $target });
1761 }
1762 else {
1763 this.proxy.showSpinner($target);
1764 this._sendRequest($target);
1765 }
1766 },
1767
1768 /**
1769 * Executes deletion.
1770 *
1771 * @param string action
1772 * @param object parameters
1773 */
1774 _execute: function(action, parameters) {
1775 if (action === 'cancel') {
1776 return;
1777 }
1778
1779 this.proxy.showSpinner(parameters.target);
1780 this._sendRequest(parameters.target);
1781 },
1782
1783 _sendRequest: function(object) {
1784 this.proxy.setOption('data', {
1785 actionName: 'delete',
1786 className: this._className,
1787 objectIDs: [ $(object).data('objectID') ]
1788 });
1789
1790 this.proxy.sendRequest();
1791 },
1792
1793 /**
1794 * Deletes items from containers.
1795 *
1796 * @param object data
1797 * @param string textStatus
1798 * @param object jqXHR
1799 */
1800 _success: function(data, textStatus, jqXHR) {
1801 this.triggerEffect(data.objectIDs);
1802 },
1803
1804 /**
1805 * Triggers the delete effect for the objects with the given ids.
1806 *
1807 * @param array objectIDs
1808 */
1809 triggerEffect: function(objectIDs) {
1810 for (var $index in this._containers) {
1811 var $container = $('#' + this._containers[$index]);
1812 if (WCF.inArray($container.find('.jsDeleteButton').data('objectID'), objectIDs)) {
1813 $container.wcfBlindOut('up', function() { $container.remove(); });
1814 }
1815 }
1816 }
1817 });
1818
1819 /**
1820 * Basic implementation for AJAXProxy-based toggle actions.
1821 *
1822 * @param string className
1823 * @param jQuery containerList
1824 * @param string toggleButtonSelector
1825 */
1826 WCF.Action.Toggle = Class.extend({
1827 /**
1828 * Initializes 'toggle'-Proxy
1829 *
1830 * @param string className
1831 * @param jQuery containerList
1832 */
1833 init: function(className, containerList, toggleButtonSelector) {
1834 if (!containerList.length) return;
1835 this.containerList = containerList;
1836 this.className = className;
1837
1838 this.toggleButtonSelector = '.jsToggleButton';
1839 if (toggleButtonSelector) {
1840 this.toggleButtonSelector = toggleButtonSelector;
1841 }
1842
1843 // initialize proxy
1844 var options = {
1845 success: $.proxy(this._success, this)
1846 };
1847 this.proxy = new WCF.Action.Proxy(options);
1848
1849 // bind event listener
1850 this.containerList.each($.proxy(function(index, container) {
1851 $(container).find(this.toggleButtonSelector).bind('click', $.proxy(this._click, this));
1852 }, this));
1853 },
1854
1855 /**
1856 * Sends AJAX request.
1857 *
1858 * @param object event
1859 */
1860 _click: function(event) {
1861 this.proxy.setOption('data', {
1862 actionName: 'toggle',
1863 className: this.className,
1864 objectIDs: [ $(event.target).data('objectID') ]
1865 });
1866
1867 this.proxy.sendRequest();
1868 },
1869
1870 /**
1871 * Toggles status icons.
1872 *
1873 * @param object data
1874 * @param string textStatus
1875 * @param object jqXHR
1876 */
1877 _success: function(data, textStatus, jqXHR) {
1878 this.triggerEffect(data.objectIDs);
1879 },
1880
1881 /**
1882 * Triggers the toggle effect for the objects with the given ids.
1883 *
1884 * @param array objectIDs
1885 */
1886 triggerEffect: function(objectIDs) {
1887 this.containerList.each($.proxy(function(index, container) {
1888 var $toggleButton = $(container).find(this.toggleButtonSelector);
1889 if (WCF.inArray($toggleButton.data('objectID'), objectIDs)) {
1890 $(container).wcfHighlight();
1891
1892 // toggle icon source
1893 $toggleButton.attr('src', function() {
1894 if (this.src.match(/disabled\.svg$/)) {
1895 return this.src.replace(/disabled\.svg$/, 'enabled.svg');
1896 }
1897 else {
1898 return this.src.replace(/enabled\.svg$/, 'disabled.svg');
1899 }
1900 });
1901
1902 // toogle icon title
1903 $toggleButton.attr('title', function() {
1904 if (this.src.match(/enabled\.svg$/)) {
1905 if ($(this).data('disableTitle')) {
1906 return $(this).data('disableTitle');
1907 }
1908
1909 return WCF.Language.get('wcf.global.button.disable');
1910 }
1911 else {
1912 if ($(this).data('enableTitle')) {
1913 return $(this).data('enableTitle');
1914 }
1915
1916 return WCF.Language.get('wcf.global.button.enable');
1917 }
1918 });
1919
1920 // toggle css class
1921 $(container).toggleClass('disabled');
1922 }
1923 }, this));
1924 }
1925 });
1926
1927 /**
1928 * Executes provided callback if scroll threshold is reached. Usuable to determine
1929 * if user reached the bottom of an element to load new elements on the fly.
1930 *
1931 * If you do not provide a value for 'reference' and 'target' it will assume you're
1932 * monitoring page scrolls, otherwise a valid jQuery selector must be provided for both.
1933 *
1934 * @param integer threshold
1935 * @param object callback
1936 * @param string reference
1937 * @param string target
1938 */
1939 WCF.Action.Scroll = Class.extend({
1940 /**
1941 * callback used once threshold is reached
1942 * @var object
1943 */
1944 _callback: null,
1945
1946 /**
1947 * reference object
1948 * @var jQuery
1949 */
1950 _reference: null,
1951
1952 /**
1953 * target object
1954 * @var jQuery
1955 */
1956 _target: null,
1957
1958 /**
1959 * threshold value
1960 * @var integer
1961 */
1962 _threshold: 0,
1963
1964 /**
1965 * Initializes a new WCF.Action.Scroll object.
1966 *
1967 * @param integer threshold
1968 * @param object callback
1969 * @param string reference
1970 * @param string target
1971 */
1972 init: function(threshold, callback, reference, target) {
1973 this._threshold = parseInt(threshold);
1974 if (this._threshold === 0) {
1975 console.debug("[WCF.Action.Scroll] Given threshold is invalid, aborting.");
1976 return;
1977 }
1978
1979 if ($.isFunction(callback)) this._callback = callback;
1980 if (this._callback === null) {
1981 console.debug("[WCF.Action.Scroll] Given callback is invalid, aborting.");
1982 return;
1983 }
1984
1985 // bind element references
1986 this._reference = $((reference) ? reference : window);
1987 this._target = $((target) ? target : document);
1988
1989 // watch for scroll event
1990 this.start();
1991 },
1992
1993 /**
1994 * Calculates if threshold is reached and notifies callback.
1995 */
1996 _scroll: function() {
1997 var $targetHeight = this._target.height();
1998 var $topOffset = this._reference.scrollTop();
1999 var $referenceHeight = this._reference.height();
2000
2001 // calculate if defined threshold is visible
2002 if (($targetHeight - ($referenceHeight + $topOffset)) < this._threshold) {
2003 this._callback(this);
2004 }
2005 },
2006
2007 /**
2008 * Enables scroll monitoring, may be used to resume.
2009 */
2010 start: function() {
2011 this._reference.on('scroll', $.proxy(this._scroll, this));
2012 },
2013
2014 /**
2015 * Disables scroll monitoring, e.g. no more elements loadable.
2016 */
2017 stop: function() {
2018 this._reference.off('scroll');
2019 }
2020 });
2021
2022 /**
2023 * Namespace for date-related functions.
2024 */
2025 WCF.Date = {};
2026
2027 /**
2028 * Provides a date picker for date input fields.
2029 */
2030 WCF.Date.Picker = {
2031 /**
2032 * Initializes the jQuery UI based date picker.
2033 */
2034 init: function() {
2035 $('input[type=date]').each(function(index, input) {
2036 // do *not* use .attr()
2037 var $input = $(input).prop('type', 'text');
2038
2039 // TODO: we should support all these braindead date formats, at least within output
2040 $input.datepicker({
2041 changeMonth: true,
2042 changeYear: true,
2043 showOtherMonths: true,
2044 dateFormat: 'yy-mm-dd',
2045 yearRange: '1900:2038' // TODO: make it configurable?
2046 });
2047 });
2048 }
2049 };
2050
2051 /**
2052 * Provides utility functions for date operations.
2053 */
2054 WCF.Date.Util = {
2055 /**
2056 * Returns UTC timestamp, if date is not given, current time will be used.
2057 *
2058 * @param Date date
2059 * @return integer
2060 */
2061 gmdate: function(date) {
2062 var $date = (date) ? date : new Date();
2063
2064 return Math.round(Date.UTC(
2065 $date.getUTCFullYear(),
2066 $date.getUTCMonth(),
2067 $date.getUTCDay(),
2068 $date.getUTCHours(),
2069 $date.getUTCMinutes(),
2070 $date.getUTCSeconds()
2071 ) / 1000);
2072 },
2073
2074 /**
2075 * Returns a Date object with precise offset (including timezone and local timezone).
2076 * Parameter timestamp must be in miliseconds!
2077 *
2078 * @param integer timestamp
2079 * @param integer offset
2080 * @return Date
2081 */
2082 getTimezoneDate: function(timestamp, offset) {
2083 var $date = new Date(timestamp);
2084 var $localOffset = $date.getTimezoneOffset() * -1 * 60000;
2085
2086 return new Date((timestamp - $localOffset - offset));
2087 }
2088 };
2089
2090 /**
2091 * Handles relative time designations.
2092 */
2093 WCF.Date.Time = Class.extend({
2094 /**
2095 * Initializes relative datetimes.
2096 */
2097 init: function() {
2098 // initialize variables
2099 this.elements = $('time.datetime');
2100 this.timestamp = 0;
2101
2102 // calculate relative datetime on init
2103 this._refresh();
2104
2105 // re-calculate relative datetime every minute
2106 new WCF.PeriodicalExecuter($.proxy(this._refresh, this), 60000);
2107
2108 // bind dom node inserted listener
2109 WCF.DOMNodeInsertedHandler.addCallback('WCF.Date.Time', $.proxy(this._domNodeInserted, this));
2110 },
2111
2112 /**
2113 * Updates element collection once a DOM node was inserted.
2114 */
2115 _domNodeInserted: function() {
2116 this.elements = $('time.datetime');
2117 this._refresh();
2118 },
2119
2120 /**
2121 * Refreshes relative datetime for each element.
2122 */
2123 _refresh: function() {
2124 // TESTING ONLY!
2125 var $date = new Date();
2126 this.timestamp = ($date.getTime() - $date.getMilliseconds()) / 1000;
2127 // TESTING ONLY!
2128
2129 this.elements.each($.proxy(this._refreshElement, this));
2130 },
2131
2132 /**
2133 * Refreshes relative datetime for current element.
2134 *
2135 * @param integer index
2136 * @param object element
2137 */
2138 _refreshElement: function(index, element) {
2139 if (!$(element).attr('title')) {
2140 $(element).attr('title', $(element).text());
2141 }
2142
2143 var $timestamp = $(element).data('timestamp');
2144 var $date = $(element).data('date');
2145 var $time = $(element).data('time');
2146 var $offset = $(element).data('offset');
2147
2148 // timestamp is in the future
2149 if ($timestamp > this.timestamp) {
2150 var $string = WCF.Language.get('wcf.date.dateTimeFormat');
2151 $(element).text($string.replace(/\%date\%/, $date).replace(/\%time\%/, $time));
2152 }
2153 // timestamp is less than 60 minutes ago (display 1 hour ago rather than 60 minutes ago)
2154 else if (this.timestamp < ($timestamp + 3540)) {
2155 var $minutes = Math.round((this.timestamp - $timestamp) / 60);
2156 $(element).text(eval(WCF.Language.get('wcf.date.relative.minutes')));
2157 }
2158 // timestamp is less than 24 hours ago
2159 else if (this.timestamp < ($timestamp + 86400)) {
2160 var $hours = Math.round((this.timestamp - $timestamp) / 3600);
2161 $(element).text(eval(WCF.Language.get('wcf.date.relative.hours')));
2162 }
2163 // timestamp is less than a week ago
2164 else if (this.timestamp < ($timestamp + 604800)) {
2165 var $days = Math.round((this.timestamp - $timestamp) / 86400);
2166 var $string = eval(WCF.Language.get('wcf.date.relative.pastDays'));
2167
2168 // get day of week
2169 var $dateObj = WCF.Date.Util.getTimezoneDate(($timestamp * 1000), $offset);
2170 var $dow = $dateObj.getDay();
2171
2172 $(element).text($string.replace(/\%day\%/, WCF.Language.get('__days')[$dow]).replace(/\%time\%/, $time));
2173 }
2174 // timestamp is between ~700 million years BC and last week
2175 else {
2176 var $string = WCF.Language.get('wcf.date.dateTimeFormat');
2177 $(element).text($string.replace(/\%date\%/, $date).replace(/\%time\%/, $time));
2178 }
2179 }
2180 });
2181
2182 /**
2183 * Hash-like dictionary. Based upon idead from Prototype's hash
2184 *
2185 * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/hash.js
2186 */
2187 WCF.Dictionary = Class.extend({
2188 /**
2189 * list of variables
2190 * @var object
2191 */
2192 _variables: { },
2193
2194 /**
2195 * Initializes a new dictionary.
2196 */
2197 init: function() {
2198 this._variables = { };
2199 },
2200
2201 /**
2202 * Adds an entry.
2203 *
2204 * @param string key
2205 * @param mixed value
2206 */
2207 add: function(key, value) {
2208 this._variables[key] = value;
2209 },
2210
2211 /**
2212 * Adds a traditional object to current dataset.
2213 *
2214 * @param object object
2215 */
2216 addObject: function(object) {
2217 for (var $key in object) {
2218 this.add($key, object[$key]);
2219 }
2220 },
2221
2222 /**
2223 * Adds a dictionary to current dataset.
2224 *
2225 * @param object dictionary
2226 */
2227 addDictionary: function(dictionary) {
2228 dictionary.each($.proxy(function(pair) {
2229 this.add(pair.key, pair.value);
2230 }, this));
2231 },
2232
2233 /**
2234 * Retrieves the value of an entry or returns null if key is not found.
2235 *
2236 * @param string key
2237 * @returns mixed
2238 */
2239 get: function(key) {
2240 if (this.isset(key)) {
2241 return this._variables[key];
2242 }
2243
2244 return null;
2245 },
2246
2247 /**
2248 * Returns true if given key is a valid entry.
2249 *
2250 * @param string key
2251 */
2252 isset: function(key) {
2253 return this._variables.hasOwnProperty(key);
2254 },
2255
2256 /**
2257 * Removes an entry.
2258 *
2259 * @param string key
2260 */
2261 remove: function(key) {
2262 delete this._variables[key];
2263 },
2264
2265 /**
2266 * Iterates through dictionary.
2267 *
2268 * Usage:
2269 * var $hash = new WCF.Dictionary();
2270 * $hash.add('foo', 'bar');
2271 * $hash.each(function(pair) {
2272 * // alerts: foo = bar
2273 * alert(pair.key + ' = ' + pair.value);
2274 * });
2275 *
2276 * @param function callback
2277 */
2278 each: function(callback) {
2279 if (!$.isFunction(callback)) {
2280 return;
2281 }
2282
2283 for (var $key in this._variables) {
2284 var $value = this._variables[$key];
2285 var $pair = {
2286 key: $key,
2287 value: $value
2288 };
2289
2290 callback($pair);
2291 }
2292 },
2293
2294 /**
2295 * Returns the amount of items.
2296 *
2297 * @return integer
2298 */
2299 count: function() {
2300 return $.getLength(this._variables);
2301 },
2302
2303 /**
2304 * Returns true, if dictionary is empty.
2305 *
2306 * @return integer
2307 */
2308 isEmpty: function() {
2309 return !this.count();
2310 }
2311 });
2312
2313 /**
2314 * Global language storage.
2315 *
2316 * @see WCF.Dictionary
2317 */
2318 WCF.Language = {
2319 _variables: new WCF.Dictionary(),
2320
2321 /**
2322 * @see WCF.Dictionary.add()
2323 */
2324 add: function(key, value) {
2325 this._variables.add(key, value);
2326 },
2327
2328 /**
2329 * @see WCF.Dictionary.addObject()
2330 */
2331 addObject: function(object) {
2332 this._variables.addObject(object);
2333 },
2334
2335 /**
2336 * Retrieves a variable.
2337 *
2338 * @param string key
2339 * @return mixed
2340 */
2341 get: function(key, parameters) {
2342 // initialize parameters with an empty object
2343 if (typeof parameters === 'undefined') var parameters = {};
2344
2345 var value = this._variables.get(key);
2346
2347 if (typeof value === 'string') {
2348 // transform strings into template and try to refetch
2349 this.add(key, new WCF.Template(value));
2350 return this.get(key, parameters);
2351 }
2352 else if (value !== null && typeof value === 'object' && typeof value.fetch !== 'undefined') {
2353 // evaluate templates
2354 value = value.fetch(parameters);
2355 }
2356 else if (value === null) {
2357 // return key again
2358 return key;
2359 }
2360
2361 return value;
2362 }
2363 };
2364
2365 /**
2366 * Handles multiple language input fields.
2367 *
2368 * @param string elementID
2369 * @param boolean forceSelection
2370 * @param object values
2371 * @param object availableLanguages
2372 */
2373 WCF.MultipleLanguageInput = Class.extend({
2374 /**
2375 * list of available languages
2376 * @var object
2377 */
2378 _availableLanguages: {},
2379
2380 /**
2381 * initialization state
2382 * @var boolean
2383 */
2384 _didInit: false,
2385
2386 /**
2387 * target input element
2388 * @var jQuery
2389 */
2390 _element: null,
2391
2392 /**
2393 * true, if data was entered after initialization
2394 * @var boolean
2395 */
2396 _insertedDataAfterInit: false,
2397
2398 /**
2399 * enables multiple language ability
2400 * @var boolean
2401 */
2402 _isEnabled: false,
2403
2404 /**
2405 * enforce multiple language ability
2406 * @var boolean
2407 */
2408 _forceSelection: false,
2409
2410 /**
2411 * currently active language id
2412 * @var integer
2413 */
2414 _languageID: 0,
2415
2416 /**
2417 * language selection list
2418 * @var jQuery
2419 */
2420 _list: null,
2421
2422 /**
2423 * list of language values on init
2424 * @var object
2425 */
2426 _values: null,
2427
2428 /**
2429 * Initializes multiple language ability for given element id.
2430 *
2431 * @param integer elementID
2432 * @param boolean forceSelection
2433 * @param boolean isEnabled
2434 * @param object values
2435 * @param object availableLanguages
2436 */
2437 init: function(elementID, forceSelection, values, availableLanguages) {
2438 this._element = $('#' + $.wcfEscapeID(elementID));
2439 this._forceSelection = forceSelection;
2440 this._values = values;
2441 this._availableLanguages = availableLanguages;
2442
2443 // default to current user language
2444 this._languageID = LANGUAGE_ID;
2445 if (this._element.length == 0) {
2446 console.debug("[WCF.MultipleLanguageInput] element id '" + elementID + "' is unknown");
2447 return;
2448 }
2449
2450 // build selection handler
2451 var $enableOnInit = ($.getLength(this._values) > 0) ? true : false;
2452 this._insertedDataAfterInit = $enableOnInit;
2453 this._prepareElement($enableOnInit);
2454
2455 // listen for submit event
2456 this._element.parents('form').submit($.proxy(this._submit, this));
2457
2458 this._didInit = true;
2459 },
2460
2461 /**
2462 * Builds language handler.
2463 *
2464 * @param boolean enableOnInit
2465 */
2466 _prepareElement: function(enableOnInit) {
2467 // enable DOMNodeInserted event
2468 WCF.DOMNodeInsertedHandler.enable();
2469
2470 this._element.wrap('<div class="dropdown preInput" />');
2471 var $wrapper = this._element.parent();
2472 var $button = $('<p class="button dropdownToggle"><span>' + WCF.Language.get('wcf.global.button.disabledI18n') + '</span></p>').prependTo($wrapper);
2473 $button.data('target', $wrapper.wcfIdentify()).click($.proxy(this._enable, this));
2474
2475 // insert list
2476 this._list = $('<ul class="dropdownMenu"></ul>').insertAfter($button);
2477
2478 // add a special class if next item is a textarea
2479 if ($button.nextAll('textarea').length) {
2480 $button.addClass('dropdownCaptionTextarea');
2481 }
2482 else {
2483 $button.addClass('dropdownCaption');
2484 }
2485
2486 // insert available languages
2487 for (var $languageID in this._availableLanguages) {
2488 $('<li><span>' + this._availableLanguages[$languageID] + '</span></li>').data('languageID', $languageID).click($.proxy(this._changeLanguage, this)).appendTo(this._list);
2489 }
2490
2491 // disable language input
2492 if (!this._forceSelection) {
2493 $('<li class="dropdownDivider" />').appendTo(this._list);
2494 $('<li><span>' + WCF.Language.get('wcf.global.button.disabledI18n') + '</span></li>').click($.proxy(this._disable, this)).appendTo(this._list);
2495 }
2496
2497 if (enableOnInit || this._forceSelection) {
2498 $button.trigger('click');
2499
2500 // pre-select current language
2501 this._list.children('li').each($.proxy(function(index, listItem) {
2502 var $listItem = $(listItem);
2503 if ($listItem.data('languageID') == this._languageID) {
2504 $listItem.trigger('click');
2505 }
2506 }, this));
2507 }
2508
2509 WCF.Dropdown.registerCallback($wrapper.wcfIdentify(), $.proxy(this._handleAction, this));
2510
2511 // disable DOMNodeInserted event
2512 WCF.DOMNodeInsertedHandler.disable();
2513 },
2514
2515 /**
2516 * Handles dropdown actions.
2517 *
2518 * @param jQuery dropdown
2519 * @param string action
2520 */
2521 _handleAction: function(dropdown, action) {
2522 if (action === 'close') {
2523 this._closeSelection();
2524 }
2525 },
2526
2527 /**
2528 * Enables the language selection or shows the selection if already enabled.
2529 *
2530 * @param object event
2531 */
2532 _enable: function(event) {
2533 if (!this._isEnabled) {
2534 var $button = $(event.currentTarget);
2535 $button.next('.dropdownMenu').css({
2536 top: ($button.outerHeight() - 1) + 'px'
2537 });
2538
2539 if ($button.getTagName() === 'p') {
2540 $button = $button.children('span:eq(0)');
2541 }
2542
2543 $button.addClass('active');
2544
2545 this._isEnabled = true;
2546 }
2547
2548 // toggle list
2549 if (this._list.is(':visible')) {
2550 this._showSelection();
2551 }
2552
2553 // discard event
2554 event.stopPropagation();
2555 },
2556
2557 /**
2558 * Shows the language selection.
2559 */
2560 _showSelection: function() {
2561 if (this._isEnabled) {
2562 // display status for each language
2563 this._list.children('li').each($.proxy(function(index, listItem) {
2564 var $listItem = $(listItem);
2565 var $languageID = $listItem.data('languageID');
2566
2567 if ($languageID) {
2568 if (this._values[$languageID] && this._values[$languageID] != '') {
2569 $listItem.removeClass('missingValue');
2570 }
2571 else {
2572 $listItem.addClass('missingValue');
2573 }
2574 }
2575 }, this));
2576 }
2577 },
2578
2579 /**
2580 * Closes the language selection.
2581 */
2582 _closeSelection: function() {
2583 this._disable();
2584 },
2585
2586 /**
2587 * Changes the currently active language.
2588 *
2589 * @param object event
2590 */
2591 _changeLanguage: function(event) {
2592 var $button = $(event.currentTarget);
2593 this._insertedDataAfterInit = true;
2594
2595 // save current value
2596 if (this._didInit) {
2597 this._values[this._languageID] = this._element.val();
2598 }
2599
2600 // set new language
2601 this._languageID = $button.data('languageID');
2602 if (this._values[this._languageID]) {
2603 this._element.val(this._values[this._languageID]);
2604 }
2605 else {
2606 this._element.val('');
2607 }
2608
2609 // update marking
2610 this._list.children('li').removeClass('active');
2611 $button.addClass('active');
2612
2613 // update label
2614 this._list.prev('.dropdownToggle').children('span').text(this._availableLanguages[this._languageID]);
2615
2616 // close selection and set focus on input element
2617 //this._closeSelection();
2618 this._element.blur().focus();
2619 },
2620
2621 /**
2622 * Disables language selection for current element.
2623 *
2624 * @param object event
2625 */
2626 _disable: function(event) {
2627 if (event === undefined && this._insertedDataAfterInit) {
2628 event = null;
2629 }
2630
2631 if (this._forceSelection || !this._list || event === null) {
2632 return;
2633 }
2634
2635 // remove active marking
2636 this._list.prev('.dropdownToggle').children('span').removeClass('active').text(WCF.Language.get('wcf.global.button.disabledI18n'));
2637
2638 // update element value
2639 if (this._values[LANGUAGE_ID]) {
2640 this._element.val(this._values[LANGUAGE_ID]);
2641 }
2642 else {
2643 // no value for current language found, proceed with empty input
2644 this._element.val();
2645 }
2646
2647 this._element.blur();
2648 this._insertedDataAfterInit = false;
2649 this._isEnabled = false;
2650 this._values = { };
2651 },
2652
2653 /**
2654 * Prepares language variables on before submit.
2655 */
2656 _submit: function() {
2657 // insert hidden form elements on before submit
2658 if (!this._isEnabled) {
2659 return 0xDEADBEEF;
2660 }
2661
2662 // fetch active value
2663 if (this._languageID) {
2664 this._values[this._languageID] = this._element.val();
2665 }
2666
2667 var $form = $(this._element.parents('form')[0]);
2668 var $elementID = this._element.wcfIdentify();
2669
2670 for (var $languageID in this._values) {
2671 $('<input type="hidden" name="' + $elementID + '_i18n[' + $languageID + ']" value="' + this._values[$languageID] + '" />').appendTo($form);
2672 }
2673
2674 // remove name attribute to prevent conflict with i18n values
2675 this._element.removeAttr('name');
2676 }
2677 });
2678
2679 /**
2680 * Icon collection used across all JavaScript classes.
2681 *
2682 * @see WCF.Dictionary
2683 */
2684 WCF.Icon = {
2685 /**
2686 * list of icons
2687 * @var WCF.Dictionary
2688 */
2689 _icons: new WCF.Dictionary(),
2690
2691 /**
2692 * @see WCF.Dictionary.add()
2693 */
2694 add: function(name, path) {
2695 this._icons.add(name, path);
2696 },
2697
2698 /**
2699 * @see WCF.Dictionary.addObject()
2700 */
2701 addObject: function(object) {
2702 this._icons.addObject(object);
2703 },
2704
2705 /**
2706 * @see WCF.Dictionary.get()
2707 */
2708 get: function(name) {
2709 return this._icons.get(name);
2710 }
2711 };
2712
2713 /**
2714 * Number utilities.
2715 */
2716 WCF.Number = {
2717 /**
2718 * Rounds a number to a given number of floating points digits. Defaults to 0.
2719 *
2720 * @param number number
2721 * @param floatingPoint number of digits
2722 * @return number
2723 */
2724 round: function (number, floatingPoint) {
2725 floatingPoint = Math.pow(10, (floatingPoint || 0));
2726
2727 return Math.round(number * floatingPoint) / floatingPoint;
2728 }
2729 };
2730
2731 /**
2732 * String utilities.
2733 */
2734 WCF.String = {
2735 /**
2736 * Adds thousands separators to a given number.
2737 *
2738 * @param mixed number
2739 * @return string
2740 */
2741 addThousandsSeparator: function(number) {
2742 var $numberString = String(number);
2743 var parts = $numberString.split(/[^0-9]+/);
2744
2745 var $decimalPoint = $numberString.match(/[^0-9]+/);
2746
2747 $numberString = parts[0];
2748 var $decimalPart = '';
2749 if ($decimalPoint !== null) {
2750 delete parts[0];
2751 var $decimalPart = $decimalPoint.join('')+parts.join('');
2752 }
2753 if (parseInt(number) >= 1000 || parseInt(number) <= -1000) {
2754 var $negative = false;
2755 if (parseInt(number) <= -1000) {
2756 $negative = true;
2757 $numberString = $numberString.substring(1);
2758 }
2759 var $separator = WCF.Language.get('wcf.global.thousandsSeparator');
2760
2761 if ($separator != null && $separator != '') {
2762 var $numElements = new Array();
2763 var $firstPart = $numberString.length % 3;
2764 if ($firstPart == 0) $firstPart = 3;
2765 for (var $i = 0; $i < Math.ceil($numberString.length / 3); $i++) {
2766 if ($i == 0) $numElements.push($numberString.substring(0, $firstPart));
2767 else {
2768 var $start = (($i - 1) * 3) + $firstPart;
2769 $numElements.push($numberString.substring($start, $start + 3));
2770 }
2771 }
2772 $numberString = (($negative) ? ('-') : ('')) + $numElements.join($separator);
2773 }
2774 }
2775
2776 return $numberString + $decimalPart;
2777 },
2778
2779 /**
2780 * Escapes special HTML-characters within a string
2781 *
2782 * @param string string
2783 * @return string
2784 */
2785 escapeHTML: function (string) {
2786 return string.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2787 },
2788
2789 /**
2790 * Escapes a String to work with RegExp.
2791 *
2792 * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/regexp.js#L25
2793 * @param string string
2794 * @return string
2795 */
2796 escapeRegExp: function(string) {
2797 return string.replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1');
2798 },
2799
2800 /**
2801 * Rounds number to given count of floating point digits, localizes decimal-point and inserts thousands-separators
2802 *
2803 * @param mixed number
2804 * @return string
2805 */
2806 formatNumeric: function(number, floatingPoint) {
2807 number = String(WCF.Number.round(number, floatingPoint || 2));
2808 number = number.replace('.', WCF.Language.get('wcf.global.decimalPoint'));
2809
2810 return this.addThousandsSeparator(number);
2811 },
2812
2813 /**
2814 * Makes a string's first character lowercase
2815 *
2816 * @param string string
2817 * @return string
2818 */
2819 lcfirst: function(string) {
2820 return string.substring(0, 1).toLowerCase() + string.substring(1);
2821 },
2822
2823 /**
2824 * Makes a string's first character uppercase
2825 *
2826 * @param string string
2827 * @return string
2828 */
2829 ucfirst: function(string) {
2830 return string.substring(0, 1).toUpperCase() + string.substring(1);
2831 }
2832 };
2833
2834 /**
2835 * Basic implementation for WCF TabMenus. Use the data attributes 'active' to specify the
2836 * tab which should be shown on init. Furthermore you may specify a 'store' data-attribute
2837 * which will be filled with the currently selected tab.
2838 */
2839 WCF.TabMenu = {
2840 /**
2841 * list of tabmenu containers
2842 * @var object
2843 */
2844 _containers: { },
2845
2846 /**
2847 * initialization state
2848 * @var boolean
2849 */
2850 _didInit: false,
2851
2852 /**
2853 * Initializes all TabMenus
2854 */
2855 init: function() {
2856 var $containers = $('.tabMenuContainer');
2857 var self = this;
2858 $containers.each(function(index, tabMenu) {
2859 var $tabMenu = $(tabMenu);
2860 var $containerID = $tabMenu.wcfIdentify();
2861 if (self._containers[$containerID]) {
2862 // continue with next container
2863 return true;
2864 }
2865
2866 if ($tabMenu.data('store') && !$('#' + $tabMenu.data('store')).length) {
2867 $('<input type="hidden" name="' + $tabMenu.data('store') + '" value="" id="' + $tabMenu.data('store') + '" />').appendTo($tabMenu.parents('form').find('.formSubmit'));
2868 }
2869
2870 // init jQuery UI TabMenu
2871 self._containers[$containerID] = $tabMenu;
2872 $tabMenu.wcfTabs({
2873 select: function(event, ui) {
2874 var $panel = $(ui.panel);
2875 var $container = $panel.closest('.tabMenuContainer');
2876
2877 // store currently selected item
2878 var $tabMenu = $container;
2879 while (true) {
2880 // do not trigger on init
2881 if ($tabMenu.data('isParent') === undefined) {
2882 break;
2883 }
2884
2885 if ($tabMenu.data('isParent')) {
2886 if ($tabMenu.data('store')) {
2887 $('#' + $tabMenu.data('store')).val($panel.attr('id'));
2888 }
2889
2890 break;
2891 }
2892 else {
2893 $tabMenu = $tabMenu.data('parent');
2894 }
2895 }
2896
2897 // set panel id as location hash
2898 if (WCF.TabMenu._didInit) {
2899 location.hash = '#' + $panel.attr('id');
2900 }
2901
2902 $container.trigger('tabsselect', event, ui);
2903 }
2904 });
2905
2906 $tabMenu.data('isParent', ($tabMenu.children('.tabMenuContainer, .tabMenuContent').length > 0)).data('parent', false);
2907 if (!$tabMenu.data('isParent')) {
2908 // check if we're a child element
2909 if ($tabMenu.parent().hasClass('tabMenuContainer')) {
2910 $tabMenu.data('parent', $tabMenu.parent());
2911 }
2912 }
2913 });
2914
2915 // try to resolve location hash
2916 if (!this._didInit) {
2917 this.selectTabs();
2918 $(window).bind('hashchange', $.proxy(this.selectTabs, this));
2919
2920 if (!this._selectErroneousTab()) {
2921 this._selectActiveTab();
2922 }
2923 }
2924
2925 this._didInit = true;
2926 },
2927
2928 /**
2929 * Force display of first erroneous tab, returns true, if at
2930 * least one tab contains an error.
2931 *
2932 * @return boolean
2933 */
2934 _selectErroneousTab: function() {
2935 for (var $containerID in this._containers) {
2936 var $tabMenu = this._containers[$containerID];
2937
2938 if (!$tabMenu.data('isParent') && $tabMenu.find('.formError').length) {
2939 while (true) {
2940 if ($tabMenu.data('parent') === false) {
2941 break;
2942 }
2943
2944 $tabMenu = $tabMenu.data('parent').wcfTabs('select', $tabMenu.wcfIdentify());
2945 }
2946
2947 return true;
2948 }
2949 }
2950
2951 return false;
2952 },
2953
2954 /**
2955 * Selects the active tab menu item.
2956 */
2957 _selectActiveTab: function() {
2958 for (var $containerID in this._containers) {
2959 var $tabMenu = this._containers[$containerID];
2960 if ($tabMenu.data('active')) {
2961 var $index = $tabMenu.data('active');
2962 var $subIndex = null;
2963 if (/-/.test($index)) {
2964 var $tmp = $index.split('-');
2965 $index = $tmp[0];
2966 $subIndex = $tmp[1];
2967 }
2968
2969 $tabMenu.find('.tabMenuContent').each(function(innerIndex, tabMenuItem) {
2970 var $tabMenuItem = $(tabMenuItem);
2971 if ($tabMenuItem.wcfIdentify() == $index) {
2972 $tabMenu.wcfTabs('select', innerIndex);
2973
2974 if ($subIndex !== null) {
2975 if ($tabMenuItem.hasClass('tabMenuContainer')) {
2976 $tabMenuItem.wcfTabs('select', $tabMenu.data('active'));
2977 }
2978 else {
2979 $tabMenu.wcfTabs('select', $tabMenu.data('active'));
2980 }
2981 }
2982
2983 return false;
2984 }
2985 });
2986 }
2987 }
2988 },
2989
2990 /**
2991 * Resolves location hash to display tab menus.
2992 */
2993 selectTabs: function() {
2994 if (location.hash) {
2995 var $hash = location.hash.substr(1);
2996 var $subIndex = null;
2997 if (/-/.test(location.hash)) {
2998 var $tmp = $hash.split('-');
2999 $hash = $tmp[0];
3000 $subIndex = $tmp[1];
3001 }
3002
3003 // find a container which matches the first part
3004 for (var $containerID in this._containers) {
3005 var $tabMenu = this._containers[$containerID];
3006 if ($tabMenu.wcfTabs('hasAnchor', $hash, false)) {
3007 if ($subIndex !== null) {
3008 // try to find child tabMenu
3009 var $childTabMenu = $tabMenu.find('#' + $.wcfEscapeID($hash) + '.tabMenuContainer');
3010 if ($childTabMenu.length !== 1) {
3011 return;
3012 }
3013
3014 // validate match for second part
3015 if (!$childTabMenu.wcfTabs('hasAnchor', $subIndex, true)) {
3016 return;
3017 }
3018
3019 $childTabMenu.wcfTabs('select', $hash + '-' + $subIndex);
3020 }
3021
3022 $tabMenu.wcfTabs('select', $hash);
3023 return;
3024 }
3025 }
3026 }
3027 }
3028 };
3029
3030 /**
3031 * Templates that may be fetched more than once with different variables.
3032 * Based upon ideas from Prototype's template.
3033 *
3034 * Usage:
3035 * var myTemplate = new WCF.Template('{$hello} World');
3036 * myTemplate.fetch({ hello: 'Hi' }); // Hi World
3037 * myTemplate.fetch({ hello: 'Hello' }); // Hello World
3038 *
3039 * my2ndTemplate = new WCF.Template('{@$html}{$html}');
3040 * my2ndTemplate.fetch({ html: '<b>Test</b>' }); // <b>Test</b>&lt;b&gt;Test&lt;/b&gt;
3041 *
3042 * var my3rdTemplate = new WCF.Template('You can use {literal}{$variable}{/literal}-Tags here');
3043 * my3rdTemplate.fetch({ variable: 'Not shown' }); // You can use {$variable}-Tags here
3044 *
3045 * @param template template-content
3046 * @see https://github.com/sstephenson/prototype/blob/master/src/prototype/lang/template.js
3047 */
3048 WCF.Template = Class.extend({
3049 /**
3050 * template content
3051 * @var string
3052 */
3053 _template: '',
3054
3055 /**
3056 * saved literal tags
3057 * @var WCF.Dictionary
3058 */
3059 _literals: new WCF.Dictionary(),
3060
3061 /**
3062 * Prepares template
3063 *
3064 * @param $template template-content
3065 */
3066 init: function($template) {
3067 this._template = $template;
3068
3069 // save literal-tags
3070 this._template = this._template.replace(/\{literal\}(.*?)\{\/literal\}/g, $.proxy(function ($match) {
3071 // hopefully no one uses this string in one of his templates
3072 var id = '@@@@@@@@@@@'+Math.random()+'@@@@@@@@@@@';
3073 this._literals.add(id, $match.replace(/\{\/?literal\}/g, ''));
3074
3075 return id;
3076 }, this));
3077 },
3078
3079 /**
3080 * Fetches the template with the given variables
3081 *
3082 * @param $variables variables to insert
3083 * @return parsed template
3084 */
3085 fetch: function($variables) {
3086 var $result = this._template;
3087
3088 // insert them :)
3089 for (var $key in $variables) {
3090 $result = $result.replace(new RegExp(WCF.String.escapeRegExp('{$'+$key+'}'), 'g'), WCF.String.escapeHTML(new String($variables[$key])));
3091 $result = $result.replace(new RegExp(WCF.String.escapeRegExp('{#$'+$key+'}'), 'g'), WCF.String.formatNumeric($variables[$key]));
3092 $result = $result.replace(new RegExp(WCF.String.escapeRegExp('{@$'+$key+'}'), 'g'), $variables[$key]);
3093 }
3094
3095 // insert delimiter tags
3096 $result = $result.replace('{ldelim}', '{').replace('{rdelim}', '}');
3097
3098 // and re-insert saved literals
3099 return this.insertLiterals($result);
3100 },
3101
3102 /**
3103 * Inserts literals into given string
3104 *
3105 * @param $template string to insert into
3106 * @return string with inserted literals
3107 */
3108 insertLiterals: function ($template) {
3109 this._literals.each(function ($pair) {
3110 $template = $template.replace($pair.key, $pair.value);
3111 });
3112
3113 return $template;
3114 },
3115
3116 /**
3117 * Compiles this template into javascript-code
3118 *
3119 * @return WCF.Template.Compiled
3120 */
3121 compile: function () {
3122 var $compiled = this._template;
3123
3124 // escape \ and '
3125 $compiled = $compiled.replace('\\', '\\\\').replace("'", "\\'");
3126
3127 // parse our variable-tags
3128 $compiled = $compiled.replace(/\{\$(.*?)\}/g, function ($match) {
3129 var $name = '$v.' + $match.substring(2, $match.length - 1);
3130 // trinary operator to maintain compatibility with uncompiled template
3131 // ($name) ? $name : '$match'
3132 // -> $v.muh ? $v.muh : '{$muh}'
3133 return "' + WCF.String.escapeHTML("+ $name + " ? " + $name + " : '" + $match + "') + '";
3134 }).replace(/\{#\$(.*?)\}/g, function ($match) {
3135 var $name = '$v.' + $match.substring(3, $match.length - 1);
3136 // trinary operator to maintain compatibility with uncompiled template
3137 // ($name) ? $name : '$match'
3138 // -> $v.muh ? $v.muh : '{$muh}'
3139 return "' + WCF.String.formatNumeric("+ $name + " ? " + $name + " : '" + $match + "') + '";
3140 }).replace(/\{@\$(.*?)\}/g, function ($match) {
3141 var $name = '$v.' + $match.substring(3, $match.length - 1);
3142 // trinary operator to maintain compatibility with uncompiled template
3143 // ($name) ? $name : '$match'
3144 // -> $v.muh ? $v.muh : '{$muh}'
3145 return "' + ("+ $name + " ? " + $name + " : '" + $match + "') + '";
3146 });
3147
3148 // insert delimiter tags
3149 $compiled = $compiled.replace('{ldelim}', '{').replace('{rdelim}', '}');
3150
3151 // escape newlines
3152 $compiled = $compiled.replace(/(\r\n|\n|\r)/g, '\\n');
3153
3154 // and re-insert saved literals
3155 return new WCF.Template.Compiled("'" + this.insertLiterals($compiled) + "';");
3156 }
3157 });
3158
3159 /**
3160 * Represents a compiled template
3161 *
3162 * @param compiled compiled template
3163 */
3164 WCF.Template.Compiled = Class.extend({
3165 /**
3166 * Compiled template
3167 *
3168 * @var string
3169 */
3170 _compiled: '',
3171
3172 /**
3173 * Initializes our compiled template
3174 *
3175 * @param $compiled compiled template
3176 */
3177 init: function($compiled) {
3178 this._compiled = $compiled;
3179 },
3180
3181 /**
3182 * @see WCF.Template.fetch
3183 */
3184 fetch: function($v) {
3185 return eval(this._compiled);
3186 }
3187 });
3188
3189 /**
3190 * Toggles options.
3191 *
3192 * @param string element
3193 * @param array showItems
3194 * @param array hideItems
3195 */
3196 WCF.ToggleOptions = Class.extend({
3197 /**
3198 * target item
3199 *
3200 * @var jQuery
3201 */
3202 _element: null,
3203
3204 /**
3205 * list of items to be shown
3206 *
3207 * @var array
3208 */
3209 _showItems: [],
3210
3211 /**
3212 * list of items to be hidden
3213 *
3214 * @var array
3215 */
3216 _hideItems: [],
3217
3218 /**
3219 * Initializes option toggle.
3220 *
3221 * @param string element
3222 * @param array showItems
3223 * @param array hideItems
3224 */
3225 init: function(element, showItems, hideItems) {
3226 this._element = $('#' + element);
3227 this._showItems = showItems;
3228 this._hideItems = hideItems;
3229
3230 // bind event
3231 this._element.click($.proxy(this._toggle, this));
3232
3233 // execute toggle on init
3234 this._toggle();
3235 },
3236
3237 /**
3238 * Toggles items.
3239 */
3240 _toggle: function() {
3241 if (!this._element.attr('checked')) return;
3242
3243 for (var $i = 0, $length = this._showItems.length; $i < $length; $i++) {
3244 var $item = this._showItems[$i];
3245
3246 $('#' + $item).show();
3247 }
3248
3249 for (var $i = 0, $length = this._hideItems.length; $i < $length; $i++) {
3250 var $item = this._hideItems[$i];
3251
3252 $('#' + $item).hide();
3253 }
3254 }
3255 });
3256
3257 /**
3258 * Namespace for all kind of collapsible containers.
3259 */
3260 WCF.Collapsible = {};
3261
3262 /**
3263 * Simple implementation for collapsible content, neither does it
3264 * store its state nor does it allow AJAX callbacks to fetch content.
3265 */
3266 WCF.Collapsible.Simple = {
3267 /**
3268 * Initializes collapsibles.
3269 */
3270 init: function() {
3271 $('.jsCollapsible').each($.proxy(function(index, button) {
3272 this._initButton(button);
3273 }, this));
3274 },
3275
3276 /**
3277 * Binds an event listener on all buttons triggering the collapsible.
3278 *
3279 * @param object button
3280 */
3281 _initButton: function(button) {
3282 var $button = $(button);
3283 var $isOpen = $button.data('isOpen');
3284
3285 if (!$isOpen) {
3286 // hide container on init
3287 $('#' + $button.data('collapsibleContainer')).hide();
3288 }
3289
3290 $button.click($.proxy(this._toggle, this));
3291 },
3292
3293 /**
3294 * Toggles collapsible containers on click.
3295 *
3296 * @param object event
3297 */
3298 _toggle: function(event) {
3299 var $button = $(event.currentTarget);
3300 var $isOpen = $button.data('isOpen');
3301 var $target = $('#' + $.wcfEscapeID($button.data('collapsibleContainer')));
3302
3303 if ($isOpen) {
3304 $target.stop().wcfBlindOut('vertical', $.proxy(function() {
3305 this._toggleImage($button, 'wcf.icon.closed');
3306 }, this));
3307 $isOpen = false;
3308 }
3309 else {
3310 $target.stop().wcfBlindIn('vertical', $.proxy(function() {
3311 this._toggleImage($button, 'wcf.icon.opened');
3312 }, this));
3313 $isOpen = true;
3314 }
3315
3316 $button.data('isOpen', $isOpen);
3317
3318 // suppress event
3319 event.stopPropagation();
3320 return false;
3321 },
3322
3323 /**
3324 * Toggles image of target button.
3325 *
3326 * @param jQuery button
3327 * @param string image
3328 */
3329 _toggleImage: function(button, image) {
3330 var $icon = WCF.Icon.get(image);
3331 var $image = button.find('img');
3332
3333 if ($image.length) {
3334 $image.attr('src', $icon);
3335 }
3336 }
3337 };
3338
3339 /**
3340 * Basic implementation for collapsible containers with AJAX support. Results for open
3341 * and closed state will be cached.
3342 *
3343 * @param string className
3344 */
3345 WCF.Collapsible.Remote = Class.extend({
3346 /**
3347 * class name
3348 * @var string
3349 */
3350 _className: '',
3351
3352 /**
3353 * list of active containers
3354 * @var object
3355 */
3356 _containers: {},
3357
3358 /**
3359 * container meta data
3360 * @var object
3361 */
3362 _containerData: {},
3363
3364 /**
3365 * action proxy
3366 * @var WCF.Action.Proxy
3367 */
3368 _proxy: null,
3369
3370 /**
3371 * Initializes the controller for collapsible containers with AJAX support.
3372 *
3373 * @param string className
3374 */
3375 init: function(className) {
3376 this._className = className;
3377
3378 // validate containers
3379 var $containers = this._getContainers();
3380 if ($containers.length == 0) {
3381 console.debug('[WCF.Collapsible.Remote] Empty container set given, aborting.');
3382 }
3383
3384 this._proxy = new WCF.Action.Proxy({
3385 success: $.proxy(this._success, this)
3386 });
3387
3388 // initialize each container
3389 $containers.each($.proxy(function(index, container) {
3390 var $container = $(container);
3391 var $containerID = $container.wcfIdentify();
3392 this._containers[$containerID] = $container;
3393
3394 this._initContainer($containerID);
3395 }, this));
3396 },
3397
3398 /**
3399 * Initializes a collapsible container.
3400 *
3401 * @param string containerID
3402 */
3403 _initContainer: function(containerID) {
3404 var $target = this._getTarget(containerID);
3405 var $buttonContainer = this._getButtonContainer(containerID);
3406 var $button = this._createButton(containerID, $buttonContainer);
3407
3408 // store container meta data
3409 this._containerData[containerID] = {
3410 button: $button,
3411 buttonContainer: $buttonContainer,
3412 isOpen: this._containers[containerID].data('isOpen'),
3413 target: $target
3414 };
3415 },
3416
3417 /**
3418 * Returns a collection of collapsible containers.
3419 *
3420 * @return jQuery
3421 */
3422 _getContainers: function() { },
3423
3424 /**
3425 * Returns the target element for current collapsible container.
3426 *
3427 * @param integer containerID
3428 * @return jQuery
3429 */
3430 _getTarget: function(containerID) { },
3431
3432 /**
3433 * Returns the button container for current collapsible container.
3434 *
3435 * @param integer containerID
3436 * @return jQuery
3437 */
3438 _getButtonContainer: function(containerID) { },
3439
3440 /**
3441 * Creates the toggle button.
3442 *
3443 * @param integer containerID
3444 * @param jQuery buttonContainer
3445 */
3446 _createButton: function(containerID, buttonContainer) {
3447 var $isOpen = this._containers[containerID].data('isOpen');
3448 var $button = $('<a class="collapsibleButton jsTooltip" title="'+WCF.Language.get('wcf.global.button.collapsible')+'"><img src="' + WCF.Icon.get('wcf.icon.' + ($isOpen ? 'opened' : 'closed')) + '" alt="" class="icon16" /></a>').prependTo(buttonContainer);
3449 $button.data('containerID', containerID).click($.proxy(this._toggleContainer, this));
3450
3451 return $button;
3452 },
3453
3454 /**
3455 * Toggles a container.
3456 *
3457 * @param object event
3458 */
3459 _toggleContainer: function(event) {
3460 var $button = $(event.currentTarget);
3461 var $containerID = $button.data('containerID');
3462 var $isOpen = this._containerData[$containerID].isOpen;
3463 var $state = ($isOpen) ? 'open' : 'close';
3464 var $newState = ($isOpen) ? 'close' : 'open';
3465
3466 // fetch content state via AJAX
3467 this._proxy.setOption('data', {
3468 actionName: 'loadContainer',
3469 className: this._className,
3470 objectIDs: [ this._getObjectID($containerID) ],
3471 parameters: $.extend(true, {
3472 containerID: $containerID,
3473 currentState: $state,
3474 newState: $newState
3475 }, this._getAdditionalParameters($containerID))
3476 });
3477 this._proxy.sendRequest();
3478
3479 // set spinner for current button
3480 this._exchangeIcon($button);
3481 },
3482
3483 /**
3484 * Exchanges button icon.
3485 *
3486 * @param jQuery button
3487 * @param string newIcon
3488 */
3489 _exchangeIcon: function(button, newIcon) {
3490 newIcon = newIcon || WCF.Icon.get('wcf.icon.loading');
3491 button.find('img').attr('src', newIcon);
3492 },
3493
3494 /**
3495 * Returns the object id for current container.
3496 *
3497 * @param integer containerID
3498 * @return integer
3499 */
3500 _getObjectID: function(containerID) {
3501 return $('#' + containerID).data('objectID');
3502 },
3503
3504 /**
3505 * Returns additional parameters.
3506 *
3507 * @param integer containerID
3508 * @return object
3509 */
3510 _getAdditionalParameters: function(containerID) {
3511 return {};
3512 },
3513
3514 /**
3515 * Updates container content.
3516 *
3517 * @param integer containerID
3518 * @param string newContent
3519 * @param string newState
3520 */
3521 _updateContent: function(containerID, newContent, newState) {
3522 this._containerData[containerID].target.html(newContent);
3523 },
3524
3525 /**
3526 * Sets content upon successfull AJAX request.
3527 *
3528 * @param object data
3529 * @param string textStatus
3530 * @param jQuery jqXHR
3531 */
3532 _success: function(data, textStatus, jqXHR) {
3533 // validate container id
3534 if (!data.returnValues.containerID) return;
3535 var $containerID = data.returnValues.containerID;
3536
3537 // check if container id is known
3538 if (!this._containers[$containerID]) return;
3539
3540 // update content storage
3541 this._containerData[$containerID].isOpen = (data.returnValues.isOpen) ? true : false;
3542 var $newState = (data.returnValues.isOpen) ? 'open' : 'close';
3543
3544 // update container content
3545 this._updateContent($containerID, data.returnValues.content, $newState);
3546
3547 // update icon
3548 this._exchangeIcon(this._containerData[$containerID].button, WCF.Icon.get('wcf.icon.' + (data.returnValues.isOpen ? 'opened' : 'closed')));
3549 }
3550 });
3551
3552 /**
3553 * Basic implementation for collapsible containers with AJAX support. Requires collapsible
3554 * content to be available in DOM already, if you want to load content on the fly use
3555 * WCF.Collapsible.Remote instead.
3556 */
3557 WCF.Collapsible.SimpleRemote = WCF.Collapsible.Remote.extend({
3558 /**
3559 * Initializes an AJAX-based collapsible handler.
3560 *
3561 * @param string className
3562 */
3563 init: function(className) {
3564 this._super(className);
3565
3566 // override settings for action proxy
3567 this._proxy = new WCF.Action.Proxy({
3568 showLoadingOverlay: false
3569 });
3570 },
3571
3572 /**
3573 * @see WCF.Collapsible.Remote._initContainer()
3574 */
3575 _initContainer: function(containerID) {
3576 this._super(containerID);
3577
3578 // hide container on init if applicable
3579 if (!this._containerData[containerID].isOpen) {
3580 this._containerData[containerID].target.hide();
3581 this._exchangeIcon(this._containerData[containerID].button, WCF.Icon.get('wcf.icon.closed'));
3582 }
3583 },
3584
3585 /**
3586 * Toggles container visibility.
3587 *
3588 * @param object event
3589 */
3590 _toggleContainer: function(event) {
3591 var $button = $(event.currentTarget);
3592 var $containerID = $button.data('containerID');
3593 var $isOpen = this._containerData[$containerID].isOpen;
3594 var $currentState = ($isOpen) ? 'open' : 'close';
3595 var $newState = ($isOpen) ? 'close' : 'open';
3596
3597 this._proxy.setOption('data', {
3598 actionName: 'toggleContainer',
3599 className: this._className,
3600 objectIDs: [ this._getObjectID($containerID) ],
3601 parameters: $.extend(true, {
3602 containerID: $containerID,
3603 currentState: $currentState,
3604 newState: $newState
3605 }, this._getAdditionalParameters($containerID))
3606 });
3607 this._proxy.sendRequest();
3608
3609 // exchange icon
3610 this._exchangeIcon(this._containerData[$containerID].button, WCF.Icon.get('wcf.icon.' + ($newState === 'open' ? 'opened' : 'closed')));
3611
3612 // toggle container
3613 if ($newState === 'open') {
3614 this._containerData[$containerID].target.show();
3615 }
3616 else {
3617 this._containerData[$containerID].target.hide();
3618 }
3619
3620 // update container data
3621 this._containerData[$containerID].isOpen = ($newState === 'open' ? true : false);
3622 }
3623 });
3624
3625 /**
3626 * Provides collapsible sidebars with persistency support.
3627 */
3628 WCF.Collapsible.Sidebar = Class.extend({
3629 /**
3630 * trigger button object
3631 * @var jQuery
3632 */
3633 _button: null,
3634
3635 /**
3636 * trigger button height
3637 * @var integer
3638 */
3639 _buttonHeight: 0,
3640
3641 /**
3642 * sidebar state
3643 * @var boolean
3644 */
3645 _isOpen: false,
3646
3647 /**
3648 * main container object
3649 * @var jQuery
3650 */
3651 _mainContainer: null,
3652
3653 /**
3654 * action proxy
3655 * @var WCF.Action.Proxy
3656 */
3657 _proxy: null,
3658
3659 /**
3660 * sidebar object
3661 * @var jQuery
3662 */
3663 _sidebar: null,
3664
3665 /**
3666 * sidebar height
3667 * @var integer
3668 */
3669 _sidebarHeight: 0,
3670
3671 /**
3672 * sidebar identifier
3673 * @var string
3674 */
3675 _sidebarName: '',
3676
3677 /**
3678 * sidebar offset from document top
3679 * @var integer
3680 */
3681 _sidebarOffset: 0,
3682
3683 /**
3684 * user panel height
3685 * @var integer
3686 */
3687 _userPanelHeight: 0,
3688
3689 /**
3690 * Creates a new WCF.Collapsible.Sidebar object.
3691 */
3692 init: function() {
3693 this._sidebar = $('.sidebar:eq(0)');
3694 if (!this._sidebar.length) {
3695 console.debug("[WCF.Collapsible.Sidebar] Could not find sidebar, aborting.");
3696 return;
3697 }
3698
3699 this._isOpen = (this._sidebar.data('isOpen')) ? true : false;
3700 this._sidebarName = this._sidebar.data('sidebarName');
3701 this._mainContainer = $('#main');
3702 this._sidebarHeight = this._sidebar.height();
3703 this._sidebarOffset = this._sidebar.getOffsets('offset').top;
3704 this._userPanelHeight = $('#topMenu').outerHeight();
3705
3706 // add toggle button
3707 WCF.DOMNodeInsertedHandler.enable();
3708 this._button = $('<a class="collapsibleButton jsTooltip" title="' + WCF.Language.get('wcf.global.button.collapsible') + '" />').prependTo(this._sidebar);
3709 this._button.click($.proxy(this._click, this));
3710 this._buttonHeight = this._button.outerHeight();
3711 WCF.DOMNodeInsertedHandler.disable();
3712
3713 this._proxy = new WCF.Action.Proxy({
3714 showLoadingOverlay: false,
3715 url: 'index.php/AJAXInvoke/?t=' + SECURITY_TOKEN + SID_ARG_2ND
3716 });
3717
3718 $(document).scroll($.proxy(this._scroll, this)).resize($.proxy(this._scroll, this));
3719
3720 this._renderSidebar();
3721 this._scroll();
3722 },
3723
3724 /**
3725 * Handles clicks on the trigger button.
3726 */
3727 _click: function() {
3728 this._isOpen = (this._isOpen) ? false : true;
3729
3730 this._proxy.setOption('data', {
3731 actionName: 'toggle',
3732 className: 'wcf\\system\\user\\collapsible\\content\\UserCollapsibleSidebarHandler',
3733 isOpen: (this._isOpen ? 1 : 0),
3734 sidebarName: this._sidebarName
3735 });
3736 this._proxy.sendRequest();
3737
3738 this._renderSidebar();
3739 },
3740
3741 /**
3742 * Aligns the toggle button upon scroll or resize.
3743 */
3744 _scroll: function() {
3745 var $window = $(window);
3746 var $scrollOffset = $window.scrollTop();
3747
3748 // calculate top and bottom coordinates of visible sidebar
3749 var $topOffset = Math.max($scrollOffset - this._sidebarOffset, 0);
3750 var $bottomOffset = Math.min(this._mainContainer.height(), ($window.height() + $scrollOffset) - this._sidebarOffset);
3751
3752 var $buttonTop = 0;
3753 if ($bottomOffset === $topOffset) {
3754 // sidebar not within visible area
3755 $buttonTop = this._sidebarOffset + this._sidebarHeight;
3756 }
3757 else {
3758 $buttonTop = $topOffset + (($bottomOffset - $topOffset) / 2);
3759
3760 // if the user panel is above the sidebar, substract it's height
3761 var $overlap = Math.max(Math.min($topOffset - this._userPanelHeight, this._userPanelHeight), 0);
3762 if ($overlap > 0) {
3763 $buttonTop += ($overlap / 2);
3764 }
3765 }
3766
3767 // ensure the button does not exceed bottom boundaries
3768 if (($bottomOffset - $topOffset - this._userPanelHeight) < this._buttonHeight) {
3769 $buttonTop = $buttonTop - this._buttonHeight;
3770 }
3771 else {
3772 // exclude half button height
3773 $buttonTop = Math.max($buttonTop - (this._buttonHeight / 2), 0);
3774 }
3775
3776 this._button.css({ top: $buttonTop + 'px' });
3777
3778 },
3779
3780 /**
3781 * Renders the sidebar state.
3782 */
3783 _renderSidebar: function() {
3784 if (this._isOpen) {
3785 this._mainContainer.removeClass('sidebarCollapsed');
3786 }
3787 else {
3788 this._mainContainer.addClass('sidebarCollapsed');
3789 }
3790
3791 // update button position
3792 this._scroll();
3793 }
3794 });
3795
3796 /**
3797 * Holds userdata of the current user
3798 */
3799 WCF.User = {
3800 /**
3801 * id of the active user
3802 * @var integer
3803 */
3804 userID: 0,
3805
3806 /**
3807 * name of the active user
3808 * @var string
3809 */
3810 username: '',
3811
3812 /**
3813 * Initializes userdata
3814 *
3815 * @param integer userID
3816 * @param string username
3817 */
3818 init: function(userID, username) {
3819 this.userID = userID;
3820 this.username = username;
3821 }
3822 };
3823
3824 /**
3825 * Namespace for effect-related functions.
3826 */
3827 WCF.Effect = {};
3828
3829 /**
3830 * Scrolls to a specific element offset, optionally handling menu height.
3831 */
3832 WCF.Effect.Scroll = Class.extend({
3833 /**
3834 * Scrolls to a specific element offset.
3835 *
3836 * @param jQuery element
3837 * @param boolean excludeMenuHeight
3838 * @return boolean
3839 */
3840 scrollTo: function(element, excludeMenuHeight) {
3841 if (!element.length) {
3842 return true;
3843 }
3844
3845 var $elementOffset = element.getOffsets().top;
3846 var $documentHeight = $(document).height();
3847 var $windowHeight = $(window).height();
3848
3849 // handles menu height
3850 if (excludeMenuHeight) {
3851 $elementOffset = Math.max($elementOffset - $('#topMenu').outerHeight(), 0);
3852 }
3853
3854 if ($elementOffset > $documentHeight - $windowHeight) {
3855 $elementOffset = $documentHeight - $windowHeight;
3856 if ($elementOffset < 0) {
3857 $elementOffset = 0;
3858 }
3859 }
3860
3861 $('html,body').animate({ scrollTop: $elementOffset }, 400, function (x, t, b, c, d) {
3862 return -c * ( ( t = t / d - 1 ) * t * t * t - 1) + b;
3863 });
3864
3865 return false;
3866 }
3867 });
3868
3869 /**
3870 * Creates a smooth scroll effect.
3871 */
3872 WCF.Effect.SmoothScroll = WCF.Effect.Scroll.extend({
3873 /**
3874 * Initializes effect.
3875 */
3876 init: function() {
3877 var self = this;
3878 $(document).on('click', 'a[href$=#top],a[href$=#bottom]', function() {
3879 var $target = $(this.hash);
3880 self.scrollTo($target, true);
3881
3882 return false;
3883 });
3884 }
3885 });
3886
3887 /**
3888 * Creates the balloon tool-tip.
3889 */
3890 WCF.Effect.BalloonTooltip = Class.extend({
3891 /**
3892 * initialization state
3893 * @var boolean
3894 */
3895 _didInit: false,
3896
3897 /**
3898 * tooltip element
3899 * @var jQuery
3900 */
3901 _tooltip: null,
3902
3903 /**
3904 * cache viewport dimensions
3905 * @var object
3906 */
3907 _viewportDimensions: { },
3908
3909 /**
3910 * Initializes tooltips.
3911 */
3912 init: function() {
3913 if (!this._didInit) {
3914 // create empty div
3915 this._tooltip = $('<div id="balloonTooltip" class="balloonTooltip"><span id="balloonTooltipText"></span><span class="pointer"><span></span></span></div>').appendTo($('body')).hide();
3916
3917 // get viewport dimensions
3918 this._updateViewportDimensions();
3919
3920 // update viewport dimensions on resize
3921 $(window).resize($.proxy(this._updateViewportDimensions, this));
3922
3923 // observe DOM changes
3924 WCF.DOMNodeInsertedHandler.addCallback('WCF.Effect.BalloonTooltip', $.proxy(this.init, this));
3925
3926 this._didInit = true;
3927 }
3928
3929 // init elements
3930 $('.jsTooltip').each($.proxy(this._initTooltip, this));
3931 },
3932
3933 /**
3934 * Updates cached viewport dimensions.
3935 */
3936 _updateViewportDimensions: function() {
3937 this._viewportDimensions = $(document).getDimensions();
3938 },
3939
3940 /**
3941 * Initializes a tooltip element.
3942 *
3943 * @param integer index
3944 * @param object element
3945 */
3946 _initTooltip: function(index, element) {
3947 var $element = $(element);
3948
3949 if ($element.hasClass('jsTooltip')) {
3950 $element.removeClass('jsTooltip');
3951 var $title = $element.attr('title');
3952
3953 // ignore empty elements
3954 if ($title !== '') {
3955 $element.data('tooltip', $title);
3956 $element.removeAttr('title');
3957
3958 $element.hover(
3959 $.proxy(this._mouseEnterHandler, this),
3960 $.proxy(this._mouseLeaveHandler, this)
3961 );
3962 $element.click($.proxy(this._mouseLeaveHandler, this));
3963 }
3964 }
3965 },
3966
3967 /**
3968 * Shows tooltip on hover.
3969 *
3970 * @param object event
3971 */
3972 _mouseEnterHandler: function(event) {
3973 var $element = $(event.currentTarget);
3974
3975 var $title = $element.attr('title');
3976 if ($title && $title !== '') {
3977 $element.data('tooltip', $title);
3978 $element.removeAttr('title');
3979 }
3980
3981 // reset tooltip position
3982 this._tooltip.css({
3983 top: "0px",
3984 left: "0px"
3985 });
3986
3987 // empty tooltip, skip
3988 if (!$element.data('tooltip')) {
3989 this._tooltip.hide();
3990 return;
3991 }
3992
3993 // update text
3994 this._tooltip.children('span:eq(0)').text($element.data('tooltip'));
3995
3996 // get arrow
3997 var $arrow = this._tooltip.find('.pointer');
3998
3999 // get arrow width
4000 this._tooltip.show();
4001 var $arrowWidth = $arrow.outerWidth();
4002 this._tooltip.hide();
4003
4004 // calculate position
4005 var $elementOffsets = $element.getOffsets('offset');
4006 var $elementDimensions = $element.getDimensions('outer');
4007 var $tooltipDimensions = this._tooltip.getDimensions('outer');
4008 var $tooltipDimensionsInner = this._tooltip.getDimensions('inner');
4009
4010 var $elementCenter = $elementOffsets.left + Math.ceil($elementDimensions.width / 2);
4011 var $tooltipHalfWidth = Math.ceil($tooltipDimensions.width / 2);
4012
4013 // determine alignment
4014 var $alignment = 'center';
4015 if (($elementCenter - $tooltipHalfWidth) < 5) {
4016 $alignment = 'left';
4017 }
4018 else if ((this._viewportDimensions.width - 5) < ($elementCenter + $tooltipHalfWidth)) {
4019 $alignment = 'right';
4020 }
4021
4022 // calculate top offset
4023 var $top = $elementOffsets.top + $elementDimensions.height + 7;
4024
4025 // calculate left offset
4026 switch ($alignment) {
4027 case 'center':
4028 var $left = Math.round($elementOffsets.left - $tooltipHalfWidth + ($elementDimensions.width / 2));
4029
4030 $arrow.css({
4031 left: ($tooltipDimensionsInner.width / 2 - $arrowWidth / 2) + "px"
4032 });
4033 break;
4034
4035 case 'left':
4036 var $left = $elementOffsets.left;
4037
4038 $arrow.css({
4039 left: "5px"
4040 });
4041 break;
4042
4043 case 'right':
4044 var $left = $elementOffsets.left + $elementDimensions.width - $tooltipDimensions.width;
4045
4046 $arrow.css({
4047 left: ($tooltipDimensionsInner.width - $arrowWidth - 5) + "px"
4048 });
4049 break;
4050 }
4051
4052 // move tooltip
4053 this._tooltip.css({
4054 top: $top + "px",
4055 left: $left + "px"
4056 });
4057
4058 // show tooltip
4059 this._tooltip.wcfFadeIn();
4060 },
4061
4062 /**
4063 * Hides tooltip once cursor left the element.
4064 *
4065 * @param object event
4066 */
4067 _mouseLeaveHandler: function(event) {
4068 this._tooltip.stop().hide().css({
4069 opacity: 1
4070 });
4071 }
4072 });
4073
4074 /**
4075 * Handles clicks outside an overlay, hitting body-tag through bubbling.
4076 *
4077 * You should always remove callbacks before disposing the attached element,
4078 * preventing errors from blocking the iteration. Furthermore you should
4079 * always handle clicks on your overlay's container and return 'false' to
4080 * prevent bubbling.
4081 */
4082 WCF.CloseOverlayHandler = {
4083 /**
4084 * list of callbacks
4085 * @var WCF.Dictionary
4086 */
4087 _callbacks: new WCF.Dictionary(),
4088
4089 /**
4090 * indicates that overlay handler is listening to click events on body-tag
4091 * @var boolean
4092 */
4093 _isListening: false,
4094
4095 /**
4096 * Adds a new callback.
4097 *
4098 * @param string identifier
4099 * @param object callback
4100 */
4101 addCallback: function(identifier, callback) {
4102 this._bindListener();
4103
4104 if (this._callbacks.isset(identifier)) {
4105 console.debug("[WCF.CloseOverlayHandler] identifier '" + identifier + "' is already bound to a callback");
4106 return false;
4107 }
4108
4109 this._callbacks.add(identifier, callback);
4110 },
4111
4112 /**
4113 * Removes a callback from list.
4114 *
4115 * @param string identifier
4116 */
4117 removeCallback: function(identifier) {
4118 if (this._callbacks.isset(identifier)) {
4119 this._callbacks.remove(identifier);
4120 }
4121 },
4122
4123 /**
4124 * Binds click event handler.
4125 */
4126 _bindListener: function() {
4127 if (this._isListening) return;
4128
4129 $('body').click($.proxy(this._executeCallbacks, this));
4130
4131 this._isListening = true;
4132 },
4133
4134 /**
4135 * Executes callbacks on click.
4136 */
4137 _executeCallbacks: function(event) {
4138 this._callbacks.each(function(pair) {
4139 // execute callback
4140 pair.value();
4141 });
4142 }
4143 };
4144
4145 /**
4146 * Notifies objects once a DOM node was inserted.
4147 */
4148 WCF.DOMNodeInsertedHandler = {
4149 /**
4150 * list of callbacks
4151 * @var WCF.Dictionary
4152 */
4153 _callbacks: new WCF.Dictionary(),
4154
4155 /**
4156 * true if DOMNodeInserted event should be ignored
4157 * @var boolean
4158 */
4159 _discardEvent: true,
4160
4161 /**
4162 * counts requests to enable WCF.DOMNodeInsertedHandler
4163 * @var integer
4164 */
4165 _discardEventCount: 0,
4166
4167 /**
4168 * prevent infinite loop if a callback manipulates DOM
4169 * @var boolean
4170 */
4171 _isExecuting: false,
4172
4173 /**
4174 * indicates that overlay handler is listening to click events on body-tag
4175 * @var boolean
4176 */
4177 _isListening: false,
4178
4179 /**
4180 * Adds a new callback.
4181 *
4182 * @param string identifier
4183 * @param object callback
4184 */
4185 addCallback: function(identifier, callback) {
4186 this._discardEventCount = 0;
4187 this._bindListener();
4188
4189 if (this._callbacks.isset(identifier)) {
4190 console.debug("[WCF.DOMNodeInsertedHandler] identifier '" + identifier + "' is already bound to a callback");
4191 return false;
4192 }
4193
4194 this._callbacks.add(identifier, callback);
4195 },
4196
4197 /**
4198 * Removes a callback from list.
4199 *
4200 * @param string identifier
4201 */
4202 removeCallback: function(identifier) {
4203 if (this._callbacks.isset(identifier)) {
4204 this._callbacks.remove(identifier);
4205 }
4206 },
4207
4208 /**
4209 * Binds click event handler.
4210 */
4211 _bindListener: function() {
4212 if (this._isListening) return;
4213
4214 $(document).bind('DOMNodeInserted', $.proxy(this._executeCallbacks, this));
4215
4216 this._isListening = true;
4217 },
4218
4219 /**
4220 * Executes callbacks on click.
4221 */
4222 _executeCallbacks: function() {
4223 if (this._discardEvent || this._isExecuting) return;
4224
4225 // do not track events while executing callbacks
4226 this._isExecuting = true;
4227
4228 this._callbacks.each(function(pair) {
4229 // execute callback
4230 pair.value();
4231 });
4232
4233 // enable listener again
4234 this._isExecuting = false;
4235 },
4236
4237 /**
4238 * Disables DOMNodeInsertedHandler, should be used after you've enabled it.
4239 */
4240 disable: function() {
4241 this._discardEventCount--;
4242
4243 if (this._discardEventCount < 1) {
4244 this._discardEvent = true;
4245 this._discardEventCount = 0;
4246 }
4247 },
4248
4249 /**
4250 * Enables DOMNodeInsertedHandler, should be used if you're inserting HTML (e.g. via AJAX)
4251 * which might contain event-related elements. You have to disable the DOMNodeInsertedHandler
4252 * once you've enabled it, if you fail it will cause an infinite loop!
4253 */
4254 enable: function() {
4255 this._discardEventCount++;
4256
4257 this._discardEvent = false;
4258 },
4259
4260 /**
4261 * Forces execution of DOMNodeInsertedHandler.
4262 */
4263 forceExecution: function() {
4264 this.enable();
4265 this._executeCallbacks();
4266 this.disable();
4267 }
4268 };
4269
4270 /**
4271 * Notifies objects once a DOM node was removed.
4272 */
4273 WCF.DOMNodeRemovedHandler = {
4274 /**
4275 * list of callbacks
4276 * @var WCF.Dictionary
4277 */
4278 _callbacks: new WCF.Dictionary(),
4279
4280 /**
4281 * prevent infinite loop if a callback manipulates DOM
4282 * @var boolean
4283 */
4284 _isExecuting: false,
4285
4286 /**
4287 * indicates that overlay handler is listening to DOMNodeRemoved events on body-tag
4288 * @var boolean
4289 */
4290 _isListening: false,
4291
4292 /**
4293 * Adds a new callback.
4294 *
4295 * @param string identifier
4296 * @param object callback
4297 */
4298 addCallback: function(identifier, callback) {
4299 this._bindListener();
4300
4301 if (this._callbacks.isset(identifier)) {
4302 console.debug("[WCF.DOMNodeRemovedHandler] identifier '" + identifier + "' is already bound to a callback");
4303 return false;
4304 }
4305
4306 this._callbacks.add(identifier, callback);
4307 },
4308
4309 /**
4310 * Removes a callback from list.
4311 *
4312 * @param string identifier
4313 */
4314 removeCallback: function(identifier) {
4315 if (this._callbacks.isset(identifier)) {
4316 this._callbacks.remove(identifier);
4317 }
4318 },
4319
4320 /**
4321 * Binds click event handler.
4322 */
4323 _bindListener: function() {
4324 if (this._isListening) return;
4325
4326 $(document).bind('DOMNodeRemoved', $.proxy(this._executeCallbacks, this));
4327
4328 this._isListening = true;
4329 },
4330
4331 /**
4332 * Executes callbacks if a DOM node is removed.
4333 */
4334 _executeCallbacks: function(event) {
4335 if (this._isExecuting) return;
4336
4337 // do not track events while executing callbacks
4338 this._isExecuting = true;
4339
4340 this._callbacks.each(function(pair) {
4341 // execute callback
4342 pair.value(event);
4343 });
4344
4345 // enable listener again
4346 this._isExecuting = false;
4347 }
4348 };
4349
4350 /**
4351 * Namespace for table related classes.
4352 */
4353 WCF.Table = {};
4354
4355 /**
4356 * Handles empty tables which can be used in combination with WCF.Action.Proxy.
4357 */
4358 WCF.Table.EmptyTableHandler = Class.extend({
4359 /**
4360 * handler options
4361 * @var object
4362 */
4363 _options: {},
4364
4365 /**
4366 * class name of the relevant rows
4367 * @var string
4368 */
4369 _rowClassName: '',
4370
4371 /**
4372 * Initalizes a new WCF.Table.EmptyTableHandler object.
4373 *
4374 * @param jQuery tableContainer
4375 * @param string rowClassName
4376 * @param object options
4377 */
4378 init: function(tableContainer, rowClassName, options) {
4379 this._rowClassName = rowClassName;
4380 this._tableContainer = tableContainer;
4381
4382 this._options = $.extend(true, {
4383 emptyMessage: null,
4384 messageType: 'info',
4385 refreshPage: false,
4386 updatePageNumber: false
4387 }, options || { });
4388
4389 WCF.DOMNodeRemovedHandler.addCallback('WCF.Table.EmptyTableHandler.' + rowClassName, $.proxy(this._remove, this));
4390 },
4391
4392 /**
4393 * Handles the removal of a DOM node.
4394 */
4395 _remove: function(event) {
4396 var element = $(event.target);
4397
4398 // check if DOM element is relevant
4399 if (element.hasClass(this._rowClassName)) {
4400 var tbody = element.parents('tbody:eq(0)');
4401
4402 // check if table will be empty if DOM node is removed
4403 if (tbody.children('tr').length == 1) {
4404 if (this._options.emptyMessage) {
4405 // insert message
4406 this._tableContainer.replaceWith($('<p />').addClass(this._options.messageType).text(this._options.emptyMessage));
4407 }
4408 else if (this._options.refreshPage) {
4409 // refresh page
4410 if (this._options.updatePageNumber) {
4411 // calculate the new page number
4412 var pageNumberURLComponents = window.location.href.match(/(\?|&)pageNo=(\d+)/g);
4413 if (pageNumberURLComponents) {
4414 var currentPageNumber = pageNumberURLComponents[pageNumberURLComponents.length - 1].match(/\d+/g);
4415 if (this._options.updatePageNumber > 0) {
4416 currentPageNumber++;
4417 }
4418 else {
4419 currentPageNumber--;
4420 }
4421
4422 window.location = window.location.href.replace(pageNumberURLComponents[pageNumberURLComponents.length - 1], pageNumberURLComponents[pageNumberURLComponents.length - 1][0] + 'pageNo=' + currentPageNumber);
4423 }
4424 }
4425 else {
4426 window.location.reload();
4427 }
4428 }
4429 else {
4430 // simply remove the table container
4431 this._tableContainer.remove();
4432 }
4433 }
4434 }
4435 }
4436 });
4437
4438 /**
4439 * Namespace for search related classes.
4440 */
4441 WCF.Search = {};
4442
4443 /**
4444 * Performs a quick search.
4445 */
4446 WCF.Search.Base = Class.extend({
4447 /**
4448 * notification callback
4449 * @var object
4450 */
4451 _callback: null,
4452
4453 /**
4454 * class name
4455 * @var string
4456 */
4457 _className: '',
4458
4459 /**
4460 * comma seperated list
4461 * @var boolean
4462 */
4463 _commaSeperated: false,
4464
4465 /**
4466 * list with values that are excluded from seaching
4467 * @var array
4468 */
4469 _excludedSearchValues: [],
4470
4471 /**
4472 * count of available results
4473 * @var integer
4474 */
4475 _itemCount: 0,
4476
4477 /**
4478 * item index, -1 if none is selected
4479 * @var integer
4480 */
4481 _itemIndex: -1,
4482
4483 /**
4484 * result list
4485 * @var jQuery
4486 */
4487 _list: null,
4488
4489 /**
4490 * old search string, used for comparison
4491 * @var array<string>
4492 */
4493 _oldSearchString: [ ],
4494
4495 /**
4496 * action proxy
4497 * @var WCF.Action.Proxy
4498 */
4499 _proxy: null,
4500
4501 /**
4502 * search input field
4503 * @var jQuery
4504 */
4505 _searchInput: null,
4506
4507 /**
4508 * minimum search input length, MUST be 1 or higher
4509 * @var integer
4510 */
4511 _triggerLength: 3,
4512
4513 /**
4514 * Initializes a new search.
4515 *
4516 * @param jQuery searchInput
4517 * @param object callback
4518 * @param array excludedSearchValues
4519 * @param boolean commaSeperated
4520 * @param boolean showLoadingOverlay
4521 */
4522 init: function(searchInput, callback, excludedSearchValues, commaSeperated, showLoadingOverlay) {
4523 if (callback !== null && callback !== undefined && !$.isFunction(callback)) {
4524 console.debug("[WCF.Search.Base] The given callback is invalid, aborting.");
4525 return;
4526 }
4527
4528 this._callback = (callback) ? callback : null;
4529 this._excludedSearchValues = [];
4530 if (excludedSearchValues) {
4531 this._excludedSearchValues = excludedSearchValues;
4532 }
4533
4534 this._searchInput = $(searchInput);
4535 if (!this._searchInput.length) {
4536 console.debug("[WCF.Search.Base] Selector '" + searchInput + "' for search input is invalid, aborting.");
4537 return;
4538 }
4539
4540 this._searchInput.keydown($.proxy(this._keyDown, this)).keyup($.proxy(this._keyUp, this)).wrap('<span class="dropdown" />');
4541 this._list = $('<ul class="dropdownMenu" />').insertAfter(this._searchInput);
4542 this._commaSeperated = (commaSeperated) ? true : false;
4543 this._oldSearchString = [ ];
4544
4545 this._itemCount = 0;
4546 this._itemIndex = -1;
4547
4548 this._proxy = new WCF.Action.Proxy({
4549 showLoadingOverlay: (showLoadingOverlay === false ? false : true),
4550 success: $.proxy(this._success, this)
4551 });
4552
4553 if (this._searchInput.getTagName() === 'input') {
4554 this._searchInput.attr('autocomplete', 'off');
4555 }
4556
4557 this._searchInput.blur($.proxy(this._blur, this));
4558 },
4559
4560 /**
4561 * Closes the dropdown after a short delay.
4562 */
4563 _blur: function() {
4564 var self = this;
4565 new WCF.PeriodicalExecuter(function(pe) {
4566 if (self._list.is(':visible')) {
4567 self._clearList(false);
4568 }
4569
4570 pe.stop();
4571 }, 100);
4572 },
4573
4574 /**
4575 * Blocks execution of 'Enter' event.
4576 *
4577 * @param object event
4578 */
4579 _keyDown: function(event) {
4580 if (event.which === 13) {
4581 event.preventDefault();
4582 }
4583 },
4584
4585 /**
4586 * Performs a search upon key up.
4587 *
4588 * @param object event
4589 */
4590 _keyUp: function(event) {
4591 // handle arrow keys and return key
4592 switch (event.which) {
4593 case 37: // arrow-left
4594 case 39: // arrow-right
4595 return;
4596 break;
4597
4598 case 38: // arrow up
4599 this._selectPreviousItem();
4600 return;
4601 break;
4602
4603 case 40: // arrow down
4604 this._selectNextItem();
4605 return;
4606 break;
4607
4608 case 13: // return key
4609 return this._selectElement(event);
4610 break;
4611 }
4612
4613 var $content = this._getSearchString(event);
4614 if ($content === '') {
4615 this._clearList(true);
4616 }
4617 else if ($content.length >= this._triggerLength) {
4618 var $parameters = {
4619 data: {
4620 excludedSearchValues: this._excludedSearchValues,
4621 searchString: $content
4622 }
4623 };
4624
4625 this._proxy.setOption('data', {
4626 actionName: 'getSearchResultList',
4627 className: this._className,
4628 parameters: this._getParameters($parameters)
4629 });
4630 this._proxy.sendRequest();
4631 }
4632 else {
4633 // input below trigger length
4634 this._clearList(false);
4635 }
4636 },
4637
4638 /**
4639 * Selects the next item in list.
4640 */
4641 _selectNextItem: function() {
4642 if (this._itemCount === 0) {
4643 return;
4644 }
4645
4646 // remove previous marking
4647 this._itemIndex++;
4648 if (this._itemIndex === this._itemCount) {
4649 this._itemIndex = 0;
4650 }
4651
4652 this._highlightSelectedElement();
4653 },
4654
4655 /**
4656 * Selects the previous item in list.
4657 */
4658 _selectPreviousItem: function() {
4659 if (this._itemCount === 0) {
4660 return;
4661 }
4662
4663 this._itemIndex--;
4664 if (this._itemIndex === -1) {
4665 this._itemIndex = this._itemCount - 1;
4666 }
4667
4668 this._highlightSelectedElement();
4669 },
4670
4671 /**
4672 * Highlights the active item.
4673 */
4674 _highlightSelectedElement: function() {
4675 this._list.find('li').removeClass('dropdownNavigationItem');
4676 this._list.find('li:eq(' + this._itemIndex + ')').addClass('dropdownNavigationItem');
4677 },
4678
4679 /**
4680 * Selects the active item by pressing the return key.
4681 *
4682 * @param object event
4683 * @return boolean
4684 */
4685 _selectElement: function(event) {
4686 if (this._itemCount === 0) {
4687 return true;
4688 }
4689
4690 this._list.find('li.dropdownNavigationItem').trigger('click');
4691
4692 return false;
4693 },
4694
4695 /**
4696 * Returns search string.
4697 *
4698 * @return string
4699 */
4700 _getSearchString: function(event) {
4701 var $searchString = $.trim(this._searchInput.val());
4702 if (this._commaSeperated) {
4703 var $keyCode = event.keyCode || event.which;
4704 if ($keyCode == 188) {
4705 // ignore event if char is 188 = ,
4706 return '';
4707 }
4708
4709 var $current = $searchString.split(',');
4710 var $length = $current.length;
4711 for (var $i = 0; $i < $length; $i++) {
4712 // remove whitespaces at the beginning or end
4713 $current[$i] = $.trim($current[$i]);
4714 }
4715
4716 for (var $i = 0; $i < $length; $i++) {
4717 var $part = $current[$i];
4718
4719 if (this._oldSearchString[$i]) {
4720 // compare part
4721 if ($part != this._oldSearchString[$i]) {
4722 // current part was changed
4723 $searchString = $part;
4724 break;
4725 }
4726 }
4727 else {
4728 // new part was added
4729 $searchString = $part;
4730 break;
4731 }
4732 }
4733
4734 this._oldSearchString = $current;
4735 }
4736
4737 return $searchString;
4738 },
4739
4740 /**
4741 * Returns parameters for quick search.
4742 *
4743 * @param object parameters
4744 * @return object
4745 */
4746 _getParameters: function(parameters) {
4747 return parameters;
4748 },
4749
4750 /**
4751 * Evalutes search results.
4752 *
4753 * @param object data
4754 * @param string textStatus
4755 * @param jQuery jqXHR
4756 */
4757 _success: function(data, textStatus, jqXHR) {
4758 this._clearList(false);
4759
4760 // no items available, abort
4761 if (!$.getLength(data.returnValues)) {
4762 return;
4763 }
4764
4765 for (var $i in data.returnValues) {
4766 var $item = data.returnValues[$i];
4767
4768 this._createListItem($item);
4769 }
4770
4771 this._list.parent().addClass('dropdownOpen');
4772 WCF.Dropdown.setAlignment(undefined, this._list);
4773
4774 WCF.CloseOverlayHandler.addCallback('WCF.Search.Base', $.proxy(function() { this._clearList(true); }, this));
4775 },
4776
4777 /**
4778 * Creates a new list item.
4779 *
4780 * @param object item
4781 * @return jQuery
4782 */
4783 _createListItem: function(item) {
4784 var $listItem = $('<li><span>' + item.label + '</span></li>').appendTo(this._list);
4785 $listItem.data('objectID', item.objectID).data('label', item.label).click($.proxy(this._executeCallback, this));
4786
4787 this._itemCount++;
4788
4789 return $listItem;
4790 },
4791
4792 /**
4793 * Executes callback upon result click.
4794 *
4795 * @param object event
4796 */
4797 _executeCallback: function(event) {
4798 var $clearSearchInput = false;
4799 var $listItem = $(event.currentTarget);
4800 // notify callback
4801 if (this._commaSeperated) {
4802 // auto-complete current part
4803 var $result = $listItem.data('label');
4804 for (var $i = 0, $length = this._oldSearchString.length; $i < $length; $i++) {
4805 var $part = this._oldSearchString[$i];
4806 if ($result.toLowerCase().indexOf($part.toLowerCase()) === 0) {
4807 this._oldSearchString[$i] = $result;
4808 this._searchInput.attr('value', this._oldSearchString.join(', '));
4809
4810 if ($.browser.webkit) {
4811 // chrome won't display the new value until the textarea is rendered again
4812 // this quick fix forces chrome to render it again, even though it changes nothing
4813 this._searchInput.css({ display: 'block' });
4814 }
4815
4816 // set focus on input field again
4817 var $position = this._searchInput.val().toLowerCase().indexOf($result.toLowerCase()) + $result.length;
4818 this._searchInput.focus().setCaret($position);
4819
4820 break;
4821 }
4822 }
4823 }
4824 else {
4825 if (this._callback === null) {
4826 this._searchInput.val($listItem.data('label'));
4827 }
4828 else {
4829 $clearSearchInput = (this._callback($listItem.data()) === true) ? true : false;
4830 }
4831 }
4832
4833 // close list and revert input
4834 this._clearList($clearSearchInput);
4835 },
4836
4837 /**
4838 * Closes the suggestion list and clears search input on demand.
4839 *
4840 * @param boolean clearSearchInput
4841 */
4842 _clearList: function(clearSearchInput) {
4843 if (clearSearchInput && !this._commaSeperated) {
4844 this._searchInput.val('');
4845 }
4846
4847 this._list.parent().removeClass('dropdownOpen').end().empty();
4848
4849 WCF.CloseOverlayHandler.removeCallback('WCF.Search.Base');
4850
4851 // reset item navigation
4852 this._itemCount = 0;
4853 this._itemIndex = -1;
4854 },
4855
4856 /**
4857 * Adds an excluded search value.
4858 *
4859 * @param string value
4860 */
4861 addExcludedSearchValue: function(value) {
4862 if (!WCF.inArray(value, this._excludedSearchValues)) {
4863 this._excludedSearchValues.push(value);
4864 }
4865 },
4866
4867 /**
4868 * Adds an excluded search value.
4869 *
4870 * @param string value
4871 */
4872 removeExcludedSearchValue: function(value) {
4873 var index = $.inArray(value, this._excludedSearchValues);
4874 if (index != -1) {
4875 this._excludedSearchValues.splice(index, 1);
4876 }
4877 }
4878 });
4879
4880 /**
4881 * Provides quick search for users and user groups.
4882 *
4883 * @see WCF.Search.Base
4884 */
4885 WCF.Search.User = WCF.Search.Base.extend({
4886 /**
4887 * @see WCF.Search.Base._className
4888 */
4889 _className: 'wcf\\data\\user\\UserAction',
4890
4891 /**
4892 * include user groups in search
4893 * @var boolean
4894 */
4895 _includeUserGroups: false,
4896
4897 /**
4898 * @see WCF.Search.Base.init()
4899 */
4900 init: function(searchInput, callback, includeUserGroups, excludedSearchValues, commaSeperated) {
4901 this._includeUserGroups = includeUserGroups;
4902
4903 this._super(searchInput, callback, excludedSearchValues, commaSeperated);
4904 },
4905
4906 /**
4907 * @see WCF.Search.Base._getParameters()
4908 */
4909 _getParameters: function(parameters) {
4910 parameters.data.includeUserGroups = this._includeUserGroups ? 1 : 0;
4911
4912 return parameters;
4913 },
4914
4915 /**
4916 * @see WCF.Search.Base._createListItem()
4917 */
4918 _createListItem: function(item) {
4919 var $listItem = this._super(item);
4920
4921 // insert item type
4922 if (this._includeUserGroups) $('<img src="' + WCF.Icon.get('wcf.icon.user' + (item.type == 'group' ? 's' : '')) + '" alt="" class="icon16" style="margin-right: 4px;" />').prependTo($listItem.children('span:eq(0)'));
4923 $listItem.data('type', item.type);
4924
4925 return $listItem;
4926 }
4927 });
4928
4929 /**
4930 * Namespace for system-related classes.
4931 */
4932 WCF.System = { };
4933
4934 /**
4935 * System notification overlays.
4936 *
4937 * @param string message
4938 * @param string cssClassNames
4939 */
4940 WCF.System.Notification = Class.extend({
4941 /**
4942 * callback on notification close
4943 * @var object
4944 */
4945 _callback: null,
4946
4947 /**
4948 * CSS class names
4949 * @var string
4950 */
4951 _cssClassNames: '',
4952
4953 /**
4954 * notification message
4955 * @var string
4956 */
4957 _message: '',
4958
4959 /**
4960 * notification overlay
4961 * @var jQuery
4962 */
4963 _overlay: null,
4964
4965 /**
4966 * Creates a new system notification overlay.
4967 *
4968 * @param string message
4969 * @param string cssClassNames
4970 */
4971 init: function(message, cssClassNames) {
4972 this._cssClassNames = cssClassNames || 'success';
4973 this._message = message;
4974 this._overlay = $('#systemNotification');
4975
4976 if (!this._overlay.length) {
4977 this._overlay = $('<div id="systemNotification"><p></p></div>').appendTo(document.body);
4978 }
4979 },
4980
4981 /**
4982 * Shows the notification overlay.
4983 *
4984 * @param object callback
4985 * @param integer duration
4986 * @param string message
4987 * @param string cssClassName
4988 */
4989 show: function(callback, duration, message, cssClassNames) {
4990 duration = parseInt(duration);
4991 if (!duration) duration = 2000;
4992
4993 if (callback && $.isFunction(callback)) {
4994 this._callback = callback;
4995 }
4996
4997 this._overlay.children('p').html((message || this._message));
4998 this._overlay.children('p').removeClass().addClass((cssClassNames || this._cssClassNames));
4999
5000 // hide overlay after specified duration
5001 new WCF.PeriodicalExecuter($.proxy(this._hide, this), duration);
5002
5003 this._overlay.addClass('open');
5004 },
5005
5006 /**
5007 * Hides the notification overlay after executing the callback.
5008 *
5009 * @param WCF.PeriodicalExecuter pe
5010 */
5011 _hide: function(pe) {
5012 if (this._callback !== null) {
5013 this._callback();
5014 }
5015
5016 this._overlay.removeClass('open');
5017
5018 pe.stop();
5019 }
5020 });
5021
5022 /**
5023 * Provides dialog-based confirmations.
5024 */
5025 WCF.System.Confirmation = {
5026 /**
5027 * notification callback
5028 * @var object
5029 */
5030 _callback: null,
5031
5032 /**
5033 * confirmation dialog
5034 * @var jQuery
5035 */
5036 _dialog: null,
5037
5038 /**
5039 * callback parameters
5040 * @var object
5041 */
5042 _parameters: null,
5043
5044 /**
5045 * dialog visibility
5046 * @var boolean
5047 */
5048 _visible: false,
5049
5050 /**
5051 * Displays a confirmation dialog.
5052 *
5053 * @param string message
5054 * @param object callback
5055 * @param object parameters
5056 * @param jQuery template
5057 */
5058 show: function(message, callback, parameters, template) {
5059 if (this._visible) {
5060 console.debug('[WCF.System.Confirmation] Confirmation dialog is already open, refusing action.');
5061 return;
5062 }
5063
5064 if (!$.isFunction(callback)) {
5065 console.debug('[WCF.System.Confirmation] Given callback is invalid, aborting.');
5066 return;
5067 }
5068
5069 this._callback = callback;
5070 this._parameters = parameters;
5071
5072 var $render = true;
5073 if (this._dialog === null) {
5074 this._createDialog();
5075 $render = false;
5076 }
5077
5078 this._dialog.find('#wcfSystemConfirmationContent').empty().hide();
5079 if (template && template.length) {
5080 template.appendTo(this._dialog.find('#wcfSystemConfirmationContent').show());
5081 }
5082
5083 this._dialog.find('p').html(message);
5084 this._dialog.wcfDialog({
5085 onClose: $.proxy(this._close, this),
5086 onShow: $.proxy(this._show, this),
5087 title: WCF.Language.get('wcf.global.confirmation.title')
5088 });
5089 if ($render) {
5090 this._dialog.wcfDialog('render');
5091 }
5092
5093 this._visible = true;
5094 },
5095
5096 /**
5097 * Creates the confirmation dialog on first use.
5098 */
5099 _createDialog: function() {
5100 this._dialog = $('<div id="wcfSystemConfirmation" class="systemConfirmation"><p /><div id="wcfSystemConfirmationContent" /></div>').hide().appendTo(document.body);
5101 var $formButtons = $('<div class="formSubmit" />').appendTo(this._dialog);
5102
5103 $('<button class="buttonPrimary">' + WCF.Language.get('wcf.global.confirmation.confirm') + '</button>').data('action', 'confirm').click($.proxy(this._click, this)).appendTo($formButtons);
5104 $('<button>' + WCF.Language.get('wcf.global.confirmation.cancel') + '</button>').data('action', 'cancel').click($.proxy(this._click, this)).appendTo($formButtons);
5105 },
5106
5107 /**
5108 * Handles button clicks.
5109 *
5110 * @param object event
5111 */
5112 _click: function(event) {
5113 this._notify($(event.currentTarget).data('action'));
5114 },
5115
5116 /**
5117 * Handles dialog being closed.
5118 */
5119 _close: function() {
5120 if (this._visible) {
5121 this._notify('cancel');
5122 }
5123 },
5124
5125 /**
5126 * Notifies callback upon user's decision.
5127 *
5128 * @param string action
5129 */
5130 _notify: function(action) {
5131 this._visible = false;
5132 this._dialog.wcfDialog('close');
5133
5134 this._callback(action, this._parameters);
5135 },
5136
5137 /**
5138 * Tries to set focus on confirm button.
5139 */
5140 _show: function() {
5141 this._dialog.find('button.buttonPrimary').blur().focus();
5142 }
5143 };
5144
5145 /**
5146 * Provides the 'jump to page' overlay.
5147 */
5148 WCF.System.PageNavigation = {
5149 /**
5150 * submit button
5151 * @var jQuery
5152 */
5153 _button: null,
5154
5155 /**
5156 * page No description
5157 * @var jQuery
5158 */
5159 _description: null,
5160
5161 /**
5162 * dialog overlay
5163 * @var jQuery
5164 */
5165 _dialog: null,
5166
5167 /**
5168 * active element id
5169 * @var string
5170 */
5171 _elementID: '',
5172
5173 /**
5174 * list of tracked navigation bars
5175 * @var object
5176 */
5177 _elements: { },
5178
5179 /**
5180 * page No input
5181 * @var jQuery
5182 */
5183 _pageNo: null,
5184
5185 /**
5186 * Initializes the 'jump to page' overlay for given selector.
5187 *
5188 * @param string selector
5189 * @param object callback
5190 */
5191 init: function(selector, callback) {
5192 var $elements = $(selector);
5193 if (!$elements.length) {
5194 return;
5195 }
5196
5197 callback = callback || null;
5198 if (callback !== null && !$.isFunction(callback)) {
5199 console.debug("[WCF.System.PageNavigation] Callback for selector '" + selector + "' is invalid, aborting.");
5200 return;
5201 }
5202
5203 this._initElements($elements, callback);
5204 },
5205
5206 /**
5207 * Initializes the 'jump to page' overlay for given elements.
5208 *
5209 * @param jQuery elements
5210 * @param object callback
5211 */
5212 _initElements: function(elements, callback) {
5213 var self = this;
5214 elements.each(function(index, element) {
5215 var $element = $(element);
5216 console.debug($element.data());
5217 var $elementID = $element.wcfIdentify();
5218 if (self._elements[$elementID] === undefined) {
5219 self._elements[$elementID] = $element;
5220 $element.find('li.jumpTo').data('elementID', $elementID).click($.proxy(self._click, self));
5221 }
5222 }).data('callback', callback);
5223 },
5224
5225 /**
5226 * Shows the 'jump to page' overlay.
5227 *
5228 * @param object event
5229 */
5230 _click: function(event) {
5231 this._elementID = $(event.currentTarget).data('elementID');
5232
5233 if (this._dialog === null) {
5234 this._dialog = $('<div id="pageNavigationOverlay" />').hide().appendTo(document.body);
5235
5236 var $fieldset = $('<fieldset><legend>' + WCF.Language.get('wcf.global.page.jumpTo') + '</legend></fieldset>').appendTo(this._dialog);
5237 $('<dl><dt><label for="jsPageNavigationPageNo">' + WCF.Language.get('wcf.global.page.jumpTo') + '</label></dt><dd></dd></dl>').appendTo($fieldset);
5238 this._pageNo = $('<input type="number" id="jsPageNavigationPageNo" value="1" min="1" max="1" class="long" />').keyup($.proxy(this._keyUp, this)).appendTo($fieldset.find('dd'));
5239 this._description = $('<small></small>').insertAfter(this._pageNo);
5240 var $formSubmit = $('<div class="formSubmit" />').appendTo(this._dialog);
5241 this._button = $('<button class="buttonPrimary">' + WCF.Language.get('wcf.global.button.submit') + '</button>').click($.proxy(this._submit, this)).appendTo($formSubmit);
5242 }
5243
5244 this._button.enable();
5245 this._description.html(WCF.Language.get('wcf.global.page.jumpTo.description').replace(/#pages#/, this._elements[this._elementID].data('pages')));
5246 this._pageNo.val('1').attr('max', this._elements[this._elementID].data('pages'));
5247
5248 this._dialog.wcfDialog({
5249 'title': WCF.Language.get('wcf.global.page.pageNavigation')
5250 });
5251 },
5252
5253 /**
5254 * Validates the page No input.
5255 */
5256 _keyUp: function() {
5257 var $pageNo = parseInt(this._pageNo.val()) || 0;
5258 if ($pageNo < 1 || $pageNo > this._pageNo.attr('max')) {
5259 this._button.disable();
5260 }
5261 else {
5262 this._button.enable();
5263 }
5264 },
5265
5266 /**
5267 * Redirects to given page No.
5268 */
5269 _submit: function() {
5270 var $pageNavigation = this._elements[this._elementID];
5271 if ($pageNavigation.data('callback') === null) {
5272 var $redirectURL = $pageNavigation.data('link').replace(/pageNo=%d/, 'pageNo=' + this._pageNo.val());
5273 window.location = $redirectURL;
5274 }
5275 else {
5276 $pageNavigation.data('callback')(this._pageNo.val());
5277 this._dialog.wcfDialog('close');
5278 }
5279 }
5280 };
5281
5282 /**
5283 * Sends periodical requests to protect the session from expiring. By default
5284 * it will send a request 1 minute before it would expire.
5285 *
5286 * @param integer seconds
5287 */
5288 WCF.System.KeepAlive = Class.extend({
5289 /**
5290 * Initializes the WCF.System.KeepAlive class.
5291 *
5292 * @param integer seconds
5293 */
5294 init: function(seconds) {
5295 new WCF.PeriodicalExecuter(function() {
5296 new WCF.Action.Proxy({
5297 autoSend: true,
5298 data: {
5299 actionName: 'keepAlive',
5300 className: 'wcf\\data\\session\\SessionAction'
5301 },
5302 showLoadingOverlay: false
5303 });
5304 }, (seconds * 1000));
5305 }
5306 });
5307
5308 /**
5309 * Default implementation for inline editors.
5310 *
5311 * @param string elementSelector
5312 */
5313 WCF.InlineEditor = Class.extend({
5314 /**
5315 * list of registered callbacks
5316 * @var array<object>
5317 */
5318 _callbacks: [ ],
5319
5320 /**
5321 * list of dropdown selections
5322 * @var object
5323 */
5324 _dropdowns: { },
5325
5326 /**
5327 * list of container elements
5328 * @var object
5329 */
5330 _elements: { },
5331
5332 /**
5333 * notification object
5334 * @var WCF.System.Notification
5335 */
5336 _notification: null,
5337
5338 /**
5339 * list of known options
5340 * @var array<object>
5341 */
5342 _options: [ ],
5343
5344 /**
5345 * action proxy
5346 * @var WCF.Action.Proxy
5347 */
5348 _proxy: null,
5349
5350 /**
5351 * list of data to update upon success
5352 * @var array<object>
5353 */
5354 _updateData: [ ],
5355
5356 /**
5357 * Initializes a new inline editor.
5358 */
5359 init: function(elementSelector) {
5360 var $elements = $(elementSelector);
5361 if (!$elements.length) {
5362 return;
5363 }
5364
5365 var self = this;
5366 $elements.each(function(index, element) {
5367 var $element = $(element);
5368 var $elementID = $element.wcfIdentify();
5369
5370 // find trigger element
5371 var $trigger = self._getTriggerElement($element);
5372 if ($trigger === null || $trigger.length !== 1) {
5373 return;
5374 }
5375
5376 $trigger.click($.proxy(self._show, self)).data('elementID', $elementID);
5377
5378 // store reference
5379 self._elements[$elementID] = $element;
5380 });
5381
5382 this._proxy = new WCF.Action.Proxy({
5383 success: $.proxy(this._success, this)
5384 });
5385
5386 this._setOptions();
5387
5388 WCF.CloseOverlayHandler.addCallback('WCF.InlineEditor', $.proxy(this._closeAll, this));
5389
5390 this._notification = new WCF.System.Notification(WCF.Language.get('wcf.global.success'), 'success');
5391 },
5392
5393 /**
5394 * Closes all inline editors.
5395 */
5396 _closeAll: function() {
5397 for (var $elementID in this._elements) {
5398 this._hide($elementID);
5399 }
5400 },
5401
5402 /**
5403 * Sets options for this inline editor.
5404 */
5405 _setOptions: function() {
5406 this._options = [ ];
5407 },
5408
5409 /**
5410 * Register an option callback for validation and execution.
5411 *
5412 * @param object callback
5413 */
5414 registerCallback: function(callback) {
5415 if ($.isFunction(callback)) {
5416 this._callbacks.push(callback);
5417 }
5418 },
5419
5420 /**
5421 * Returns the triggering element.
5422 *
5423 * @param jQuery element
5424 * @return jQuery
5425 */
5426 _getTriggerElement: function(element) {
5427 return null;
5428 },
5429
5430 /**
5431 * Shows a dropdown menu if options are available.
5432 *
5433 * @param object event
5434 */
5435 _show: function(event) {
5436 var $elementID = $(event.currentTarget).data('elementID');
5437
5438 // build drop down
5439 if (!this._dropdowns[$elementID]) {
5440 var $trigger = this._getTriggerElement(this._elements[$elementID]).addClass('dropdownToggle').wrap('<span class="dropdown" />');
5441 var $dropdown = $trigger.parent('span');
5442 $trigger.data('target', $dropdown.wcfIdentify());
5443 this._dropdowns[$elementID] = $('<ul class="dropdownMenu" style="top: ' + ($dropdown.outerHeight() + 14) + 'px;" />').insertAfter($trigger);
5444 }
5445 this._dropdowns[$elementID].empty();
5446
5447 // validate options
5448 var $hasOptions = false;
5449 var $lastElementType = '';
5450 for (var $i = 0, $length = this._options.length; $i < $length; $i++) {
5451 var $option = this._options[$i];
5452
5453 if ($option.optionName === 'divider') {
5454 if ($lastElementType !== '' && $lastElementType !== 'divider') {
5455 $('<li class="dropdownDivider" />').appendTo(this._dropdowns[$elementID]);
5456 $lastElementType = $option.optionName;
5457 }
5458 }
5459 else if (this._validate($elementID, $option.optionName) || this._validateCallbacks($elementID, $option.optionName)) {
5460 var $listItem = $('<li><span>' + $option.label + '</span></li>').appendTo(this._dropdowns[$elementID]);
5461 $listItem.data('elementID', $elementID).data('optionName', $option.optionName).click($.proxy(this._click, this));
5462
5463 $hasOptions = true;
5464 $lastElementType = $option.optionName;
5465 }
5466 }
5467
5468 if ($hasOptions) {
5469 this._dropdowns[$elementID].parent('span').addClass('dropdownOpen');
5470 }
5471
5472 return false;
5473 },
5474
5475 /**
5476 * Validates an option.
5477 *
5478 * @param string elementID
5479 * @param string optionName
5480 * @returns boolean
5481 */
5482 _validate: function(elementID, optionName) {
5483 return false;
5484 },
5485
5486 /**
5487 * Validates an option provided by callbacks.
5488 *
5489 * @param string elementID
5490 * @param string optionName
5491 * @return boolean
5492 */
5493 _validateCallbacks: function(elementID, optionName) {
5494 var $length = this._callbacks.length;
5495 if ($length) {
5496 for (var $i = 0; $i < $length; $i++) {
5497 if (this._callbacks[$i].validate(this._elements[elementID], optionName)) {
5498 return true;
5499 }
5500 }
5501 }
5502
5503 return false;
5504 },
5505
5506 /**
5507 * Handles AJAX responses.
5508 *
5509 * @param object data
5510 * @param string textStatus
5511 * @param jQuery jqXHR
5512 */
5513 _success: function(data, textStatus, jqXHR) {
5514 var $length = this._updateData.length;
5515 if (!$length) {
5516 return;
5517 }
5518
5519 this._updateState();
5520
5521 this._updateData = [ ];
5522 },
5523
5524 /**
5525 * Update element states based upon update data.
5526 */
5527 _updateState: function() { },
5528
5529 /**
5530 * Handles clicks within dropdown.
5531 *
5532 * @param object event
5533 */
5534 _click: function(event) {
5535 var $listItem = $(event.currentTarget);
5536 var $elementID = $listItem.data('elementID');
5537 var $optionName = $listItem.data('optionName');
5538
5539 if (!this._execute($elementID, $optionName)) {
5540 this._executeCallback($elementID, $optionName);
5541 }
5542
5543 this._hide($elementID);
5544 },
5545
5546 /**
5547 * Executes actions associated with an option.
5548 *
5549 * @param string elementID
5550 * @param string optionName
5551 * @return boolean
5552 */
5553 _execute: function(elementID, optionName) {
5554 return false;
5555 },
5556
5557 /**
5558 * Executes actions associated with an option provided by callbacks.
5559 *
5560 * @param string elementID
5561 * @param string optionName
5562 * @return boolean
5563 */
5564 _executeCallback: function(elementID, optionName) {
5565 var $length = this._callbacks.length;
5566 if ($length) {
5567 for (var $i = 0; $i < $length; $i++) {
5568 if (this._callbacks[$i].execute(this._elements[elementID], optionName)) {
5569 return true;
5570 }
5571 }
5572 }
5573
5574 return false;
5575 },
5576
5577 /**
5578 * Hides a dropdown menu.
5579 *
5580 * @param string elementID
5581 */
5582 _hide: function(elementID) {
5583 if (this._dropdowns[elementID]) {
5584 this._dropdowns[elementID].empty().parent('span').removeClass('dropdownOpen');
5585 }
5586 }
5587 });
5588
5589 /**
5590 * Default implementation for ajax file uploads
5591 *
5592 * @param jquery buttonSelector
5593 * @param jquery fileListSelector
5594 * @param string className
5595 * @param jquery options
5596 */
5597 WCF.Upload = Class.extend({
5598 /**
5599 * name of the upload field
5600 * @var string
5601 */
5602 _name: '__files[]',
5603
5604 /**
5605 * button selector
5606 * @var jQuery
5607 */
5608 _buttonSelector: null,
5609
5610 /**
5611 * file list selector
5612 * @var jQuery
5613 */
5614 _fileListSelector: null,
5615
5616 /**
5617 * upload file
5618 * @var jQuery
5619 */
5620 _fileUpload: null,
5621
5622 /**
5623 * class name
5624 * @var string
5625 */
5626 _className: '',
5627
5628 /**
5629 * additional options
5630 * @var jQuery
5631 */
5632 _options: {},
5633
5634 /**
5635 * upload matrix
5636 * @var array
5637 */
5638 _uploadMatrix: [],
5639
5640 /**
5641 * true, if the active user's browser supports ajax file uploads
5642 * @var boolean
5643 */
5644 _supportsAJAXUpload: true,
5645
5646 /**
5647 * fallback overlay for stupid browsers
5648 * @var jquery
5649 */
5650 _overlay: null,
5651
5652 /**
5653 * Initializes a new upload handler.
5654 */
5655 init: function(buttonSelector, fileListSelector, className, options) {
5656 this._buttonSelector = buttonSelector;
5657 this._fileListSelector = fileListSelector;
5658 this._className = className;
5659 this._options = $.extend(true, {
5660 action: 'upload',
5661 multiple: false,
5662 url: 'index.php/AJAXUpload/?t=' + SECURITY_TOKEN + SID_ARG_2ND
5663 }, options);
5664
5665 // check for ajax upload support
5666 var $xhr = new XMLHttpRequest();
5667 this._supportsAJAXUpload = ($xhr && ('upload' in $xhr) && ('onprogress' in $xhr.upload));
5668
5669 // create upload button
5670 this._createButton();
5671 },
5672
5673 /**
5674 * Creates the upload button.
5675 */
5676 _createButton: function() {
5677 if (this._supportsAJAXUpload) {
5678 this._fileUpload = $('<input type="file" name="'+this._name+'" '+(this._options.multiple ? 'multiple="true" ' : '')+'/>');
5679 this._fileUpload.change($.proxy(this._upload, this));
5680 var $button = $('<p class="button uploadButton"><span>'+WCF.Language.get('wcf.global.button.upload')+'</span></p>');
5681 $button.append(this._fileUpload);
5682 }
5683 else {
5684 var $button = $('<p class="button"><span>Upload</span></p>');
5685 $button.click($.proxy(this._showOverlay, this));
5686 }
5687
5688 this._insertButton($button);
5689 },
5690
5691 /**
5692 * Inserts the upload button.
5693 */
5694 _insertButton: function(button) {
5695 this._buttonSelector.append(button);
5696 },
5697
5698 /**
5699 * Callback for file uploads.
5700 */
5701 _upload: function() {
5702 var $files = this._fileUpload.prop('files');
5703
5704 if ($files.length > 0) {
5705 var $fd = new FormData();
5706 var self = this;
5707 var $uploadID = this._uploadMatrix.length;
5708 this._uploadMatrix[$uploadID] = [];
5709
5710 for (var $i = 0; $i < $files.length; $i++) {
5711 var $li = this._initFile($files[$i]);
5712 $li.data('filename', $files[$i].name);
5713 this._uploadMatrix[$uploadID].push($li);
5714 $fd.append('__files[]', $files[$i]);
5715 }
5716 $fd.append('actionName', this._options.action);
5717 $fd.append('className', this._className);
5718 var $additionalParameters = this._getParameters();
5719 for (var $name in $additionalParameters) {
5720 $fd.append('parameters['+$name+']', $additionalParameters[$name]);
5721 }
5722
5723 $.ajax({
5724 type: 'POST',
5725 url: this._options.url,
5726 enctype: 'multipart/form-data',
5727 data: $fd,
5728 contentType: false,
5729 processData: false,
5730 success: function(data, textStatus, jqXHR) {
5731 self._success($uploadID, data);
5732 },
5733 error: $.proxy(this._error, this),
5734 xhr: function() {
5735 var $xhr = $.ajaxSettings.xhr();
5736 if ($xhr) {
5737 $xhr.upload.addEventListener('progress', function(event) {
5738 self._progress($uploadID, event);
5739 }, false);
5740 }
5741 return $xhr;
5742 }
5743 });
5744 }
5745 },
5746
5747 /**
5748 * Callback for success event
5749 */
5750 _success: function(uploadID, data) {
5751 console.debug(data);
5752 },
5753
5754 /**
5755 * Callback for error event
5756 */
5757 _error: function(jqXHR, textStatus, errorThrown) {
5758 console.debug(jqXHR.responseText);
5759 },
5760
5761 /**
5762 * Callback for progress event
5763 */
5764 _progress: function(uploadID, event) {
5765 var $percentComplete = Math.round(event.loaded * 100 / event.total);
5766
5767 for (var $i = 0; $i < this._uploadMatrix[uploadID].length; $i++) {
5768 this._uploadMatrix[uploadID][$i].find('progress').attr('value', $percentComplete);
5769 }
5770 },
5771
5772 /**
5773 * Returns additional parameters.
5774 */
5775 _getParameters: function() {
5776 return {};
5777 },
5778
5779 _initFile: function(file) {
5780 var $li = $('<li>'+file.name+' ('+file.size+')<progress max="100"></progress></li>');
5781 this._fileListSelector.append($li);
5782
5783 return $li;
5784 },
5785
5786 /**
5787 * Shows the fallback overlay (work in progress)
5788 */
5789 _showOverlay: function() {
5790 var $self = this;
5791 if (!this._overlay) {
5792 // create overlay
5793 this._overlay = $('<div style="display: none;"><form enctype="multipart/form-data" method="post" action="'+this._options.url+'"><dl><dt><label for="__fileUpload">File</label></dt><dd><input type="file" id="__fileUpload" name="'+this._name+'" '+(this._options.multiple ? 'multiple="true" ' : '')+'/></dd></dl><div class="formSubmit"><input type="submit" value="Upload" accesskey="s" /></div></form></div>');
5794 }
5795
5796 // create iframe
5797 var $iframe = $('<iframe style="display: none"></iframe>'); // width: 300px; height: 100px; border: 5px solid red
5798 $iframe.attr('name', $iframe.wcfIdentify());
5799 $('body').append($iframe);
5800 this._overlay.find('form').attr('target', $iframe.wcfIdentify());
5801
5802 // add events (iframe onload)
5803 $iframe.load(function() {
5804 console.debug('iframe ready');
5805 console.debug($iframe.contents());
5806 });
5807
5808 this._overlay.find('form').submit(function() {
5809 $iframe.data('loading', true);
5810 $self._overlay.wcfDialog('close');
5811 });
5812
5813 this._overlay.wcfDialog({
5814 title: 'Upload',
5815 onClose: function() {
5816 if (!$iframe.data('loading')) {
5817 $iframe.remove();
5818 }
5819 }
5820 });
5821 }
5822 });
5823
5824 /**
5825 * Namespace for sortables.
5826 */
5827 WCF.Sortable = {};
5828
5829 /**
5830 * Sortable implementation for lists.
5831 *
5832 * @param string containerID
5833 * @param string className
5834 * @param integer offset
5835 * @param object options
5836 */
5837 WCF.Sortable.List = Class.extend({
5838 /**
5839 * additional parameters for AJAX request
5840 * @var object
5841 */
5842 _additionalParameters: { },
5843
5844 /**
5845 * action class name
5846 * @var string
5847 */
5848 _className: '',
5849
5850 /**
5851 * container id
5852 * @var string
5853 */
5854 _containerID: '',
5855
5856 /**
5857 * container object
5858 * @var jQuery
5859 */
5860 _container: null,
5861
5862 /**
5863 * notification object
5864 * @var WCF.System.Notification
5865 */
5866 _notification: null,
5867
5868 /**
5869 * show order offset
5870 * @var integer
5871 */
5872 _offset: 0,
5873
5874 /**
5875 * list of options
5876 * @var object
5877 */
5878 _options: { },
5879
5880 /**
5881 * proxy object
5882 * @var WCF.Action.Proxy
5883 */
5884 _proxy: null,
5885
5886 /**
5887 * object structure
5888 * @var object
5889 */
5890 _structure: { },
5891
5892 /**
5893 * Creates a new sortable list.
5894 *
5895 * @param string containerID
5896 * @param string className
5897 * @param integer offset
5898 * @param object options
5899 * @param boolean isSimpleSorting
5900 * @param object additionalParameters
5901 */
5902 init: function(containerID, className, offset, options, isSimpleSorting, additionalParameters) {
5903 this._additionalParameters = additionalParameters || { };
5904 this._containerID = $.wcfEscapeID(containerID);
5905 this._container = $('#' + this._containerID);
5906 this._className = className;
5907 this._offset = (offset) ? offset : 0;
5908 this._proxy = new WCF.Action.Proxy({
5909 success: $.proxy(this._success, this)
5910 });
5911 this._structure = { };
5912
5913 // init sortable
5914 this._options = $.extend(true, {
5915 axis: 'y',
5916 connectWith: '#' + this._containerID + ' .sortableList',
5917 disableNesting: 'sortableNoNesting',
5918 errorClass: 'sortableInvalidTarget',
5919 forcePlaceholderSize: true,
5920 helper: 'clone',
5921 items: 'li:not(.sortableNoSorting)',
5922 opacity: .6,
5923 placeholder: 'sortablePlaceholder',
5924 tolerance: 'pointer',
5925 toleranceElement: '> span'
5926 }, options || { });
5927
5928 if (isSimpleSorting) {
5929 $('#' + this._containerID + ' .sortableList').sortable(this._options);
5930 }
5931 else {
5932 $('#' + this._containerID + ' > .sortableList').wcfNestedSortable(this._options);
5933 }
5934
5935 if (this._className) {
5936 this._container.find('.formSubmit > button[data-type="submit"]').click($.proxy(this._submit, this));
5937 }
5938 },
5939
5940 /**
5941 * Saves object structure.
5942 */
5943 _submit: function() {
5944 // reset structure
5945 this._structure = { };
5946
5947 // build structure
5948 this._container.find('.sortableList').each($.proxy(function(index, list) {
5949 var $list = $(list);
5950 var $parentID = $list.data('objectID');
5951
5952 if ($parentID !== undefined) {
5953 $list.children(this._options.items).each($.proxy(function(index, listItem) {
5954 var $objectID = $(listItem).data('objectID');
5955
5956 if (!this._structure[$parentID]) {
5957 this._structure[$parentID] = [ ];
5958 }
5959
5960 this._structure[$parentID].push($objectID);
5961 }, this));
5962 }
5963 }, this));
5964
5965 // send request
5966 var $parameters = $.extend(true, {
5967 data: {
5968 offset: this._offset,
5969 structure: this._structure
5970 }
5971 }, this._additionalParameters);
5972
5973 this._proxy.setOption('data', {
5974 actionName: 'updatePosition',
5975 className: this._className,
5976 parameters: $parameters
5977 });
5978 this._proxy.sendRequest();
5979 },
5980
5981 /**
5982 * Shows notification upon success.
5983 *
5984 * @param object data
5985 * @param string textStatus
5986 * @param jQuery jqXHR
5987 */
5988 _success: function(data, textStatus, jqXHR) {
5989 if (this._notification === null) {
5990 this._notification = new WCF.System.Notification(WCF.Language.get('wcf.global.form.edit.success'));
5991 }
5992
5993 this._notification.show();
5994 }
5995 });
5996
5997 WCF.Popover = Class.extend({
5998 /**
5999 * currently active element id
6000 * @var string
6001 */
6002 _activeElementID: '',
6003
6004 /**
6005 * cancels popover
6006 * @var boolean
6007 */
6008 _cancelPopover: false,
6009
6010 /**
6011 * element data
6012 * @var object
6013 */
6014 _data: { },
6015
6016 /**
6017 * default dimensions, should reflect the estimated size
6018 * @var object
6019 */
6020 _defaultDimensions: {
6021 height: 150,
6022 width: 450
6023 },
6024
6025 /**
6026 * default orientation, may be a combintion of left/right and bottom/top
6027 * @var object
6028 */
6029 _defaultOrientation: {
6030 x: 'right',
6031 y: 'top'
6032 },
6033
6034 /**
6035 * delay to show or hide popover, values in miliseconds
6036 * @var object
6037 */
6038 _delay: {
6039 show: 250,
6040 hide: 500
6041 },
6042
6043 /**
6044 * true, if an element is being hovered
6045 * @var boolean
6046 */
6047 _hoverElement: false,
6048
6049 /**
6050 * element id of element being hovered
6051 * @var string
6052 */
6053 _hoverElementID: '',
6054
6055 /**
6056 * true, if popover is being hovered
6057 * @var boolean
6058 */
6059 _hoverPopover: false,
6060
6061 /**
6062 * minimum margin (all directions) for popover
6063 * @var integer
6064 */
6065 _margin: 20,
6066
6067 /**
6068 * periodical executer once element or popover is no longer being hovered
6069 * @var WCF.PeriodicalExecuter
6070 */
6071 _peOut: null,
6072
6073 /**
6074 * periodical executer once an element is being hovered
6075 * @var WCF.PeriodicalExecuter
6076 */
6077 _peOverElement: null,
6078
6079 /**
6080 * popover object
6081 * @var jQuery
6082 */
6083 _popover: null,
6084
6085 /**
6086 * popover content
6087 * @var jQuery
6088 */
6089 _popoverContent: null,
6090
6091
6092 /**
6093 * popover horizontal offset
6094 * @var integer
6095 */
6096 _popoverOffset: 10,
6097
6098 /**
6099 * element selector
6100 * @var string
6101 */
6102 _selector: '',
6103
6104 /**
6105 * Initializes a new WCF.Popover object.
6106 *
6107 * @param string selector
6108 */
6109 init: function(selector) {
6110 // assign default values
6111 this._activeElementID = '';
6112 this._cancelPopover = false;
6113 this._data = { };
6114 this._defaultDimensions = {
6115 height: 150,
6116 width: 450
6117 };
6118 this._defaultOrientation = {
6119 x: 'right',
6120 y: 'top'
6121 };
6122 this._delay = {
6123 show: 250,
6124 hide: 500
6125 };
6126 this._hoverElement = false;
6127 this._hoverElementID = '';
6128 this._hoverPopover = false;
6129 this._margin = 20;
6130 this._peOut = null;
6131 this._peOverElement = null;
6132 this._popoverOffset = 10;
6133 this._selector = selector;
6134
6135 this._popover = $('<div class="popover"><div class="popoverContent"></div></div>').hide().appendTo(document.body);
6136 this._popoverContent = this._popover.children('.popoverContent:eq(0)');
6137 this._popover.hover($.proxy(this._overPopover, this), $.proxy(this._out, this));
6138
6139 this._initContainers();
6140 WCF.DOMNodeInsertedHandler.addCallback('WCF.Popover.'+selector, $.proxy(this._initContainers, this));
6141 },
6142
6143 /**
6144 * Initializes all element triggers.
6145 */
6146 _initContainers: function() {
6147 var $elements = $(this._selector);
6148 if (!$elements.length) {
6149 return;
6150 }
6151
6152 $elements.each($.proxy(function(index, element) {
6153 var $element = $(element);
6154 var $elementID = $element.wcfIdentify();
6155
6156 if (!this._data[$elementID]) {
6157 this._data[$elementID] = {
6158 'content': null,
6159 'isLoading': false
6160 };
6161
6162 $element.hover($.proxy(this._overElement, this), $.proxy(this._out, this));
6163
6164 if ($element.getTagName() === 'a' && $element.attr('href')) {
6165 $element.click($.proxy(this._cancel, this));
6166 }
6167 }
6168 }, this));
6169 },
6170
6171 /**
6172 * Cancels popovers if link is being clicked
6173 */
6174 _cancel: function(event) {
6175 this._cancelPopover = true;
6176 this._hide(true);
6177 },
6178
6179 /**
6180 * Triggered once an element is being hovered.
6181 *
6182 * @param object event
6183 */
6184 _overElement: function(event) {
6185 if (this._cancelPopover) {
6186 return;
6187 }
6188
6189 if (this._peOverElement !== null) {
6190 this._peOverElement.stop();
6191 }
6192
6193 var $elementID = $(event.currentTarget).wcfIdentify();
6194 this._hoverElementID = $elementID;
6195 this._peOverElement = new WCF.PeriodicalExecuter($.proxy(function(pe) {
6196 pe.stop();
6197
6198 // still above the same element
6199 if (this._hoverElementID === $elementID) {
6200 this._activeElementID = $elementID;
6201 this._prepare();
6202 }
6203 }, this), this._delay.show);
6204
6205 this._hoverElement = true;
6206 this._hoverPopover = false;
6207 },
6208
6209 /**
6210 * Prepares popover to be displayed.
6211 */
6212 _prepare: function() {
6213 if (this._cancelPopover) {
6214 return;
6215 }
6216
6217 if (this._peOut !== null) {
6218 this._peOut.stop();
6219 }
6220
6221 // hide and reset
6222 if (this._popover.is(':visible')) {
6223 this._hide(true);
6224 }
6225
6226 // insert html
6227 if (!this._data[this._activeElementID].loading && this._data[this._activeElementID].content) {
6228 WCF.DOMNodeInsertedHandler.enable();
6229
6230 this._popoverContent.html(this._data[this._activeElementID].content);
6231
6232 WCF.DOMNodeInsertedHandler.disable();
6233 }
6234 else {
6235 this._data[this._activeElementID].loading = true;
6236 }
6237
6238 // get dimensions
6239 var $dimensions = this._popover.show().getDimensions();
6240 if (this._data[this._activeElementID].loading) {
6241 $dimensions = {
6242 height: Math.max($dimensions.height, this._defaultDimensions.height),
6243 width: Math.max($dimensions.width, this._defaultDimensions.width)
6244 };
6245 }
6246 else {
6247 $dimensions = this._fixElementDimensions(this._popover, $dimensions);
6248 }
6249 this._popover.hide();
6250
6251 // get orientation
6252 var $orientation = this._getOrientation($dimensions.height, $dimensions.width);
6253 this._popover.css(this._getCSS($orientation.x, $orientation.y));
6254
6255 // apply orientation to popover
6256 this._popover.removeClass('bottom left right top').addClass($orientation.x).addClass($orientation.y);
6257
6258 this._show();
6259 },
6260
6261 /**
6262 * Displays the popover.
6263 */
6264 _show: function() {
6265 if (this._cancelPopover) {
6266 return;
6267 }
6268
6269 this._popover.stop().show().css({ opacity: 1 }).wcfFadeIn();
6270
6271 if (this._data[this._activeElementID].loading) {
6272 this._loadContent();
6273 }
6274 else {
6275 this._popoverContent.css({ opacity: 1 });
6276 }
6277 },
6278
6279 /**
6280 * Loads content, should be overwritten by child classes.
6281 */
6282 _loadContent: function() { },
6283
6284 /**
6285 * Inserts content and animating transition.
6286 *
6287 * @param string elementID
6288 * @param boolean animate
6289 */
6290 _insertContent: function(elementID, content, animate) {
6291 this._data[elementID] = {
6292 content: content,
6293 loading: false
6294 };
6295
6296 // only update content if element id is active
6297 if (this._activeElementID === elementID) {
6298 WCF.DOMNodeInsertedHandler.enable();
6299
6300 if (animate) {
6301 // get current dimensions
6302 var $dimensions = this._popoverContent.getDimensions();
6303
6304 // insert new content
6305 this._popoverContent.css({
6306 height: 'auto',
6307 width: 'auto'
6308 });
6309 this._popoverContent.html(this._data[elementID].content);
6310 var $newDimensions = this._popoverContent.getDimensions();
6311
6312 // enforce current dimensions and remove HTML
6313 this._popoverContent.html('').css({
6314 height: $dimensions.height + 'px',
6315 width: $dimensions.width + 'px'
6316 });
6317
6318 // animate to new dimensons
6319 var self = this;
6320 this._popoverContent.animate({
6321 height: $newDimensions.height + 'px',
6322 width: $newDimensions.width + 'px'
6323 }, 300, function() {
6324 WCF.DOMNodeInsertedHandler.enable();
6325
6326 self._popoverContent.html(self._data[elementID].content).css({ opacity: 0 }).animate({ opacity: 1 }, 200);
6327
6328 WCF.DOMNodeInsertedHandler.disable();
6329 });
6330 }
6331 else {
6332 // insert new content
6333 this._popoverContent.html(this._data[elementID].content);
6334 }
6335
6336 WCF.DOMNodeInsertedHandler.disable();
6337 }
6338 },
6339
6340 /**
6341 * Hides the popover.
6342 */
6343 _hide: function(disableAnimation) {
6344 var self = this;
6345 this._popoverContent.stop();
6346 this._popover.stop();
6347
6348 if (disableAnimation) {
6349 self._popover.css({ opacity: 0 }).hide();
6350 self._popoverContent.empty().css({ height: 'auto', opacity: 0, width: 'auto' });
6351 }
6352 else {
6353 this._popover.wcfFadeOut(function() {
6354 self._popoverContent.empty().css({ height: 'auto', opacity: 0, width: 'auto' });
6355 self._popover.hide();
6356 });
6357 }
6358 },
6359
6360 /**
6361 * Triggered once popover is being hovered.
6362 */
6363 _overPopover: function() {
6364 if (this._peOut !== null) {
6365 this._peOut.stop();
6366 }
6367
6368 this._hoverElement = false;
6369 this._hoverPopover = true;
6370 },
6371
6372 /**
6373 * Triggered once element *or* popover is now longer hovered.
6374 */
6375 _out: function(event) {
6376 if (this._cancelPopover) {
6377 return;
6378 }
6379
6380 this._hoverElementID = '';
6381 this._hoverElement = false;
6382 this._hoverPopover = false;
6383
6384 this._peOut = new WCF.PeriodicalExecuter($.proxy(function(pe) {
6385 pe.stop();
6386
6387 // hide popover is neither element nor popover was hovered given time
6388 if (!this._hoverElement && !this._hoverPopover) {
6389 this._hide(false);
6390 }
6391 }, this), this._delay.hide);
6392 },
6393
6394 /**
6395 * Resolves popover orientation, tries to use default orientation first.
6396 *
6397 * @param integer height
6398 * @param integer width
6399 * @return object
6400 */
6401 _getOrientation: function(height, width) {
6402 // get offsets and dimensions
6403 var $element = $('#' + this._activeElementID);
6404 var $offsets = $element.getOffsets('offset');
6405 var $elementDimensions = $element.getDimensions();
6406 var $documentDimensions = $(document).getDimensions();
6407
6408 // try default orientation first
6409 var $orientationX = (this._defaultOrientation.x === 'left') ? 'left' : 'right';
6410 var $orientationY = (this._defaultOrientation.y === 'bottom') ? 'bottom' : 'top';
6411 var $result = this._evaluateOrientation($orientationX, $orientationY, $offsets, $elementDimensions, $documentDimensions, height, width);
6412
6413 if ($result.flawed) {
6414 // try flipping orientationX
6415 $orientationX = ($orientationX === 'left') ? 'right' : 'left';
6416 $result = this._evaluateOrientation($orientationX, $orientationY, $offsets, $elementDimensions, $documentDimensions, height, width);
6417
6418 if ($result.flawed) {
6419 // try flipping orientationY while maintaing original orientationX
6420 $orientationX = ($orientationX === 'right') ? 'left' : 'right';
6421 $orientationY = ($orientationY === 'bottom') ? 'top' : 'bottom';
6422 $result = this._evaluateOrientation($orientationX, $orientationY, $offsets, $elementDimensions, $documentDimensions, height, width);
6423
6424 if ($result.flawed) {
6425 // try flipping both orientationX and orientationY compared to default values
6426 $orientationX = ($orientationX === 'left') ? 'right' : 'left';
6427 $result = this._evaluateOrientation($orientationX, $orientationY, $offsets, $elementDimensions, $documentDimensions, height, width);
6428
6429 if ($result.flawed) {
6430 // fuck this shit, we will use the default orientation
6431 $orientationX = (this._defaultOrientationX === 'left') ? 'left' : 'right';
6432 $orientationY = (this._defaultOrientationY === 'bottom') ? 'bottom' : 'top';
6433 }
6434 }
6435 }
6436 }
6437
6438 return {
6439 x: $orientationX,
6440 y: $orientationY
6441 };
6442 },
6443
6444 /**
6445 * Evaluates if popover fits into given orientation.
6446 *
6447 * @param string orientationX
6448 * @param string orientationY
6449 * @param object offsets
6450 * @param object elementDimensions
6451 * @param object documentDimensions
6452 * @param integer height
6453 * @param integer width
6454 * @return object
6455 */
6456 _evaluateOrientation: function(orientationX, orientationY, offsets, elementDimensions, documentDimensions, height, width) {
6457 var $heightDifference = 0, $widthDifference = 0;
6458 switch (orientationX) {
6459 case 'left':
6460 $widthDifference = offsets.left - width;
6461 break;
6462
6463 case 'right':
6464 $widthDifference = documentDimensions.width - (offsets.left + width);
6465 break;
6466 }
6467
6468 switch (orientationY) {
6469 case 'bottom':
6470 $heightDifference = documentDimensions.height - (offsets.top + elementDimensions.height + this._popoverOffset + height);
6471 break;
6472
6473 case 'top':
6474 $heightDifference = offsets.top - (height - this._popoverOffset);
6475 break;
6476 }
6477
6478 // check if both difference are above margin
6479 var $flawed = false;
6480 if ($heightDifference < this._margin || $widthDifference < this._margin) {
6481 $flawed = true;
6482 }
6483
6484 return {
6485 flawed: $flawed,
6486 x: $widthDifference,
6487 y: $heightDifference
6488 };
6489 },
6490
6491 /**
6492 * Computes CSS for popover.
6493 *
6494 * @param string orientationX
6495 * @param string orientationY
6496 * @return object
6497 */
6498 _getCSS: function(orientationX, orientationY) {
6499 var $css = {
6500 bottom: 'auto',
6501 left: 'auto',
6502 right: 'auto',
6503 top: 'auto'
6504 };
6505
6506 var $element = $('#' + this._activeElementID);
6507 var $offsets = $element.getOffsets('offset');
6508 var $elementDimensions = this._fixElementDimensions($element, $element.getDimensions());
6509 var $windowDimensions = $(window).getDimensions();
6510
6511 switch (orientationX) {
6512 case 'left':
6513 $css.right = $windowDimensions.width - ($offsets.left + $elementDimensions.width);
6514 break;
6515
6516 case 'right':
6517 $css.left = $offsets.left;
6518 break;
6519 }
6520
6521 switch (orientationY) {
6522 case 'bottom':
6523 $css.top = $offsets.top + ($elementDimensions.height + this._popoverOffset);
6524 break;
6525
6526 case 'top':
6527 $css.bottom = $windowDimensions.height - ($offsets.top - this._popoverOffset);
6528 break;
6529 }
6530
6531 return $css;
6532 },
6533
6534 /**
6535 * Tries to fix dimensions if element is partially hidden (overflow: hidden).
6536 *
6537 * @param jQuery element
6538 * @param object dimensions
6539 * @return dimensions
6540 */
6541 _fixElementDimensions: function(element, dimensions) {
6542 var $parentDimensions = element.parent().getDimensions();
6543
6544 if ($parentDimensions.height < dimensions.height) {
6545 dimensions.height = $parentDimensions.height;
6546 }
6547
6548 if ($parentDimensions.width < dimensions.width) {
6549 dimensions.width = $parentDimensions.width;
6550 }
6551
6552 return dimensions;
6553 }
6554 });
6555
6556 /**
6557 * Provides an extensible item list with built-in search.
6558 *
6559 * @param string itemListSelector
6560 * @param string searchInputSelector
6561 */
6562 WCF.EditableItemList = Class.extend({
6563 /**
6564 * allows custom input not recognized by search to be added
6565 * @var boolean
6566 */
6567 _allowCustomInput: false,
6568
6569 /**
6570 * action class name
6571 * @var string
6572 */
6573 _className: '',
6574
6575 /**
6576 * internal data storage
6577 * @var mixed
6578 */
6579 _data: { },
6580
6581 /**
6582 * form container
6583 * @var jQuery
6584 */
6585 _form: null,
6586
6587 /**
6588 * item list container
6589 * @var jQuery
6590 */
6591 _itemList: null,
6592
6593 /**
6594 * current object id
6595 * @var integer
6596 */
6597 _objectID: 0,
6598
6599 /**
6600 * object type id
6601 * @var integer
6602 */
6603 _objectTypeID: 0,
6604
6605 /**
6606 * search controller
6607 * @var WCF.Search.Base
6608 */
6609 _search: null,
6610
6611 /**
6612 * search input element
6613 * @var jQuery
6614 */
6615 _searchInput: null,
6616
6617 /**
6618 * Creates a new WCF.EditableItemList object.
6619 *
6620 * @param string itemListSelector
6621 * @param string searchInputSelector
6622 */
6623 init: function(itemListSelector, searchInputSelector) {
6624 this._itemList = $(itemListSelector);
6625 this._searchInput = $(searchInputSelector);
6626
6627 if (!this._itemList.length || !this._searchInput.length) {
6628 console.debug("[WCF.EditableItemList] Item list and/or search input do not exist, aborting.");
6629 return;
6630 }
6631
6632 this._objectID = this._getObjectID();
6633 this._objectTypeID = this._getObjectTypeID();
6634
6635 // bind item listener
6636 this._itemList.find('.jsEditableItem').click($.proxy(this._click, this));
6637
6638 // create item list
6639 if (!this._itemList.children('ul').length) {
6640 $('<ul />').appendTo(this._itemList);
6641 }
6642 this._itemList = this._itemList.children('ul');
6643
6644 // bind form submit
6645 this._form = this._itemList.parents('form').submit($.proxy(this._submit, this));
6646
6647 if (this._allowCustomInput) {
6648 this._searchInput.keydown($.proxy(this._keyDown, this));
6649 }
6650 },
6651
6652 /**
6653 * Handles the key down event.
6654 *
6655 * @param object event
6656 */
6657 _keyDown: function(event) {
6658 if (event === null || (event.which === 13 || event.which === 188)) {
6659 var $value = $.trim(this._searchInput.val());
6660 if ($value === '') {
6661 return true;
6662 }
6663
6664 this.addItem({
6665 objectID: 0,
6666 label: $value
6667 });
6668
6669 // reset input
6670 this._searchInput.val('');
6671
6672 if (event !== null) {
6673 event.stopPropagation();
6674 }
6675
6676 return false;
6677 }
6678
6679 return true;
6680 },
6681
6682 /**
6683 * Loads raw data and converts it into internal structure. Override this methods
6684 * in your derived classes.
6685 *
6686 * @param object data
6687 */
6688 load: function(data) { },
6689
6690 /**
6691 * Removes an item on click.
6692 *
6693 * @param object event
6694 * @return boolean
6695 */
6696 _click: function(event) {
6697 var $element = $(event.currentTarget);
6698 var $objectID = $element.data('objectID');
6699 var $label = $element.data('label');
6700
6701 if (this._search) {
6702 this._search.removeExcludedSearchValue($label);
6703 }
6704 this._removeItem($objectID, $label);
6705
6706 $element.remove();
6707
6708 event.stopPropagation();
6709 return false;
6710 },
6711
6712 /**
6713 * Returns current object id.
6714 *
6715 * @return integer
6716 */
6717 _getObjectID: function() {
6718 return 0;
6719 },
6720
6721 /**
6722 * Returns current object type id.
6723 *
6724 * @return integer
6725 */
6726 _getObjectTypeID: function() {
6727 return 0;
6728 },
6729
6730 /**
6731 * Adds a new item to the list.
6732 *
6733 * @param object data
6734 * @return boolean
6735 */
6736 addItem: function(data) {
6737 if (this._data[data.objectID]) {
6738 if (!(data.objectID === 0 && this._allowCustomInput)) {
6739 return true;
6740 }
6741 }
6742
6743 var $listItem = $('<li class="badge">' + data.label + '</li>').data('objectID', data.objectID).data('label', data.label).appendTo(this._itemList);
6744 $listItem.click($.proxy(this._click, this));
6745
6746 if (this._search) {
6747 this._search.addExcludedSearchValue(data.label);
6748 }
6749 this._addItem(data.objectID, data.label);
6750
6751 return true;
6752 },
6753
6754 /**
6755 * Handles form submit, override in your class.
6756 */
6757 _submit: function() {
6758 this._keyDown(null);
6759 },
6760
6761 /**
6762 * Adds an item to internal storage.
6763 *
6764 * @param integer objectID
6765 * @param string label
6766 */
6767 _addItem: function(objectID, label) {
6768 this._data[objectID] = label;
6769 },
6770
6771 /**
6772 * Removes an item from internal storage.
6773 *
6774 * @param integer objectID
6775 * @param string label
6776 */
6777 _removeItem: function(objectID, label) {
6778 delete this._data[objectID];
6779 }
6780 });
6781
6782 /**
6783 * Provides a generic sitemap.
6784 */
6785 WCF.Sitemap = Class.extend({
6786 /**
6787 * sitemap name cache
6788 * @var array
6789 */
6790 _cache: [ ],
6791
6792 /**
6793 * dialog overlay
6794 * @var jQuery
6795 */
6796 _dialog: null,
6797
6798 /**
6799 * initialization state
6800 * @var boolean
6801 */
6802 _didInit: false,
6803
6804 /**
6805 * action proxy
6806 * @var WCF.Action.Proxy
6807 */
6808 _proxy: null,
6809
6810 /**
6811 * Initializes the generic sitemap.
6812 */
6813 init: function() {
6814 $('#sitemap').click($.proxy(this._click, this));
6815
6816 this._cache = [ ];
6817 this._dialog = null;
6818 this._didInit = false;
6819 this._proxy = new WCF.Action.Proxy({
6820 success: $.proxy(this._success, this)
6821 });
6822 },
6823
6824 /**
6825 * Handles clicks on the sitemap icon.
6826 */
6827 _click: function() {
6828 if (this._dialog === null) {
6829 this._dialog = $('<div id="sitemapDialog" />').appendTo(document.body);
6830
6831 this._proxy.setOption('data', {
6832 actionName: 'getSitemap',
6833 className: 'wcf\\data\\sitemap\\SitemapAction'
6834 });
6835 this._proxy.sendRequest();
6836 }
6837 else {
6838 this._dialog.wcfDialog('open');
6839 }
6840 },
6841
6842 /**
6843 * Handles successful AJAX responses.
6844 *
6845 * @param object data
6846 * @param string textStatus
6847 * @param jQuery jqXHR
6848 */
6849 _success: function(data, textStatus, jqXHR) {
6850 if (this._didInit) {
6851 this._cache.push(data.returnValues.sitemapName);
6852
6853 this._dialog.find('#sitemap_' + data.returnValues.sitemapName).html(data.returnValues.template);
6854
6855 // redraw dialog
6856 this._dialog.wcfDialog('render');
6857 }
6858 else {
6859 // mark sitemap name as loaded
6860 this._cache.push(data.returnValues.sitemapName);
6861
6862 // insert sitemap template
6863 this._dialog.html(data.returnValues.template);
6864
6865 // bind event listener
6866 this._dialog.find('.sitemapNavigation').click($.proxy(this._navigate, this));
6867
6868 // show dialog
6869 this._dialog.wcfDialog({
6870 title: WCF.Language.get('wcf.sitemap.title')
6871 });
6872
6873 this._didInit = true;
6874 }
6875 },
6876
6877 /**
6878 * Navigates between different sitemaps.
6879 *
6880 * @param object event
6881 */
6882 _navigate: function(event) {
6883 var $sitemapName = $(event.currentTarget).data('sitemapName');
6884 if (WCF.inArray($sitemapName, this._cache)) {
6885 this._dialog.find('.tabMenuContainer').wcfTabs('select', 'sitemap_' + $sitemapName);
6886
6887 // redraw dialog
6888 this._dialog.wcfDialog('render');
6889 }
6890 else {
6891 this._proxy.setOption('data', {
6892 actionName: 'getSitemap',
6893 className: 'wcf\\data\\sitemap\\SitemapAction',
6894 parameters: {
6895 sitemapName: $sitemapName
6896 }
6897 });
6898 this._proxy.sendRequest();
6899 }
6900 }
6901 });
6902
6903 /**
6904 * Provides a language chooser.
6905 *
6906 * @param string containerID
6907 * @param string inputFieldID
6908 * @param integer languageID
6909 * @param object languages
6910 * @param object callback
6911 */
6912 WCF.Language.Chooser = Class.extend({
6913 /**
6914 * callback object
6915 * @var object
6916 */
6917 _callback: null,
6918
6919 /**
6920 * dropdown object
6921 * @var jQuery
6922 */
6923 _dropdown: null,
6924
6925 /**
6926 * input field
6927 * @var jQuery
6928 */
6929 _input: null,
6930
6931 /**
6932 * Initializes the language chooser.
6933 *
6934 * @param string containerID
6935 * @param string inputFieldID
6936 * @param integer languageID
6937 * @param object languages
6938 * @param object callback
6939 * @param boolean allowEmptyValue
6940 */
6941 init: function(containerID, inputFieldID, languageID, languages, callback, allowEmptyValue) {
6942 var $container = $('#' + containerID);
6943 if ($container.length != 1) {
6944 console.debug("[WCF.Language.Chooser] Invalid container id '" + containerID + "' given");
6945 return;
6946 }
6947
6948 // bind language id input
6949 this._input = $('#' + inputFieldID);
6950 if (!this._input.length) {
6951 this._input = $('<input type="hidden" name="' + inputFieldID + '" value="' + languageID + '" />').appendTo($container);
6952 }
6953
6954 // handle callback
6955 if (callback !== undefined) {
6956 if (!$.isFunction(callback)) {
6957 console.debug("[WCF.Language.Chooser] Given callback is invalid");
6958 return;
6959 }
6960
6961 this._callback = callback;
6962 }
6963
6964 // create language dropdown
6965 this._dropdown = $('<div class="dropdown" id="' + containerID + '-languageChooser" />').appendTo($container);
6966 $('<div class="dropdownToggle boxFlag box24" data-toggle="' + containerID + '-languageChooser"></div>').appendTo(this._dropdown);
6967 var $dropdownMenu = $('<ul class="dropdownMenu" />').appendTo(this._dropdown);
6968
6969 for (var $languageID in languages) {
6970 var $language = languages[$languageID];
6971 var $item = $('<li class="boxFlag"><a class="box24"><div class="framed"><img src="' + $language.iconPath + '" alt="" class="iconFlag" /></div> <hgroup><h1>' + $language.languageName + '</h1></hgroup></a></li>').appendTo($dropdownMenu);
6972 $item.data('languageID', $languageID).click($.proxy(this._click, this));
6973
6974 // update dropdown label
6975 if ($languageID == languageID) {
6976 var $html = $('' + $item.html());
6977 var $innerContent = $html.children().detach();
6978 this._dropdown.children('.dropdownToggle').empty().append($innerContent);
6979 }
6980 }
6981
6982 // allow an empty selection (e.g. using as language filter)
6983 if (allowEmptyValue) {
6984 $('<li class="dropdownDivider" />').appendTo($dropdownMenu);
6985 var $item = $('<li><a>' + WCF.Language.get('wcf.global.language.noSelection') + '</a></li>').data('languageID', 0).click($.proxy(this._click, this)).appendTo($dropdownMenu);
6986
6987 if (languageID === 0) {
6988 this._dropdown.children('.dropdownToggle').empty().append($item.html());
6989 }
6990 }
6991
6992 WCF.Dropdown.init();
6993 },
6994
6995 /**
6996 * Handles click events.
6997 *
6998 * @param object event
6999 */
7000 _click: function(event) {
7001 var $item = $(event.currentTarget);
7002 var $languageID = $item.data('languageID');
7003
7004 // update input field
7005 this._input.val($languageID);
7006
7007 // update dropdown label
7008 var $html = $('' + $item.html());
7009 var $innerContent = ($languageID === 0) ? $html : $html.children().detach();
7010 this._dropdown.children('.dropdownToggle').empty().append($innerContent);
7011
7012 // execute callback
7013 if (this._callback !== null) {
7014 this._callback($item);
7015 }
7016 }
7017 });
7018
7019 /**
7020 * Namespace for style related classes.
7021 */
7022 WCF.Style = { };
7023
7024 /**
7025 * Provides a visual style chooser.
7026 */
7027 WCF.Style.Chooser = Class.extend({
7028 /**
7029 * dialog overlay
7030 * @var jQuery
7031 */
7032 _dialog: null,
7033
7034 /**
7035 * action proxy
7036 * @var WCF.Action.Proxy
7037 */
7038 _proxy: null,
7039
7040 /**
7041 * Initializes the style chooser class.
7042 */
7043 init: function() {
7044 $('<li class="styleChooser"><a>' + WCF.Language.get('wcf.style.changeStyle') + '</a></li>').appendTo($('#footerNavigation > ul.navigationItems')).click($.proxy(this._showDialog, this));
7045
7046 this._proxy = new WCF.Action.Proxy({
7047 success: $.proxy(this._success, this)
7048 });
7049 },
7050
7051 /**
7052 * Displays the style chooser dialog.
7053 */
7054 _showDialog: function() {
7055 if (this._dialog === null) {
7056 this._dialog = $('<div id="styleChooser" />').hide().appendTo(document.body);
7057 this._loadDialog();
7058 }
7059 else {
7060 this._dialog.wcfDialog({
7061 title: WCF.Language.get('wcf.style.changeStyle')
7062 });
7063 }
7064 },
7065
7066 /**
7067 * Loads the style chooser dialog.
7068 */
7069 _loadDialog: function() {
7070 this._proxy.setOption('data', {
7071 actionName: 'getStyleChooser',
7072 className: 'wcf\\data\\style\\StyleAction'
7073 });
7074 this._proxy.sendRequest();
7075 },
7076
7077 /**
7078 * Handles successful AJAX requests.
7079 *
7080 * @param object data
7081 * @param string textStatus
7082 * @param jQuery jqXHR
7083 */
7084 _success: function(data, textStatus, jqXHR) {
7085 if (data.returnValues.actionName === 'changeStyle') {
7086 window.location.reload();
7087 return;
7088 }
7089
7090 this._dialog.html(data.returnValues.template);
7091 this._dialog.find('li').addClass('pointer').click($.proxy(this._click, this));
7092
7093 this._showDialog();
7094 },
7095
7096 /**
7097 * Changes user style.
7098 *
7099 * @param object event
7100 */
7101 _click: function(event) {
7102 this._proxy.setOption('data', {
7103 actionName: 'changeStyle',
7104 className: 'wcf\\data\\style\\StyleAction',
7105 objectIDs: [ $(event.currentTarget).data('styleID') ]
7106 });
7107 this._proxy.sendRequest();
7108 }
7109 });
7110
7111 /**
7112 * Converts static user panel items into interactive dropdowns.
7113 *
7114 * @param string containerID
7115 */
7116 WCF.UserPanel = Class.extend({
7117 /**
7118 * target container
7119 * @var jQuery
7120 */
7121 _container: null,
7122
7123 /**
7124 * initialization state
7125 * @var boolean
7126 */
7127 _didLoad: false,
7128
7129 /**
7130 * original link element
7131 * @var jQuery
7132 */
7133 _link: null,
7134
7135 /**
7136 * reverts to original link if return values are empty
7137 * @var boolean
7138 */
7139 _revertOnEmpty: true,
7140
7141 /**
7142 * Initialites the WCF.UserPanel class.
7143 *
7144 * @param string containerID
7145 */
7146 init: function(containerID) {
7147 this._container = $('#' + containerID);
7148 this._didLoad = false;
7149 this._revertOnEmpty = true;
7150
7151 if (this._container.length != 1) {
7152 console.debug("[WCF.UserPanel] Unable to find container identfied by '" + containerID + "', aborting.");
7153 return;
7154 }
7155
7156 if (this._container.data('count')) {
7157 WCF.DOMNodeInsertedHandler.enable();
7158 this._convert();
7159 WCF.DOMNodeInsertedHandler.disable();
7160 }
7161 },
7162
7163 /**
7164 * Converts link into an interactive dropdown menu.
7165 */
7166 _convert: function() {
7167 this._container.addClass('dropdown');
7168 this._link = this._container.children('a').remove();
7169
7170 $('<a class="dropdownToggle jsTooltip" title="' + this._container.data('title') + '">' + this._link.html() + '</a>').appendTo(this._container).click($.proxy(this._click, this));
7171 var $dropdownMenu = $('<ul class="dropdownMenu" />').appendTo(this._container);
7172 $('<li class="jsDropdownPlaceholder"><span>' + WCF.Language.get('wcf.global.loading') + '</span></li>').appendTo($dropdownMenu);
7173
7174 this._addDefaultItems($dropdownMenu);
7175 },
7176
7177 /**
7178 * Adds default items to dropdown menu.
7179 *
7180 * @param jQuery dropdownMenu
7181 */
7182 _addDefaultItems: function(dropdownMenu) { },
7183
7184 /**
7185 * Adds a dropdown divider.
7186 *
7187 * @param jQuery dropdownMenu
7188 */
7189 _addDivider: function(dropdownMenu) {
7190 $('<li class="dropdownDivider" />').appendTo(dropdownMenu);
7191 },
7192
7193 /**
7194 * Handles clicks on the dropdown item.
7195 */
7196 _click: function() {
7197 if (this._didLoad) {
7198 return;
7199 }
7200
7201 new WCF.Action.Proxy({
7202 autoSend: true,
7203 data: this._getParameters(),
7204 success: $.proxy(this._success, this)
7205 });
7206
7207 this._didLoad = true;
7208 },
7209
7210 /**
7211 * Returns a list of parameters for AJAX request.
7212 *
7213 * @return object
7214 */
7215 _getParameters: function() {
7216 return { };
7217 },
7218
7219 /**
7220 * Handles successful AJAX requests.
7221 *
7222 * @param object data
7223 * @param string textStatus
7224 * @param jQuery jqXHR
7225 */
7226 _success: function(data, textStatus, jqXHR) {
7227 if (data.returnValues && data.returnValues.template) {
7228 var $dropdownMenu = this._container.children('.dropdownMenu');
7229 $dropdownMenu.children('.jsDropdownPlaceholder').remove();
7230 $('' + data.returnValues.template).prependTo($dropdownMenu);
7231 }
7232 else {
7233 this._container.removeClass('dropdown').empty();
7234 this._link.appendTo(this._container);
7235
7236 // remove badge
7237 this._container.find('.badge').remove();
7238 }
7239 }
7240 });
7241
7242 /**
7243 * WCF implementation for nested sortables.
7244 */
7245 $.widget("ui.wcfNestedSortable", $.extend({}, $.mjs.nestedSortable.prototype, {
7246 _clearEmpty: function(item) {
7247 /* Does nothing because we want to keep empty lists */
7248 }
7249 }));
7250
7251 /**
7252 * WCF implementation for dialogs, based upon ideas by jQuery UI.
7253 */
7254 $.widget('ui.wcfDialog', {
7255 /**
7256 * close button
7257 * @var jQuery
7258 */
7259 _closeButton: null,
7260
7261 /**
7262 * dialog container
7263 * @var jQuery
7264 */
7265 _container: null,
7266
7267 /**
7268 * dialog content
7269 * @var jQuery
7270 */
7271 _content: null,
7272
7273 /**
7274 * dialog content dimensions
7275 * @var object
7276 */
7277 _contentDimensions: null,
7278
7279 /**
7280 * rendering state
7281 * @var boolean
7282 */
7283 _isRendering: false,
7284
7285 /**
7286 * modal overlay
7287 * @var jQuery
7288 */
7289 _overlay: null,
7290
7291 /**
7292 * plain html for title
7293 * @var string
7294 */
7295 _title: null,
7296
7297 /**
7298 * title bar
7299 * @var jQuery
7300 */
7301 _titlebar: null,
7302
7303 /**
7304 * dialog visibility state
7305 * @var boolean
7306 */
7307 _isOpen: false,
7308
7309 /**
7310 * option list
7311 * @var object
7312 */
7313 options: {
7314 // dialog
7315 autoOpen: true,
7316 closable: true,
7317 closeButtonLabel: null,
7318 hideTitle: false,
7319 modal: true,
7320 title: '',
7321 zIndex: 400,
7322
7323 // AJAX support
7324 ajax: false,
7325 data: { },
7326 showLoadingOverlay: true,
7327 success: null,
7328 type: 'POST',
7329 url: 'index.php/AJAXProxy/?t=' + SECURITY_TOKEN + SID_ARG_2ND,
7330
7331 // event callbacks
7332 onClose: null,
7333 onShow: null
7334 },
7335
7336 /**
7337 * Initializes a new dialog.
7338 */
7339 _init: function() {
7340 if (this.options.ajax) {
7341 new WCF.Action.Proxy({
7342 autoSend: true,
7343 data: this.options.data,
7344 showLoadingOverlay: this.options.showLoadingOverlay,
7345 success: $.proxy(this._success, this),
7346 type: this.options.type,
7347 url: this.options.url
7348 });
7349
7350 // force open if using AJAX
7351 this.options.autoOpen = true;
7352
7353 // apply loading overlay
7354 this._content.addClass('overlayLoading');
7355 }
7356
7357 if (this.options.autoOpen) {
7358 this.open();
7359 }
7360
7361 // act on resize
7362 $(window).resize($.proxy(this._resize, this));
7363 },
7364
7365 /**
7366 * Creates a new dialog instance.
7367 */
7368 _create: function() {
7369 if (this.options.closeButtonLabel === null) {
7370 this.options.closeButtonLabel = WCF.Language.get('wcf.global.button.close');
7371 }
7372
7373 WCF.DOMNodeInsertedHandler.enable();
7374
7375 // create dialog container
7376 this._container = $('<div class="dialogContainer" />').hide().css({ zIndex: this.options.zIndex }).appendTo(document.body);
7377 this._titlebar = $('<header class="dialogTitlebar" />').hide().appendTo(this._container);
7378 this._title = $('<span class="dialogTitle" />').hide().appendTo(this._titlebar);
7379 this._closeButton = $('<a class="dialogCloseButton jsTooltip" title="' + this.options.closeButtonLabel + '"><span /></a>').click($.proxy(this.close, this)).hide().appendTo(this._titlebar);
7380 this._content = $('<div class="dialogContent" />').appendTo(this._container);
7381
7382 this._setOption('title', this.options.title);
7383 this._setOption('closable', this.options.closable);
7384
7385 // move target element into content
7386 var $content = this.element.detach();
7387 this._content.html($content);
7388
7389 // create modal view
7390 if (this.options.modal) {
7391 this._overlay = $('#jsWcfDialogOverlay');
7392 if (!this._overlay.length) {
7393 this._overlay = $('<div id="jsWcfDialogOverlay" class="dialogOverlay" />').css({ height: '100%', zIndex: 399 }).appendTo(document.body);
7394 }
7395
7396 if (this.options.closable) {
7397 this._overlay.click($.proxy(this.close, this));
7398
7399 $(document).keyup($.proxy(function(event) {
7400 if (event.keyCode && event.keyCode === $.ui.keyCode.ESCAPE) {
7401 this.close();
7402 event.preventDefault();
7403 }
7404 }, this));
7405 }
7406 }
7407
7408 WCF.DOMNodeInsertedHandler.disable();
7409 },
7410
7411 /**
7412 * Sets the given option to the given value.
7413 * See the jQuery UI widget documentation for more.
7414 */
7415 _setOption: function(key, value) {
7416 this.options[key] = value;
7417
7418 if (key == 'hideTitle' || key == 'title') {
7419 if (!this.options.hideTitle && this.options.title != '') {
7420 this._title.html(this.options.title).show();
7421 } else {
7422 this._title.html('');
7423 }
7424 } else if (key == 'closable' || key == 'closeButtonLabel') {
7425 if (this.options.closable) {
7426 WCF.DOMNodeInsertedHandler.enable();
7427
7428 this._closeButton.attr('title', this.options.closeButtonLabel).show().find('span').html(this.options.closeButtonLabel);
7429
7430 WCF.DOMNodeInsertedHandler.disable();
7431 } else {
7432 this._closeButton.hide();
7433 }
7434 }
7435
7436 if ((!this.options.hideTitle && this.options.title != '') || this.options.closable) {
7437 this._titlebar.show();
7438 } else {
7439 this._titlebar.hide();
7440 }
7441
7442 return this;
7443 },
7444
7445 /**
7446 * Handles successful AJAX requests.
7447 *
7448 * @param object data
7449 * @param string textStatus
7450 * @param jQuery jqXHR
7451 */
7452 _success: function(data, textStatus, jqXHR) {
7453 if (this._isOpen) {
7454 // initialize dialog content
7455 this._initDialog(data);
7456
7457 // remove loading overlay
7458 this._content.removeClass('overlayLoading');
7459
7460 if (this.options.success !== null && $.isFunction(this.options.success)) {
7461 this.options.success(data, textStatus, jqXHR);
7462 }
7463 }
7464 },
7465
7466 /**
7467 * Initializes dialog content if applicable.
7468 *
7469 * @param object data
7470 */
7471 _initDialog: function(data) {
7472 // insert template
7473 if (this._getResponseValue(data, 'template')) {
7474 this._content.children().html(this._getResponseValue(data, 'template'));
7475 this.render();
7476 }
7477
7478 // set title
7479 if (this._getResponseValue(data, 'title')) {
7480 this._setOption('title', this._getResponseValue(data, 'title'));
7481 }
7482 },
7483
7484 /**
7485 * Returns a response value, taking care of different object
7486 * structure returned by AJAXProxy.
7487 *
7488 * @param object data
7489 * @param string key
7490 */
7491 _getResponseValue: function(data, key) {
7492 if (data.returnValues && data.returnValues[key]) {
7493 return data.returnValues[key];
7494 }
7495 else if (data[key]) {
7496 return data[key];
7497 }
7498
7499 return null;
7500 },
7501
7502 /**
7503 * Opens this dialog.
7504 */
7505 open: function() {
7506 if (this.isOpen()) {
7507 return;
7508 }
7509
7510 if (this._overlay !== null) {
7511 WCF.activeDialogs++;
7512
7513 if (WCF.activeDialogs === 1) {
7514 this._overlay.show();
7515 }
7516 }
7517
7518 this.render();
7519 this._isOpen = true;
7520 },
7521
7522 /**
7523 * Returns true, if dialog is visible.
7524 *
7525 * @return boolean
7526 */
7527 isOpen: function() {
7528 return this._isOpen;
7529 },
7530
7531 /**
7532 * Closes this dialog.
7533 */
7534 close: function() {
7535 if (!this.isOpen() || !this.options.closable) {
7536 return;
7537 }
7538
7539 this._isOpen = false;
7540 this._container.wcfFadeOut();
7541
7542 if (this._overlay !== null) {
7543 WCF.activeDialogs--;
7544
7545 if (WCF.activeDialogs === 0) {
7546 this._overlay.hide();
7547 }
7548 }
7549
7550 if (this.options.onClose !== null) {
7551 this.options.onClose();
7552 }
7553 },
7554
7555 /**
7556 * Renders dialog on resize if visible.
7557 */
7558 _resize: function() {
7559 if (this.isOpen()) {
7560 this.render();
7561 }
7562 },
7563
7564 /**
7565 * Renders this dialog, should be called whenever content is updated.
7566 */
7567 render: function() {
7568 if (!this.isOpen()) {
7569 // temporarily display container
7570 this._container.show();
7571 }
7572 else {
7573 // remove fixed content dimensions for calculation
7574 this._content.css({
7575 height: 'auto',
7576 width: 'auto'
7577 });
7578 }
7579
7580 // force content to be visible
7581 this._content.children().each(function() {
7582 $(this).show();
7583 });
7584
7585 // handle multiple rendering requests
7586 if (this._isRendering) {
7587 // stop current process
7588 this._container.stop();
7589 this._content.stop();
7590
7591 // set dialog to be fully opaque, should prevent weird bugs in WebKit
7592 this._container.show().css('opacity', 1.0);
7593 }
7594
7595 if (this._content.find('.formSubmit').length) {
7596 this._content.addClass('dialogForm');
7597 }
7598 else {
7599 this._content.removeClass('dialogForm');
7600 }
7601
7602 // calculate dimensions
7603 var $windowDimensions = $(window).getDimensions();
7604 var $containerDimensions = this._container.getDimensions('outer');
7605 var $contentDimensions = this._content.getDimensions();
7606
7607 // calculate maximum content height
7608 var $heightDifference = $containerDimensions.height - $contentDimensions.height;
7609 var $maximumHeight = $windowDimensions.height - $heightDifference - 120;
7610 this._content.css({ maxHeight: $maximumHeight + 'px' });
7611
7612 // re-caculate values if container height was previously limited
7613 if ($maximumHeight < $contentDimensions.height) {
7614 $containerDimensions = this._container.getDimensions('outer');
7615 }
7616
7617 // handle multiple rendering requests
7618 if (this._isRendering) {
7619 // use current dimensions as previous ones
7620 this._contentDimensions = this._getContentDimensions($maximumHeight);
7621 }
7622
7623 // calculate new dimensions
7624 $contentDimensions = this._getContentDimensions($maximumHeight);
7625
7626 // move container
7627 var $leftOffset = Math.round(($windowDimensions.width - $containerDimensions.width) / 2);
7628 var $topOffset = Math.round(($windowDimensions.height - $containerDimensions.height) / 2);
7629
7630 // place container at 20% height if possible
7631 var $desiredTopOffset = Math.round(($windowDimensions.height / 100) * 20);
7632 if ($desiredTopOffset < $topOffset) {
7633 $topOffset = $desiredTopOffset;
7634 }
7635
7636 if (!this.isOpen()) {
7637 // hide container again
7638 this._container.hide();
7639
7640 // apply offset
7641 this._container.css({
7642 left: $leftOffset + 'px',
7643 top: $topOffset + 'px'
7644 });
7645
7646 // save current dimensions
7647 this._contentDimensions = $contentDimensions;
7648
7649 // force dimensions
7650 this._content.css({
7651 height: this._contentDimensions.height + 'px',
7652 width: this._contentDimensions.width + 'px'
7653 });
7654
7655 // fade in container
7656 this._container.wcfFadeIn($.proxy(function() {
7657 this._isRendering = false;
7658 }));
7659 }
7660 else {
7661 // save reference (used in callback)
7662 var $content = this._content;
7663
7664 // force previous dimensions
7665 $content.css({
7666 height: this._contentDimensions.height + 'px',
7667 width: this._contentDimensions.width + 'px'
7668 });
7669
7670 // apply new dimensions
7671 $content.animate({
7672 height: ($contentDimensions.height) + 'px',
7673 width: ($contentDimensions.width) + 'px'
7674 }, 300, function() {
7675 // remove static dimensions
7676 $content.css({
7677 height: 'auto',
7678 width: 'auto'
7679 });
7680 });
7681
7682 // store new dimensions
7683 this._contentDimensions = $contentDimensions;
7684
7685 // move container
7686 this._isRendering = true;
7687 this._container.animate({
7688 left: $leftOffset + 'px',
7689 top: $topOffset + 'px'
7690 }, 300, $.proxy(function() {
7691 this._isRendering = false;
7692 }, this));
7693 }
7694
7695 if (this.options.onShow !== null) {
7696 this.options.onShow();
7697 }
7698 },
7699
7700 /**
7701 * Returns calculated content dimensions.
7702 *
7703 * @param integer maximumHeight
7704 * @return object
7705 */
7706 _getContentDimensions: function(maximumHeight) {
7707 var $contentDimensions = this._content.getDimensions();
7708
7709 // set height to maximum height if exceeded
7710 if ($contentDimensions.height > maximumHeight) {
7711 $contentDimensions.height = maximumHeight;
7712 }
7713
7714 return $contentDimensions;
7715 }
7716 });
7717
7718 /**
7719 * Custom tab menu implementation for WCF.
7720 */
7721 $.widget('ui.wcfTabs', $.ui.tabs, {
7722 /**
7723 * Workaround for ids containing a dot ".", until jQuery UI devs learn
7724 * to properly escape ids ... (it took 18 months until they finally
7725 * fixed it!)
7726 *
7727 * @see http://bugs.jqueryui.com/ticket/4681
7728 * @see $.ui.tabs.prototype._sanitizeSelector()
7729 */
7730 _sanitizeSelector: function(hash) {
7731 return hash.replace(/([:\.])/g, '\\$1');
7732 },
7733
7734 /**
7735 * @see $.ui.tabs.prototype.select()
7736 */
7737 select: function(index) {
7738 if (!$.isNumeric(index)) {
7739 // panel identifier given
7740 this.panels.each(function(i, panel) {
7741 if ($(panel).wcfIdentify() === index) {
7742 index = i;
7743 return false;
7744 }
7745 });
7746
7747 // unable to identify panel
7748 if (!$.isNumeric(index)) {
7749 console.debug("[ui.wcfTabs] Unable to find panel identified by '" + index + "', aborting.");
7750 return;
7751 }
7752 }
7753
7754 $.ui.tabs.prototype.select.apply(this, arguments);
7755 },
7756
7757 /**
7758 * Returns the currently selected tab index.
7759 *
7760 * @return integer
7761 */
7762 getCurrentIndex: function() {
7763 return this.lis.index(this.lis.filter('.ui-tabs-selected'));
7764 },
7765
7766 /**
7767 * Returns true, if identifier is used by an anchor.
7768 *
7769 * @param string identifier
7770 * @param boolean isChildren
7771 * @return boolean
7772 */
7773 hasAnchor: function(identifier, isChildren) {
7774 var $matches = false;
7775
7776 this.anchors.each(function(index, anchor) {
7777 var $href = $(anchor).attr('href');
7778 if (/#.+/.test($href)) {
7779 // split by anchor
7780 var $parts = $href.split('#', 2);
7781 if (isChildren) {
7782 $parts = $parts[1].split('-', 2);
7783 }
7784
7785 if ($parts[1] === identifier) {
7786 $matches = true;
7787
7788 // terminate loop
7789 return false;
7790 }
7791 }
7792 });
7793
7794 return $matches;
7795 },
7796
7797 /**
7798 * Shows default tab.
7799 */
7800 revertToDefault: function() {
7801 var $active = this.element.data('active');
7802 if (!$active || $active === '') $active = 0;
7803
7804 this.select($active);
7805 }
7806 });
7807
7808 /**
7809 * jQuery widget implementation of the wcf pagination.
7810 */
7811 $.widget('ui.wcfPages', {
7812 SHOW_LINKS: 11,
7813 SHOW_SUB_LINKS: 20,
7814
7815 options: {
7816 // vars
7817 activePage: 1,
7818 maxPage: 1,
7819
7820 // icons
7821 previousIcon: null,
7822 arrowDownIcon: null,
7823 nextIcon: null,
7824
7825 // language
7826 // we use options here instead of language variables, because the paginator is not only usable with pages
7827 nextPage: null,
7828 previousPage: null
7829 },
7830
7831 /**
7832 * Creates the pages widget.
7833 */
7834 _create: function() {
7835 if (this.options.nextPage === null) this.options.nextPage = WCF.Language.get('wcf.global.page.next');
7836 if (this.options.previousPage === null) this.options.previousPage = WCF.Language.get('wcf.global.page.previous');
7837 if (this.options.previousIcon === null) this.options.previousIcon = WCF.Icon.get('wcf.icon.circleArrowLeft');
7838 if (this.options.nextIcon === null) this.options.nextIcon = WCF.Icon.get('wcf.icon.circleArrowRight');
7839 if (this.options.arrowDownIcon === null) this.options.arrowDownIcon = WCF.Icon.get('wcf.icon.arrowDown');
7840
7841 this.element.addClass('pageNavigation');
7842
7843 this._render();
7844 },
7845
7846 /**
7847 * Destroys the pages widget.
7848 */
7849 destroy: function() {
7850 $.Widget.prototype.destroy.apply(this, arguments);
7851
7852 this.element.children().remove();
7853 },
7854
7855 /**
7856 * Renders the pages widget.
7857 */
7858 _render: function() {
7859 // only render if we have more than 1 page
7860 if (!this.options.disabled && this.options.maxPage > 1) {
7861 var $hasHiddenPages = false;
7862
7863 // make sure pagination is visible
7864 if (this.element.hasClass('hidden')) {
7865 this.element.removeClass('hidden');
7866 }
7867 this.element.show();
7868
7869 this.element.children().remove();
7870
7871 var $pageList = $('<ul></ul>');
7872 this.element.append($pageList);
7873
7874 var $previousElement = $('<li></li>').addClass('button skip');
7875 $pageList.append($previousElement);
7876
7877 if (this.options.activePage > 1) {
7878 var $previousLink = $('<a' + ((this.options.previousPage != null) ? (' title="' + this.options.previousPage + '"') : ('')) + '></a>');
7879 $previousElement.append($previousLink);
7880 this._bindSwitchPage($previousLink, this.options.activePage - 1);
7881
7882 var $previousImage = $('<img src="' + this.options.previousIcon + '" alt="" />');
7883 $previousLink.append($previousImage);
7884 }
7885 else {
7886 var $previousImage = $('<img src="' + this.options.previousIcon + '" alt="" />');
7887 $previousElement.append($previousImage);
7888 $previousElement.addClass('disabled');
7889 $previousImage.addClass('disabled');
7890 }
7891 $previousImage.addClass('icon16');
7892
7893 // add first page
7894 $pageList.append(this._renderLink(1));
7895
7896 // calculate page links
7897 var $maxLinks = this.SHOW_LINKS - 4;
7898 var $linksBefore = this.options.activePage - 2;
7899 if ($linksBefore < 0) $linksBefore = 0;
7900 var $linksAfter = this.options.maxPage - (this.options.activePage + 1);
7901 if ($linksAfter < 0) $linksAfter = 0;
7902 if (this.options.activePage > 1 && this.options.activePage < this.options.maxPage) $maxLinks--;
7903
7904 var $half = $maxLinks / 2;
7905 var $left = this.options.activePage;
7906 var $right = this.options.activePage;
7907 if ($left < 1) $left = 1;
7908 if ($right < 1) $right = 1;
7909 if ($right > this.options.maxPage - 1) $right = this.options.maxPage - 1;
7910
7911 if ($linksBefore >= $half) {
7912 $left -= $half;
7913 }
7914 else {
7915 $left -= $linksBefore;
7916 $right += $half - $linksBefore;
7917 }
7918
7919 if ($linksAfter >= $half) {
7920 $right += $half;
7921 }
7922 else {
7923 $right += $linksAfter;
7924 $left -= $half - $linksAfter;
7925 }
7926
7927 $right = Math.ceil($right);
7928 $left = Math.ceil($left);
7929 if ($left < 1) $left = 1;
7930 if ($right > this.options.maxPage) $right = this.options.maxPage;
7931
7932 // left ... links
7933 if ($left > 1) {
7934 if ($left - 1 < 2) {
7935 $pageList.append(this._renderLink(2));
7936 }
7937 else {
7938 $('<li class="button jumpTo"><a title="' + WCF.Language.get('wcf.global.page.jumpTo') + '" class="jsTooltip">…</a></li>').appendTo($pageList);
7939 $hasHiddenPages = true;
7940 }
7941 }
7942
7943 // visible links
7944 for (var $i = $left + 1; $i < $right; $i++) {
7945 $pageList.append(this._renderLink($i));
7946 }
7947
7948 // right ... links
7949 if ($right < this.options.maxPage) {
7950 if (this.options.maxPage - $right < 2) {
7951 $pageList.append(this._renderLink(this.options.maxPage - 1));
7952 }
7953 else {
7954 $('<li class="button jumpTo"><a title="' + WCF.Language.get('wcf.global.page.jumpTo') + '" class="jsTooltip">…</a></li>').appendTo($pageList);
7955 $hasHiddenPages = true;
7956 }
7957 }
7958
7959 // add last page
7960 $pageList.append(this._renderLink(this.options.maxPage));
7961
7962 // add next button
7963 var $nextElement = $('<li></li>').addClass('button skip');
7964 $pageList.append($nextElement);
7965
7966 if (this.options.activePage < this.options.maxPage) {
7967 var $nextLink = $('<a' + ((this.options.nextPage != null) ? (' title="' + this.options.nextPage + '"') : ('')) + '></a>');
7968 $nextElement.append($nextLink);
7969 this._bindSwitchPage($nextLink, this.options.activePage + 1);
7970
7971 var $nextImage = $('<img src="' + this.options.nextIcon + '" alt="" />');
7972 $nextLink.append($nextImage);
7973 }
7974 else {
7975 var $nextImage = $('<img src="' + this.options.nextIcon + '" alt="" />');
7976 $nextElement.append($nextImage);
7977 $nextElement.addClass('disabled');
7978 $nextImage.addClass('disabled');
7979 }
7980 $nextImage.addClass('icon16');
7981
7982 if ($hasHiddenPages) {
7983 $pageList.data('pages', this.options.maxPage);
7984 WCF.System.PageNavigation.init('#' + $pageList.wcfIdentify(), $.proxy(function(pageNo) {
7985 this.switchPage(pageNo);
7986 }, this));
7987 }
7988 }
7989 else {
7990 // otherwise hide the paginator if not already hidden
7991 this.element.hide();
7992 }
7993 },
7994
7995 /**
7996 * Renders a page link.
7997 *
7998 * @parameter integer page
7999 * @return jQuery
8000 */
8001 _renderLink: function(page, lineBreak) {
8002 var $pageElement = $('<li class="button"></li>');
8003 if (lineBreak != undefined && lineBreak) {
8004 $pageElement.addClass('break');
8005 }
8006 if (page != this.options.activePage) {
8007 var $pageLink = $('<a>' + WCF.String.addThousandsSeparator(page) + '</a>');
8008 $pageElement.append($pageLink);
8009 this._bindSwitchPage($pageLink, page);
8010 }
8011 else {
8012 $pageElement.addClass('active');
8013 var $pageSubElement = $('<span>' + WCF.String.addThousandsSeparator(page) + '</span>');
8014 $pageElement.append($pageSubElement);
8015 }
8016
8017 return $pageElement;
8018 },
8019
8020 /**
8021 * Binds the 'click'-event for the page switching to the given element.
8022 *
8023 * @parameter $(element) element
8024 * @paremeter integer page
8025 */
8026 _bindSwitchPage: function(element, page) {
8027 var $self = this;
8028 element.click(function() {
8029 $self.switchPage(page);
8030 });
8031 },
8032
8033 /**
8034 * Switches to the given page
8035 *
8036 * @parameter Event event
8037 * @parameter integer page
8038 */
8039 switchPage: function(page) {
8040 this._setOption('activePage', page);
8041 },
8042
8043 /**
8044 * Sets the given option to the given value.
8045 * See the jQuery UI widget documentation for more.
8046 */
8047 _setOption: function(key, value) {
8048 if (key == 'activePage') {
8049 if (value != this.options[key] && value > 0 && value <= this.options.maxPage) {
8050 // you can prevent the page switching by returning false or by event.preventDefault()
8051 // in a shouldSwitch-callback. e.g. if an AJAX request is already running.
8052 var $result = this._trigger('shouldSwitch', undefined, {
8053 nextPage: value
8054 });
8055
8056 if ($result || $result !== undefined) {
8057 this.options[key] = value;
8058 this._render();
8059 this._trigger('switched', undefined, {
8060 activePage: value
8061 });
8062 }
8063 else {
8064 this._trigger('notSwitched', undefined, {
8065 activePage: value
8066 });
8067 }
8068 }
8069 }
8070 else {
8071 this.options[key] = value;
8072
8073 if (key == 'disabled') {
8074 if (value) {
8075 this.element.children().remove();
8076 }
8077 else {
8078 this._render();
8079 }
8080 }
8081 else if (key == 'maxPage') {
8082 this._render();
8083 }
8084 }
8085
8086 return this;
8087 },
8088
8089 /**
8090 * Start input of pagenumber
8091 *
8092 * @parameter Event event
8093 */
8094 _startInput: function(event) {
8095 // hide a-tag
8096 var $childLink = $(event.currentTarget);
8097 if (!$childLink.is('a')) $childLink = $childLink.parent('a');
8098
8099 $childLink.hide();
8100
8101 // show input-tag
8102 var $childInput = $childLink.parent('li').children('input')
8103 .css('display', 'block')
8104 .val('');
8105
8106 $childInput.focus();
8107 },
8108
8109 /**
8110 * Stops input of pagenumber
8111 *
8112 * @parameter Event event
8113 */
8114 _stopInput: function(event) {
8115 // hide input-tag
8116 var $childInput = $(event.currentTarget);
8117 $childInput.css('display', 'none');
8118
8119 // show a-tag
8120 var $childContainer = $childInput.parent('li');
8121 if ($childContainer != undefined && $childContainer != null) {
8122 $childContainer.children('a').show();
8123 }
8124 },
8125
8126 /**
8127 * Handles input of pagenumber
8128 *
8129 * @parameter Event event
8130 */
8131 _handleInput: function(event) {
8132 var $ie7 = ($.browser.msie && $.browser.version == '7.0');
8133 if (event.type != 'keyup' || $ie7) {
8134 if (!$ie7 || ((event.which == 13 || event.which == 27) && event.type == 'keyup')) {
8135 if (event.which == 13) {
8136 this.switchPage(parseInt($(event.currentTarget).val()));
8137 }
8138
8139 if (event.which == 13 || event.which == 27) {
8140 this._stopInput(event);
8141 event.stopPropagation();
8142 }
8143 }
8144 }
8145 }
8146 });
8147
8148 /**
8149 * Encapsulate eval() within an own function to prevent problems
8150 * with optimizing and minifiny JS.
8151 *
8152 * @param mixed expression
8153 * @returns mixed
8154 */
8155 function wcfEval(expression) {
8156 return eval(expression);
8157 }