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