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