1 /*!
  2 * This file is part of Aloha Editor Project http://aloha-editor.org
  3 * Copyright (c) 2010-2011 Gentics Software GmbH, aloha@gentics.com
  4 * Contributors http://aloha-editor.org/contribution.php
  5 * Licensed unter the terms of http://www.aloha-editor.org/license.html
  6 *//*
  7 * Aloha Editor is free software: you can redistribute it and/or modify
  8 * it under the terms of the GNU Affero General Public License as published by
  9 * the Free Software Foundation, either version 3 of the License, or
 10 * (at your option) any later version.*
 11 *
 12 * Aloha Editor is distributed in the hope that it will be useful,
 13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 15  16 * GNU Affero General Public License for more details.
 17 *
 18 * You should have received a copy of the GNU Affero General Public License
 19 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 20 */
 21 
 22 /**
 23  * @todo: - Make the sidebars resizable using drag handles.
 24  *        - Make overlayPage setting settable from external config.
 25  */
 26 
 27 define([
 28     'aloha/core',
 29     'aloha/jquery',
 30     'aloha/selection',
 31     'PubSub'
 32 ], function (
 33 	Aloha,
 34 	$,
 35 	Selection,
 36 	PubSub
 37 ) {
 38 	'use strict';
 39 
 40 	var uid  = +(new Date());
 41 
 42 	// Extend jQuery easing animations.
 43 	//debugger;
 44 	if (!$.easing.easeOutExpo) {
 45 		$.extend($.easing, {
 46 			easeOutExpo: function (x, t, b, c, d) {
 47 				return (t==d)?b+c:c*(-Math.pow(2,-10*t/d)+1)+b;
 48 			},
 49 			easeOutElastic: function (x, t, b, c, d) {
 50 				var m=Math,s=1.70158,p=0,a=c;
 51 				if(!t)return b;
 52 				if((t/=d)==1)return b+c;
 53 				if(!p)p=d*.3;
 54 				if(a<m.abs(c)){a=c;var s=p/4;}else var s=p/(2*m.PI)*m.asin(c/a);
 55 				return a*m.pow(2,-10*t)*m.sin((t*d-s)*(2*m.PI)/p)+c+b;
 56 			}
 57 		});
 58 	}
 59 
 60 	var Panel = function Panel(opts) {
 61 		this.id = null;
 62 		this.folds = {};
 63 		this.button	= null;
 64 		this.title = $('<div class="aloha-sidebar-panel-title">' +
 65 				'<span class="aloha-sidebar-panel-title-arrow"></span>' +
 66 				'<span class="aloha-sidebar-panel-title-text">Untitled</span>' +
 67 			'</div>');
 68 		this.content = $('<div class="aloha-sidebar-panel-content">' +
 69 				'<div class="aloha-sidebar-panel-content-inner">' +
 70 					'<div class="aloha-sidebar-panel-content-inner-text"></div>' +
 71 				'</div>' +
 72 			'</div>');
 73 		this.element  = null;
 74 		this.effectiveElement = null;
 75 		this.expanded = false;
 76 		this.isActive = true;
 77 		this.init(opts);
 78 	};
 79 
 80 	var Sidebar = function Sidebar(opts) {
 81 		var sidebar = this;
 82 		this.id = 'aloha-sidebar-' + (++uid);
 83 		this.panels = {};
 84 		this.container = $('<div class="aloha-sidebar-bar">' +
 85 				'<div class="aloha-sidebar-handle">' +
 86 					'<span class="aloha-sidebar-handle-icon"></span>' +
 87 				'</div>' +
 88 				'<div class="aloha-sidebar-inner">' +
 89 					'<ul class="aloha-sidebar-panels"></ul>' +
 90 				'</div>' +
 91 			'</div>');
 92 		this.width = 300;
 93 		this.opened = false;
 94 		this.isOpen = false;
 95 		this.settings = {
 96 			// We automatically set this to true when we are in IE, where
 97 			// rotating elements using filters causes undesirable rendering
 98 			// ugliness.  Our solution is to fallback to swapping icon images.
 99 			// We set this as a sidebar property so that it can overridden by
100 			// whoever thinks they are smarter than we are.
101 			rotateIcons : !$.browser.msie,
102 			overlayPage : true
103 		};
104 
105 		$(function () {
106 			if (!((typeof Aloha.settings.sidebar !== 'undefined') &&
107 					Aloha.settings.sidebar.disabled)) {
108 				sidebar.init(opts);
109 			}
110 		});
111 	};
112 
113 	/**
114 	 * The last calculated view port height.
115 	 * @type {number}
116 	 */
117 	var previousViewportHeight = null;
118 	var previousActivePanelIds = null;
119 
120 	$.extend(Sidebar.prototype, {
121 
122 		// We build as much of the sidebar as we can before appending it to DOM
123 		// to minimize reflow.
124 		init: function (opts) {
125 			var that = this;
126 			var panels;
127 
128 			if (typeof opts === 'object') {
129 				panels = opts.panels;
130 				delete opts.panels;
131 			}
132 
133 			$.extend(this, opts);
134 
135 			if (typeof panels === 'object') {
136 				$.each(panels, function () {
137 					that.addPanel(this, true);
138 				});
139 			}
140 
141 			var bar = this.container;
142 
143 			if (this.position === 'right') {
144 				bar.addClass('aloha-sidebar-right');
145 			}
146 
147 			bar.hide()
148 			   .appendTo($('body'))
149 			   .click(function () { that.barClicked.apply(that, arguments); })
150 			   .find('.aloha-sidebar-panels').width(this.width);
151 
152 			// IE7 needs us to explicitly set the container width, since it is
153 			// unable to determine it on its own.
154 			bar.width(this.width);
155 			this.width = bar.width();
156 
157 			$(window).resize(function () {
158 				that.updateHeight();
159 			});
160 
161 			this.updateHeight();
162 			this.initToggler();
163 
164 			this.container.css(this.position === 'right'
165 				? 'marginRight' : 'marginLeft', -this.width);
166 
167 			if (this.opened) {
168 				this.open(0);
169 			}
170 
171 			this.toggleHandleIcon(this.isOpen);
172 			this.subscribeToEvents();
173 
174 			$(window).resize(function () {
175 				that.correctHeight();
176 			});
177 
178 			this.correctHeight();
179 		},
180 
181 		show: function () {
182 			this.container.css('display', 'block');
183 			return this;
184 		},
185 
186 		hide: function () {
187 			this.container.css('display','none');
188 			return this;
189 		},
190 
191 		/**
192 		 * Determines the effective elements at the current selection.
193 		 * Iterates through all panels and checks whether the panel should be
194 		 * activated for any of the effective elements in the selection.
195 		 *
196 		 * @param {Aloha.RangeObject} range The current selection range.
197 		 */
198 		checkActivePanels: function (range) {
199 			var effective = [];
200 
201 			if (typeof range !== 'undefined' &&
202 					typeof range.markupEffectiveAtStart !== 'undefined') {
203 				var l = range.markupEffectiveAtStart.length;
204 				var i;
205 				for (i = 0; i < l; ++i) {
206 					effective.push($(range.markupEffectiveAtStart[i]));
207 				}
208 			}
209 
210 			var that = this;
211 
212 			$.each(this.panels, function () {
213 				that.showActivePanel(this, effective);
214 			});
215 
216 			this.correctHeight();
217 		},
218 
219 		subscribeToEvents: function () {
220 			var that = this;
221 
222 			PubSub.sub('aloha.selection.context-change', function (message) {
223 				if (that.isOpen) {
224 					that.checkActivePanels(message.range);
225 				}
226 				that.lastRange = message.range;
227 			});
228 
229 			Aloha.bind('aloha-editable-deactivated', function (event, params) {
230 				if (that.isOpen) {
231 					that.checkActivePanels();
232 				}
233 				that.lastRange = null;
234 			});
235 
236 			this.container.mousedown(function (e) {
237 				e.originalEvent.stopSelectionUpdate = true;
238 				Aloha.eventHandled = true;
239 			});
240 
241 			this.container.mouseup(function (e) {
242 				e.originalEvent.stopSelectionUpdate = true;
243 				Aloha.eventHandled = false;
244 			});
245 		},
246 
247 		/**
248 		 * Dynamically set appropriate heights for panels.
249 		 * The height for each panel is determined by the amount of space that
250 		 * is available in the viewport and the number of panels that need to
251 		 * share that space.
252 		 */
253 		correctHeight: function () {
254 			if (!this.isOpen) {
255 				return;
256 			}
257 
258 			var viewportHeight = $(window).height();
259 			var activePanelIds = [];
260 			var panels = [];
261 			var panelId;
262 			for (panelId in this.panels) if (this.panels.hasOwnProperty(panelId)) {
263 				if (this.panels[panelId].isActive) {
264 					panels.push(this.panels[panelId]);
265 					activePanelIds.push(panelId);
266 				}
267 			}
268 
269 			if (0 === panels.length) {
270 				return;
271 			}
272 
273 			activePanelIds = activePanelIds.sort().join(',');
274 
275 			if (previousActivePanelIds === activePanelIds &&
276 			    previousViewportHeight === viewportHeight) {
277 				return;
278 			}
279 
280 			previousViewportHeight = viewportHeight;
281 			previousActivePanelIds = activePanelIds;
282 
283 			var height = this.container.find('.aloha-sidebar-inner').height();
284 			var remainingHeight = height - ((panels[0].title.outerHeight() + 10) * panels.length);
285 			var panel;
286 			var targetHeight;
287 			var panelInner;
288 			var panelText;
289 			var undone;
290 			var toadd = 0;
291 			var math = Math;
292 			var j;
293 
294 			while (panels.length > 0 && remainingHeight > 0) {
295 				remainingHeight += toadd;
296 				toadd = 0;
297 				undone = [];
298 
299 				for (j = panels.length - 1; j >= 0; --j) {
300 					panel = panels[j];
301 					panelInner = panel.content.find('.aloha-sidebar-panel-content-inner');
302 
303 					targetHeight = math.min(
304 						panelInner.height('auto').height(),
305 						math.floor(remainingHeight / (j + 1))
306 					);
307 
308 					panelInner.height(targetHeight);
309 					remainingHeight -= targetHeight;
310 					panelText = panelInner.find('.aloha-sidebar-panel-content-inner-text');
311 
312 					if (panelText.height() > targetHeight) {
313 						undone.push(panel);
314 						toadd += targetHeight;
315 						panelInner.css({
316 							'overflow-x': 'hidden',
317 							'overflow-y': 'scroll'
318 						});
319 					} else {
320 						panelInner.css('overflow-y', 'hidden');
321 					}
322 
323 					if (panel.expanded) {
324 						panel.expand();
325 					}
326 				}
327 
328 				panels = undone;
329 			}
330 		},
331 
332 		/**
333 		 * Checks whether this panel should be activated (ie: made visible) for
334 		 * any of the elements specified in a given list of elements.
335 		 *
336 		 * We have to add a null object to the list of elements to allow us to
337 		 * check whether the panel should be visible when we have no effective
338 		 * elements in the current selection
339 		 *
340 		 * @param {object} panel The Panel object we will test
341 		 * @param {Array.<jQuery.<HTMLElement>>} elements The effective
342 		 *                                                elements, any of
343 		 *                                                which may activate
344 		 *                                                the panel.
345 		 */
346 		showActivePanel: function (panel, elements) {
347 			elements.push(null);
348 
349 			var li = panel.content.parent('li');
350 			var activeOn = panel.activeOn;
351 			var effective = $();
352 			var count = 0;
353 			var j = elements.length;
354 			var i;
355 
356 			for (i = 0; i < j; ++i) {
357 				if (activeOn(elements[i])) {
358 					++count;
359 					if (elements[i]) {
360 						$.merge(effective, elements[i]);
361 					}
362 				}
363 			}
364 
365 			if (count) {
366 				panel.activate(effective);
367 			} else {
368 				panel.deactivate();
369 			}
370 		},
371 
372 		/**
373 		 * Sets up the functionality, event listeners, and animation of the
374 		 * sidebar handle
375 		 */
376 		initToggler: function () {
377 			var that = this;
378 			var bar = this.container;
379 			var icon = bar.find('.aloha-sidebar-handle-icon');
380 			var toggledClass = 'aloha-sidebar-toggled';
381 			var bounceTimer;
382 			var isRight = (this.position === 'right');
383 
384 			if (this.opened) {
385 				this.rotateHandleArrow(isRight ? 0 : 180, 0);
386 			}
387 
388 			// configure the position of the sidebar handle
389 			$(function () {
390 				if (typeof Aloha.settings.sidebar !== 'undefined' &&
391 						Aloha.settings.sidebar.handle &&
392 						Aloha.settings.sidebar.handle.top) {
393 					$(bar.find('.aloha-sidebar-handle'))[0].style.top =
394 						Aloha.settings.sidebar.handle.top;
395 				}
396 			});
397 
398 			bar.find('.aloha-sidebar-handle')
399 				.click(function () {
400 					if (bounceTimer) {
401 						clearInterval(bounceTimer);
402 					}
403 
404 					icon.stop().css('marginLeft', 4);
405 
406 					if (that.isOpen) {
407 						$(this).removeClass(toggledClass);
408 						that.close();
409 						that.isOpen = false;
410 					} else {
411 						$(this).addClass(toggledClass);
412 						that.open();
413 						that.isOpen = true;
414 					}
415 				}).hover(function () {
416 					var flag = that.isOpen ? -1 : 1;
417 
418 					if (bounceTimer) {
419 						clearInterval(bounceTimer);
420 					}
421 
422 					icon.stop();
423 
424 					$(this).stop().animate(
425 						isRight ? {marginLeft: '-=' + (flag * 5)}
426 								: {marginRight: '-=' + (flag * 5)},
427 						200);
428 
429 					bounceTimer = setInterval(function () {
430 						flag *= -1;
431 						icon.animate(
432 							isRight ? {left: '-=' + (flag * 4)}
433 									: {right: '-=' + (flag * 4)},
434 							300
435 						);
436 					}, 300);
437 				}, function () {
438 					if (bounceTimer) {
439 						clearInterval(bounceTimer);
440 					}
441 
442 					icon.stop().css(isRight ? 'left' : 'right', 5);
443 
444 					$(this).stop().animate(
445 						isRight ? {marginLeft: 0} : {marginRight: 0},
446 						600, 'easeOutElastic');
447 				});
448 		},
449 
450 		/**
451 		 * Rounds the top corners of the first visible panel, and the bottom
452 		 * corners of the last visible panel elements in the panels ul list.
453 		 * @deprecated
454 		 * @fixme: css3
455 		 */
456 		roundCorners: function () {
457 
458 			var bar = this.container;
459 			var lis = bar.find('.aloha-sidebar-panels>li:not(.aloha-sidebar-deactivated)');
460 			var topClass = 'aloha-sidebar-panel-top';
461 			var bottomClass = 'aloha-sidebar-panel-bottom';
462 
463 			bar.find('.aloha-sidebar-panel-top, .aloha-sidebar-panel-bottom')
464 			   .removeClass(topClass)
465 			   .removeClass(bottomClass);
466 
467 			lis.first().find('.aloha-sidebar-panel-title').addClass(topClass);
468 			lis.last().find('.aloha-sidebar-panel-content').addClass(bottomClass);
469 		},
470 
471 		/**
472 		 * Updates the height of the inner div of the sidebar. This is done
473 		 * whenever the viewport is resized.
474 		 */
475 		updateHeight: function () {
476 			var h = $(window).height();
477 			this.container.height(h).find('.aloha-sidebar-inner').height(h);
478 		},
479 
480 		/**
481 		 * Delegate all sidebar onclick events to the container.
482 		 * Then use handleBarclick method until we bubble up to the first
483 		 * significant element that we can interact with.
484 		 */
485 		barClicked: function (ev) {
486 			this.handleBarclick($(ev.target));
487 		},
488 
489 		/**
490 		 * We handle all click events on the sidebar from here--dispatching
491 		 * calls to which ever methods that should be invoked for the each
492 		 * interaction.
493 		 */
494 		handleBarclick: function (el) {
495 			if (el.hasClass('aloha-sidebar-panel-title')) {
496 				this.togglePanel(el);
497 			} else if (!el.hasClass('aloha-sidebar-panel-content') &&
498                        !el.hasClass('aloha-sidebar-handle') &&
499                        !el.hasClass('aloha-sidebar-bar')) {
500 				this.handleBarclick(el.parent());
501 			}
502 		},
503 
504 		getPanelById: function (id) {
505 			return this.panels[id];
506 		},
507 
508 		getPanelByElement: function (el) {
509 			var li = (el[0].tagName === 'LI') ? el : el.parent('li');
510 			return this.getPanelById(li[0].id);
511 		},
512 
513 		togglePanel: function (el) {
514 			this.getPanelByElement(el).toggle();
515 		},
516 
517 		/**
518 		 * Animation to rotate the sidebar arrow
519 		 *
520 		 * @param {number} angle The angle two which the arrow should rotate
521 		 *						 (0 or 180).
522 		 * @param {number|String} duration (Optional) How long the animation
523 		 *                                 should play for.
524 		 */
525 		rotateHandleIcon: function (angle, duration) {
526 			var arr = this.container.find('.aloha-sidebar-handle-icon');
527 			arr.animate({angle: angle}, {
528 				duration : (typeof duration === 'number' ||
529                             typeof duration === 'string') ? duration : 500,
530 				easing   : 'easeOutExpo',
531 				step     : function (val, fx) {
532 					arr.css({
533 						'-o-transform'      : 'rotate(' + val + 'deg)',
534 						'-webkit-transform' : 'rotate(' + val + 'deg)',
535 						'-moz-transform'    : 'rotate(' + val + 'deg)',
536 						'-ms-transform'     : 'rotate(' + val + 'deg)'
537 					  // We cannot use Microsoft Internet Explorer filters
538 					  // because Microsoft Internet Explore 8 does not support
539 					  // Microsoft Internet Explorer filters correctly. It
540 					  // breaks the layout
541 					  // filter             : 'progid:DXImageTransform.Microsoft.BasicImage(rotation=' + (angle / 90) + ')'
542 					});
543 				}
544 			});
545 		},
546 
547 		/**
548 		 * Sets the handle icon to the "i am opened, click me to close the
549 		 * sidebar" state, or vice versa. The direction of the arrow depends
550 		 * on whether the sidebar is on the left or right, and whether it is
551 		 * in an opened state or not.
552 		 *
553 		 * @param {boolean} isOpen Whether or not the sidebar is in the opened
554 		 *                         state.
555 		 */
556 		toggleHandleIcon: function (isOpen) {
557 			var isPointingLeft = (this.position === 'right') ^ isOpen;
558 
559 			if (this.settings.rotateIcons) {
560 				this.rotateHandleIcon(isPointingLeft ? 180 : 0, 0);
561 			} else {
562 				var icon = this.container.find('.aloha-sidebar-handle-icon');
563 
564 				if (isPointingLeft) {
565 					icon.addClass('aloha-sidebar-handle-icon-left');
566 				} else {
567 					icon.removeClass('aloha-sidebar-handle-icon-left');
568 				}
569 			}
570 		},
571 
572 		/**
573 		 * Slides the sidebar into view
574 		 */
575 		open: function (duration, callback) {
576 			if (this.isOpen) {
577 				return this;
578 			}
579 
580 			var isRight = (this.position === 'right');
581 			var anim = isRight ? {marginRight: 0} : {marginLeft: 0};
582 
583 			this.toggleHandleIcon(true);
584 			this.container.animate(anim,
585 				(typeof duration === 'number' || typeof duration === 'string')
586 					? duration : 500,
587 				'easeOutExpo');
588 
589 			if (!this.settings.overlayPage) {
590 				$('body').animate(
591 					isRight ? {marginRight: '+=' + this.width}
592 					        : {marginLeft: '+=' + this.width},
593 					500, 'easeOutExpo');
594 			}
595 
596 			this.isOpen = true;
597 			this.correctHeight();
598 			if (this.lastRange) {
599 				this.checkActivePanels(this.lastRange);
600 			}
601 			$('body').trigger('aloha-sidebar-opened', this);
602 
603 			return this;
604 		},
605 
606 		/**
607 		 * Slides that sidebar out of view.
608 		 */
609 		close: function (duration, callback) {
610 			if (!this.isOpen) {
611 				return this;
612 			}
613 
614 			var isRight = (this.position === 'right');
615 			var anim = isRight ? {marginRight: -this.width} : {marginLeft: -this.width};
616 
617 			this.toggleHandleIcon(false);
618 			this.container.animate(anim,
619 				(typeof duration === 'number' || typeof duration === 'string')
620 					? duration : 500,
621 				'easeOutExpo');
622 
623 			if (!this.settings.overlayPage) {
624 				$('body').animate(
625 					isRight ? {marginRight: '-=' + this.width}
626 					        : {marginLeft: '-=' + this.width},
627 					500, 'easeOutExpo');
628 			}
629 
630 			this.isOpen = false;
631 
632 			return this;
633 		},
634 
635 		/**
636 		 * Activates the given panel and passes to it the given element as the
637 		 * the effective that we want it to think activated it.
638 		 *
639 		 * @param {object|String} panel Panel instance or the id of a panel
640 		 *								object.
641 		 * @param {jQuery} element Element to pass to the panel as effective
642 		 *	                       element (the element that activated it).
643 		 */
644 		activatePanel: function (panel, element) {
645 			if (typeof panel === 'string') {
646 				panel = this.getPanelById(panel);
647 			}
648 
649 			if (panel) {
650 				panel.activate(element);
651 			}
652 
653 			this.roundCorners();
654 
655 			return this;
656 		},
657 
658 		/**
659 		 * Invokes the expand method for the given panel so that it expands its
660 		 * height to display its contents
661 		 *
662 		 * @param {object|String} panel Panel instance or the id of a panel
663 		 *                              object.
664 		 * @param {funtion} callback
665 		 */
666 		expandPanel: function (panel, callback) {
667 			if (typeof panel === 'string') {
668 				panel = this.getPanelById(panel);
669 			}
670 
671 			if (panel) {
672 				panel.expand(callback);
673 			}
674 
675 			return this;
676 		},
677 
678 		/**
679 		 * Collapses the panel contents by invoking the given panel's collapse
680 		 * method.
681 		 *
682 		 * @param {object|String} panel Panel instance or the id of a panel
683 		 *								object.
684 		 * @param {funtion} callback
685 		 */
686 		collapsePanel: function (panel, callback) {
687 			if (typeof panel === 'string') {
688 				panel = this.getPanelById(panel);
689 			}
690 
691 			if (panel) {
692 				panel.collapse(callback);
693 			}
694 
695 			return this;
696 		},
697 
698 		/**
699 		 * Adds a panel to this sidebar instance.
700 		 * We try and build as much of the panel DOM as we can before inserting
701 		 * it into the DOM in order to reduce reflow.
702 		 *
703 		 * @param {object} panel - either a panel instance or an associative
704 		 *			   array containing settings for the construction
705 		 *			   of a new panel.
706 		 * @param {boolean} deferRounding - (Optional) If true, the rounding-off
707 		 *				    of the top most and bottom most panels
708 		 *				    will not be automatically done. Set
709 		 *				    this to true when adding a lot of panels
710 		 *				    at once.
711 		 * @return {object} The newly created panel.
712 		 */
713 		addPanel: function (panel, deferRounding) {
714 			if (!(panel instanceof Panel)) {
715 				if (!panel.width) {
716 					panel.width = this.width;
717 				}
718 				panel.sidebar = this;
719 				panel = new Panel(panel);
720 			}
721 
722 			this.panels[panel.id] = panel;
723 			this.container.find('.aloha-sidebar-panels').append(panel.element);
724 			this.checkActivePanels(Selection.getRangeObject());
725 			return panel;
726 		}
727 
728 	});
729 
730 	// ------------------------------------------------------------------------
731 	// Panel prototype
732 	// ------------------------------------------------------------------------
733 	$.extend(Panel.prototype, {
734 
735 		init: function (opts) {
736 			this.setTitle(opts.title).setContent(opts.content);
737 
738 			delete opts.title;
739 			delete opts.content;
740 
741 			$.extend(this, opts);
742 
743 			if (!this.id) {
744 				this.id = 'aloha-sidebar-' + (++uid);
745 			}
746 
747 			var li = this.element =
748 				$('<li id="' + this.id + '">').append(this.title, this.content);
749 
750 			if (this.expanded) {
751 				this.content.height('auto');
752 			}
753 
754 			this.toggleTitleIcon(this.expanded);
755 			this.coerceActiveOn();
756 
757 			// Disable text selection on title element.
758 			this.title
759 				.attr('unselectable', 'on')
760 				.css('-moz-user-select', 'none')
761 				.each(function () { this.onselectstart = function () { return false; }; });
762 
763 			if (typeof this.onInit === 'function') {
764 				this.onInit.apply(this);
765 			}
766 		},
767 
768 		/**
769 		 * @param {boolean} isExpanded Whether or not the panel is in an
770 		 *                             expanded state.
771 		 */
772 		toggleTitleIcon: function (isExpanded) {
773 			if (this.sidebar.settings.rotateIcons) {
774 				this.rotateTitleIcon(isExpanded ? 90 : 0);
775 			} else {
776 				var icon = this.title.find('.aloha-sidebar-panel-title-arrow');
777 
778 				if (isExpanded) {
779 					icon.addClass('aloha-sidebar-panel-title-arrow-down');
780 				} else {
781 					icon.removeClass('aloha-sidebar-panel-title-arrow-down');
782 				}
783 			}
784 		},
785 
786 		/**
787 		 * Normalizes the activeOn property into a predicate function.
788 		 */
789 		coerceActiveOn: function () {
790 			if (typeof this.activeOn !== 'function') {
791 792 				var activeOn = this.activeOn;
793 
794 				this.activeOn = (function () {
795 					var typeofActiveOn = typeof activeOn,
796 						fn;
797 
798 					if (typeofActiveOn === 'boolean') {
799 						fn = function () {
800 							return activeOn;
801 						};
802 					} else if (typeofActiveOn === 'undefined') {
803 						fn = function () {
804 							return true;
805 						};
806 					} else if (typeofActiveOn === 'string') {
807 						fn = function (el) {
808 							return el ? el.is(activeOn) : false;
809 						};
810 					} else {
811 						fn = function () {
812 							return false;
813 						};
814 					}
815 
816 					return fn;
817 				}());
818 			}
819 		},
820 
821 		/**
822 		 * Activates (displays) this panel.
823 		 */
824 		activate: function (effective) {
825 			this.isActive = true;
826 			this.content.parent('li').show().removeClass('aloha-sidebar-deactivated');
827 			this.effectiveElement = effective;
828 			if (typeof this.onActivate === 'function') {
829 				this.onActivate.call(this, effective);
830 			}
831 		},
832 
833 		/**
834 		 * Hides this panel.
835 		 */
836 		deactivate: function () {
837 			if (!this.isActive) {
838 				return;
839 			}
840 			this.isActive = false;
841 			this.content.parent('li').hide().addClass('aloha-sidebar-deactivated');
842 			this.effectiveElement = null;
843 		},
844 
845 		toggle: function () {
846 			if (this.expanded) {
847 				this.collapse();
848 			} else {
849 				this.expand();
850 			}
851 		},
852 
853 		/**
854 		 * Displays the panel's contents.
855 		 */
856 		expand: function (callback) {
857 			var that = this;
858 			var el = this.content;
859 			var old_h = el.height();
860 			var new_h = el.height('auto').height();
861 			el.height(old_h).stop().animate(
862 				{height: new_h}, 500, 'easeOutExpo',
863 				function () {
864 					if (typeof callback === 'function') {
865 						callback.call(that);
866 					}
867 				}
868 			);
869 			this.element.removeClass('collapsed');
870 			this.toggleTitleIcon(true);
871 			this.expanded = true;
872 			return this;
873 		},
874 
875 		/**
876 		 * Hides the panel's contents--leaving only it's header.
877 		 */
878 		collapse: function (duration, callback) {
879 			var that = this;
880 			this.element.addClass('collapsed');
881 			this.content.stop().animate({height: 5}, 250, 'easeOutExpo',
882 				function () {
883 					if (typeof callback === 'function') {
884 						callback.call(that);
885 					}
886 				});
887 			this.toggleTitleIcon(false);
888 			this.expanded = false;
889 			return this;
890 		},
891 
892 		/**
893 		 * May also be called by the Sidebar to update title of panel
894 		 *
895 		 * @param {string} html Markup string, DOM object, or jQuery object.
896 		 */
897 		setTitle: function (html) {
898 			this.title.find('.aloha-sidebar-panel-title-text').html(html);
899 			return this;
900 		},
901 
902 		/**
903 		 * May also be called by the Sidebar to update content of panel
904 		 *
905 		 * @param {string|jQuery.<HTMLElement>|HTMLElement} html Markup string,
906 		 *                                                       DOM object, or
907 		 *                                                       jQuery object.
908 		 */
909 		setContent: function (html) {
910 			// We do this so that empty panels don't appear collapsed
911 			if (!html || html === '') {
912 				html = ' ';
913 			}
914 
915 			this.content.find('.aloha-sidebar-panel-content-inner-text').html(html);
916 			return this;
917 		},
918 
919 		rotateTitleIcon: function (angle, duration) {
920 			var arr = this.title.find('.aloha-sidebar-panel-title-arrow');
921 			arr.animate({angle: angle}, {
922 				duration : (typeof duration === 'number') ? duration : 500,
923 				easing   : 'easeOutExpo',
924 				step     : function (val, fx) {
925 					arr.css({
926 						'-o-transform'      : 'rotate(' + val + 'deg)',
927 						'-webkit-transform' : 'rotate(' + val + 'deg)',
928 						'-moz-transform'    : 'rotate(' + val + 'deg)',
929 						'-ms-transform'     : 'rotate(' + val + 'deg)'
930 					 // filter              : 'progid:DXImageTransform.Microsoft.BasicImage(rotation=' + (angle / 90) + ')'
931 					});
932 				}
933 			});
934 		},
935 
936 		/**
937 		 * Walks up the ancestors chain for the given effective element, and
938 		 * renders subpanels using the specified renderer function.
939 		 *
940 		 * @param {jQuery.<HTMLElement>} effective The effective element, whose
941 		 *                                         lineage we want to render.
942 		 * @param {function} renderer (Optional) function that will render each
943 		 *                                       element in the parental
944 		 *                                       lineage of the effective
945 		 *                                       element.
946 		 */
947 		renderEffectiveParents: function (effective, renderer) {
948 			var el = effective.first();
949 			var content = [];
950 			var path = [];
951 			var activeOn = this.activeOn;
952 			var l;
953 			var pathRev;
954 
955 			while (el.length > 0 && !el.is('.aloha-editable')) {
956 				if (activeOn(el)) {
957 					path.push('<span>' + el[0].tagName.toLowerCase() + '</span>');
958 					l = path.length;
959 					pathRev = [];
960 					while (l--) {
961 						pathRev.push(path[l]);
962 					}
963 					content.push('<div class="aloha-sidebar-panel-parent">' +
964 						'<div class="aloha-sidebar-panel-parent-path">' +
965 							pathRev.join('') +
966 						'</div>' +
967 						'<div class="aloha-sidebar-panel-parent-content' +
968 							'aloha-sidebar-opened">' + (
969 							(typeof renderer === 'function')
970 								? renderer(el)
971 								: '----'
972 							) +
973 						'</div>' +
974 					 '</div>');
975 				}
976 				el = el.parent();
977 			}
978 
979 			this.setContent(content.join(''));
980 
981 			$('.aloha-sidebar-panel-parent-path').click(function () {
982 				var $content = $(this).parent().find(
983 					'.aloha-sidebar-panel-parent-content');
984 				if ($content.hasClass('aloha-sidebar-opened')) {
985 					$content.hide().removeClass('aloha-sidebar-opened');
986 				} else {
987 					$content.show().addClass('aloha-sidebar-opened');
988 				}
989 			});
990 
991 			this.content.height('auto').find(
992 				'.aloha-sidebar-panel-content-inner').height('auto');
993 		}
994 
995 	});
996 
997 	var left = new Sidebar({
998 		position : 'left',
999 		width	 : 250 // TODO define in config
1000 	});
1001 
1002 	var right = new Sidebar({
1003 		position : 'right',
1004 		width	 : 250 // TODO define in config
1005 	});
1006 
1007 	Aloha.Sidebar = {
1008 		left  : left,
1009 		right : right
1010 	};
1011 
1012 	return Aloha.Sidebar;
1013 });
1014