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: !$.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 163 			this.initToggler();
164 
165 			this.container.css(this.position === 'right' ? '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.updateHeight();
176 				that.correctHeight();
177 			});
178 
179 			this.correctHeight();
180 		},
181 
182 		show: function () {
183 			this.container.css('display', 'block');
184 			return this;
185 		},
186 
187 		hide: function () {
188 			this.container.css('display', 'none');
189 			return this;
190 		},
191 
192 		/**
193 		 * Determines the effective elements at the current selection.
194 		 * Iterates through all panels and checks whether the panel should be
195 		 * activated for any of the effective elements in the selection.
196 		 *
197 		 * @param {Aloha.RangeObject} range The current selection range.
198 		 */
199 		checkActivePanels: function (range) {
200 			var effective = [];
201 
202 			if (typeof range !== 'undefined' && 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 			if (!this.isCompletelyOpen) {
258 				this.correctHeightWhenCompletelyOpen = true;
259 				return;
260 			}
261 
262 			var viewportHeight = $(window).height();
263 			var activePanelIds = [];
264 			var panels = [];
265 			var panelId;
266 			for (panelId in this.panels) {
267 				if (this.panels.hasOwnProperty(panelId)) {
268 					if (this.panels[panelId].isActive) {
269 						panels.push(this.panels[panelId]);
270 						activePanelIds.push(panelId);
271 					}
272 				}
273 			}
274 
275 			if (0 === panels.length) {
276 				return;
277 			}
278 
279 			activePanelIds = activePanelIds.sort().join(',');
280 
281 			if (previousActivePanelIds === activePanelIds && previousViewportHeight === viewportHeight) {
282 				return;
283 			}
284 
285 			previousViewportHeight = viewportHeight;
286 			previousActivePanelIds = activePanelIds;
287 
288 			var height = this.container.find('.aloha-sidebar-inner').height();
289 			var remainingHeight = height - ((panels[0].title.outerHeight() + 10) * panels.length);
290 			var panel;
291 			var targetHeight;
292 			var panelInner;
293 			var panelText;
294 			var undone;
295 			var toadd = 0;
296 			var math = Math;
297 			var j;
298 
299 			while (panels.length > 0 && remainingHeight > 0) {
300 				remainingHeight += toadd;
301 				toadd = 0;
302 				undone = [];
303 
304 				for (j = panels.length - 1; j >= 0; --j) {
305 					panel = panels[j];
306 					panelInner = panel.content.find('.aloha-sidebar-panel-content-inner');
307 
308 					targetHeight = math.min(
309 						panelInner.height('auto').height(),
310 						math.floor(remainingHeight / (j + 1))
311 					);
312 
313 					panelInner.height(targetHeight);
314 					remainingHeight -= targetHeight;
315 					panelText = panelInner.find('.aloha-sidebar-panel-content-inner-text');
316 
317 					if (panelText.height() > targetHeight) {
318 						undone.push(panel);
319 						toadd += targetHeight;
320 						panelInner.css({
321 							'overflow-x': 'hidden',
322 							'overflow-y': 'scroll'
323 						});
324 					} else {
325 						panelInner.css('overflow-y', 'hidden');
326 					}
327 
328 					if (panel.expanded) {
329 						panel.expand();
330 					}
331 				}
332 
333 				panels = undone;
334 			}
335 		},
336 
337 		/**
338 		 * Checks whether this panel should be activated (ie: made visible) for
339 		 * any of the elements specified in a given list of elements.
340 		 *
341 		 * We have to add a null object to the list of elements to allow us to
342 		 * check whether the panel should be visible when we have no effective
343 		 * elements in the current selection
344 		 *
345 		 * @param {object} panel The Panel object we will test
346 		 * @param {Array.<jQuery.<HTMLElement>>} elements The effective
347 		 *                                                elements, any of
348 		 *                                                which may activate
349 		 *                                                the panel.
350 		 */
351 		showActivePanel: function (panel, elements) {
352 			elements.push(null);
353 
354 			var li = panel.content.parent('li');
355 			var activeOn = panel.activeOn;
356 			var effective = $();
357 			var count = 0;
358 			var j = elements.length;
359 			var i;
360 
361 			for (i = 0; i < j; ++i) {
362 				if (activeOn(elements[i])) {
363 					++count;
364 					if (elements[i]) {
365 						$.merge(effective, elements[i]);
366 					}
367 				}
368 			}
369 
370 			if (count) {
371 				panel.activate(effective);
372 			} else {
373 				panel.deactivate();
374 			}
375 		},
376 
377 		/**
378 		 * Sets up the functionality, event listeners, and animation of the
379 		 * sidebar handle
380 		 */
381 		initToggler: function () {
382 			var that = this;
383 			var bar = this.container;
384 			var icon = bar.find('.aloha-sidebar-handle-icon');
385 			var toggledClass = 'aloha-sidebar-toggled';
386 			var bounceTimer;
387 			var isRight = (this.position === 'right');
388 
389 			if (this.opened) {
390 				this.rotateHandleArrow(isRight ? 0 : 180, 0);
391 			}
392 
393 			// configure the position of the sidebar handle
394 			$(function () {
395 				if (typeof Aloha.settings.sidebar !== 'undefined' && Aloha.settings.sidebar.handle && Aloha.settings.sidebar.handle.top) {
396 					$(bar.find('.aloha-sidebar-handle'))[0].style.top = Aloha.settings.sidebar.handle.top;
397 				}
398 			});
399 
400 			bar.find('.aloha-sidebar-handle').click(function () {
401 				if (bounceTimer) {
402 					clearInterval(bounceTimer);
403 				}
404 
405 				icon.stop().css('marginLeft', 4);
406 
407 				if (that.isOpen) {
408 					$(this).removeClass(toggledClass);
409 					that.close();
410 					that.isOpen = false;
411 				} else {
412 					$(this).addClass(toggledClass);
413 					that.open();
414 					that.isOpen = true;
415 				}
416 			}).hover(function () {
417 				var flag = that.isOpen ? -1 : 1;
418 
419 				if (bounceTimer) {
420 					clearInterval(bounceTimer);
421 				}
422 
423 				icon.stop();
424 
425 				$(this).stop().animate(
426 					isRight ? {
427 						marginLeft: '-=' + (flag * 5)
428 					} : {
429 						marginRight: '-=' + (flag * 5)
430 					},
431 					200
432 				);
433 
434 				bounceTimer = setInterval(function () {
435 					flag *= -1;
436 					icon.animate(
437 						isRight ? {
438 							left: '-=' + (flag * 4)
439 						} : {
440 							right: '-=' + (flag * 4)
441 						},
442 						300
443 					);
444 				}, 300);
445 			}, function () {
446 				if (bounceTimer) {
447 					clearInterval(bounceTimer);
448 				}
449 
450 				icon.stop().css(isRight ? 'left' : 'right', 5);
451 
452 				$(this).stop().animate(
453 					isRight ? {
454 						marginLeft: 0
455 					} : {
456 						marginRight: 0
457 					},
458 					600,
459 					'easeOutElastic'
460 				);
461 			});
462 		},
463 
464 		/**
465 		 * Rounds the top corners of the first visible panel, and the bottom
466 		 * corners of the last visible panel elements in the panels ul list.
467 		 * @deprecated
468 		 * @fixme: css3
469 		 */
470 		roundCorners: function () {
471 
472 			var bar = this.container;
473 			var lis = bar.find('.aloha-sidebar-panels>li:not(.aloha-sidebar-deactivated)');
474 			var topClass = 'aloha-sidebar-panel-top';
475 			var bottomClass = 'aloha-sidebar-panel-bottom';
476 
477 			bar.find('.aloha-sidebar-panel-top, .aloha-sidebar-panel-bottom').removeClass(topClass).removeClass(bottomClass);
478 
479 			lis.first().find('.aloha-sidebar-panel-title').addClass(topClass);
480 			lis.last().find('.aloha-sidebar-panel-content').addClass(bottomClass);
481 		},
482 
483 		/**
484 		 * Updates the height of the inner div of the sidebar. This is done
485 		 * whenever the viewport is resized.
486 		 */
487 		updateHeight: function () {
488 			var h = $(window).height();
489 			this.container.height(h).find('.aloha-sidebar-inner').height(h);
490 		},
491 
492 		/**
493 		 * Delegate all sidebar onclick events to the container.
494 		 * Then use handleBarclick method until we bubble up to the first
495 		 * significant element that we can interact with.
496 		 */
497 		barClicked: function (ev) {
498 			this.handleBarclick($(ev.target));
499 		},
500 
501 		/**
502 		 * We handle all click events on the sidebar from here--dispatching
503 		 * calls to which ever methods that should be invoked for the each
504 		 * interaction.
505 		 */
506 		handleBarclick: function (el) {
507 			if (el.hasClass('aloha-sidebar-panel-title')) {
508 				this.togglePanel(el);
509 			} else if (!el.hasClass('aloha-sidebar-panel-content') && !el.hasClass('aloha-sidebar-handle') && !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({
538 				angle: angle
539 			}, {
540 				duration: (typeof duration === 'number' || typeof duration === 'string') ? duration : 500,
541 				easing: 'easeOutExpo',
542 				step: function (val, fx) {
543 					arr.css({
544 						'-o-transform': 'rotate(' + val + 'deg)',
545 						'-webkit-transform': 'rotate(' + val + 'deg)',
546 						'-moz-transform': 'rotate(' + val + 'deg)',
547 						'-ms-transform': 'rotate(' + val + 'deg)'
548 						// We cannot use Microsoft Internet Explorer filters
549 						// because Microsoft Internet Explore 8 does not support
550 						// Microsoft Internet Explorer filters correctly. It
551 						// breaks the layout
552 						// filter             : 'progid:DXImageTransform.Microsoft.BasicImage(rotation=' + (angle / 90) + ')'
553 					});
554 				}
555 			});
556 		},
557 
558 		/**
559 		 * Sets the handle icon to the "i am opened, click me to close the
560 		 * sidebar" state, or vice versa. The direction of the arrow depends
561 		 * on whether the sidebar is on the left or right, and whether it is
562 		 * in an opened state or not.
563 		 *
564 		 * @param {boolean} isOpen Whether or not the sidebar is in the opened
565 		 *                         state.
566 		 */
567 		toggleHandleIcon: function (isOpen) {
568 			var isPointingLeft = (this.position === 'right') ^ isOpen;
569 
570 			if (this.settings.rotateIcons) {
571 				this.rotateHandleIcon(isPointingLeft ? 180 : 0, 0);
572 			} else {
573 				var icon = this.container.find('.aloha-sidebar-handle-icon');
574 
575 				if (isPointingLeft) {
576 					icon.addClass('aloha-sidebar-handle-icon-left');
577 				} else {
578 					icon.removeClass('aloha-sidebar-handle-icon-left');
579 				}
580 			}
581 		},
582 
583 		/**
584 		 * Slides the sidebar into view
585 		 */
586 		open: function (duration, callback) {
587 			if (this.isOpen) {
588 				return this;
589 			}
590 
591 			var isRight = (this.position === 'right');
592 			var anim = isRight ? {
593 				marginRight: 0
594 			} : {
595 				marginLeft: 0
596 			};
597 			var sidebar = this;
598 
599 			this.toggleHandleIcon(true);
600 			this.container.animate(anim, (typeof duration === 'number' || typeof duration === 'string') ? duration : 500, 'easeOutExpo');
601 
602 			if (!this.settings.overlayPage) {
603 				$('body').animate(
604 					isRight ? {
605 						marginRight: '+=' + this.width
606 					} : {
607 						marginLeft: '+=' + this.width
608 					},
609 					500,
610 					'easeOutExpo',
611 					function () {
612 						sidebar.isCompletelyOpen = true;
613 						if (sidebar.correctHeightWhenCompletelyOpen) {
614 615 							sidebar.correctHeight();
616 						}
617 					}
618 				);
619 			}
620 
621 			this.isOpen = true;
622 			this.correctHeight();
623 			if (this.lastRange) {
624 				this.checkActivePanels(this.lastRange);
625 			}
626 			$('body').trigger('aloha-sidebar-opened', this);
627 
628 			return this;
629 		},
630 
631 		/**
632 		 * Slides that sidebar out of view.
633 		 */
634 		close: function (duration, callback) {
635 			if (!this.isOpen) {
636 				return this;
637 			}
638 
639 			var isRight = (this.position === 'right');
640 			var anim = isRight ? {
641 				marginRight: -this.width
642 			} : {
643 				marginLeft: -this.width
644 			};
645 
646 			this.toggleHandleIcon(false);
647 			this.container.animate(anim, (typeof duration === 'number' || typeof duration === 'string') ? duration : 500, 'easeOutExpo');
648 
649 			if (!this.settings.overlayPage) {
650 				$('body').animate(
651 					isRight ? {
652 						marginRight: '-=' + this.width
653 					} : {
654 						marginLeft: '-=' + this.width
655 					},
656 					500,
657 					'easeOutExpo'
658 				);
659 			}
660 
661 			this.isOpen = false;
662 			this.isCompletelyOpen = false;
663 
664 			return this;
665 		},
666 
667 		/**
668 		 * Activates the given panel and passes to it the given element as the
669 		 * the effective that we want it to think activated it.
670 		 *
671 		 * @param {object|String} panel Panel instance or the id of a panel
672 		 *								object.
673 		 * @param {jQuery} element Element to pass to the panel as effective
674 		 *	                       element (the element that activated it).
675 		 */
676 		activatePanel: function (panel, element) {
677 			if (typeof panel === 'string') {
678 				panel = this.getPanelById(panel);
679 			}
680 
681 			if (panel) {
682 				panel.activate(element);
683 			}
684 
685 			this.roundCorners();
686 
687 			return this;
688 		},
689 
690 		/**
691 		 * Invokes the expand method for the given panel so that it expands its
692 		 * height to display its contents
693 		 *
694 		 * @param {object|String} panel Panel instance or the id of a panel
695 		 *                              object.
696 		 * @param {funtion} callback
697 		 */
698 		expandPanel: function (panel, callback) {
699 			if (typeof panel === 'string') {
700 				panel = this.getPanelById(panel);
701 			}
702 
703 			if (panel) {
704 				panel.expand(callback);
705 			}
706 
707 			return this;
708 		},
709 
710 		/**
711 		 * Collapses the panel contents by invoking the given panel's collapse
712 		 * method.
713 		 *
714 		 * @param {object|String} panel Panel instance or the id of a panel
715 		 *								object.
716 		 * @param {funtion} callback
717 		 */
718 		collapsePanel: function (panel, callback) {
719 			if (typeof panel === 'string') {
720 				panel = this.getPanelById(panel);
721 			}
722 
723 			if (panel) {
724 				panel.collapse(callback);
725 			}
726 
727 			return this;
728 		},
729 
730 		/**
731 		 * Adds a panel to this sidebar instance.
732 		 * We try and build as much of the panel DOM as we can before inserting
733 		 * it into the DOM in order to reduce reflow.
734 		 *
735 		 * @param {object} panel - either a panel instance or an associative
736 		 *			   array containing settings for the construction
737 		 *			   of a new panel.
738 		 * @param {boolean} deferRounding - (Optional) If true, the rounding-off
739 		 *				    of the top most and bottom most panels
740 		 *				    will not be automatically done. Set
741 		 *				    this to true when adding a lot of panels
742 		 *				    at once.
743 		 * @return {object} The newly created panel.
744 		 */
745 		addPanel: function (panel, deferRounding) {
746 			if (!(panel instanceof Panel)) {
747 				if (!panel.width) {
748 					panel.width = this.width;
749 				}
750 				panel.sidebar = this;
751 				panel = new Panel(panel);
752 			}
753 
754 			this.panels[panel.id] = panel;
755 			this.container.find('.aloha-sidebar-panels').append(panel.element);
756 			this.checkActivePanels(Selection.getRangeObject());
757 			return panel;
758 		}
759 
760 	});
761 
762 	// ------------------------------------------------------------------------
763 	// Panel prototype
764 	// ------------------------------------------------------------------------
765 	$.extend(Panel.prototype, {
766 
767 		init: function (opts) {
768 			this.setTitle(opts.title).setContent(opts.content);
769 
770 			delete opts.title;
771 			delete opts.content;
772 
773 			$.extend(this, opts);
774 
775 			if (!this.id) {
776 				this.id = 'aloha-sidebar-' + (++uid);
777 			}
778 
779 			var li = this.element = $('<li id="' + this.id + '">').append(this.title, this.content);
780 
781 			if (this.expanded) {
782 				this.content.height('auto');
783 			}
784 
785 			this.toggleTitleIcon(this.expanded);
786 			this.coerceActiveOn();
787 
788 			// Disable text selection on title element.
789 			this.title.attr('unselectable', 'on').css('-moz-user-select', 'none').each(function () {
790 				this.onselectstart = function () {
791 					return false;
792 				};
793 			});
794 
795 			if (typeof this.onInit === 'function') {
796 				this.onInit.apply(this);
797 			}
798 		},
799 
800 		/**
801 		 * @param {boolean} isExpanded Whether or not the panel is in an
802 		 *                             expanded state.
803 		 */
804 		toggleTitleIcon: function (isExpanded) {
805 			if (this.sidebar.settings.rotateIcons) {
806 				this.rotateTitleIcon(isExpanded ? 90 : 0);
807 			} else {
808 				var icon = this.title.find('.aloha-sidebar-panel-title-arrow');
809 
810 				if (isExpanded) {
811 					icon.addClass('aloha-sidebar-panel-title-arrow-down');
812 				} else {
813 					icon.removeClass('aloha-sidebar-panel-title-arrow-down');
814 				}
815 			}
816 		},
817 
818 		/**
819 		 * Normalizes the activeOn property into a predicate function.
820 		 */
821 		coerceActiveOn: function () {
822 			if (typeof this.activeOn !== 'function') {
823 				var activeOn = this.activeOn;
824 
825 				this.activeOn = (function () {
826 					var typeofActiveOn = typeof activeOn,
827 						fn;
828 
829 					if (typeofActiveOn === 'boolean') {
830 						fn = function () {
831 							return activeOn;
832 						};
833 					} else if (typeofActiveOn === 'undefined') {
834 						fn = function () {
835 							return true;
836 						};
837 					} else if (typeofActiveOn === 'string') {
838 						fn = function (el) {
839 							return el ? el.is(activeOn) : false;
840 						};
841 					} else {
842 						fn = function () {
843 							return false;
844 						};
845 					}
846 
847 					return fn;
848 				}());
849 			}
850 		},
851 
852 		/**
853 		 * Activates (displays) this panel.
854 		 */
855 		activate: function (effective) {
856 			this.isActive = true;
857 			this.content.parent('li').show().removeClass('aloha-sidebar-deactivated');
858 			this.effectiveElement = effective;
859 			if (typeof this.onActivate === 'function') {
860 				this.onActivate.call(this, effective);
861 			}
862 		},
863 
864 		/**
865 		 * Hides this panel.
866 		 */
867 		deactivate: function () {
868 			if (!this.isActive) {
869 				return;
870 			}
871 			this.isActive = false;
872 			this.content.parent('li').hide().addClass('aloha-sidebar-deactivated');
873 			this.effectiveElement = null;
874 		},
875 
876 		toggle: function () {
877 878 			if (this.expanded) {
879 				this.collapse();
880 			} else {
881 				this.expand();
882 			}
883 		},
884 
885 		/**
886 		 * Displays the panel's contents.
887 		 */
888 		expand: function (callback) {
889 			var that = this;
890 			var el = this.content;
891 			var old_h = el.height();
892 			var new_h = el.height('auto').height();
893 			el.height(old_h).stop().animate(
894 				{height: new_h},
895 				500,
896 				'easeOutExpo',
897 				function () {
898 					if (typeof callback === 'function') {
899 						callback.call(that);
900 					}
901 				}
902 			);
903 			this.element.removeClass('collapsed');
904 			this.toggleTitleIcon(true);
905 			this.expanded = true;
906 			return this;
907 		},
908 
909 		/**
910 		 * Hides the panel's contents--leaving only it's header.
911 		 */
912 		collapse: function (duration, callback) {
913 			var that = this;
914 			this.element.addClass('collapsed');
915 			this.content.stop().animate(
916 				{ height: 3 },
917 				250,
918 				'easeOutExpo',
919 				function () {
920 					if (typeof callback === 'function') {
921 						callback.call(that);
922 					}
923 				}
924 			);
925 			this.toggleTitleIcon(false);
926 			this.expanded = false;
927 			return this;
928 		},
929 
930 		/**
931 		 * May also be called by the Sidebar to update title of panel
932 		 *
933 		 * @param {string} html Markup string, DOM object, or jQuery object.
934 		 */
935 		setTitle: function (html) {
936 			this.title.find('.aloha-sidebar-panel-title-text').html(html);
937 			return this;
938 		},
939 
940 		/**
941 		 * May also be called by the Sidebar to update content of panel
942 		 *
943 		 * @param {string|jQuery.<HTMLElement>|HTMLElement} html Markup string,
944 		 *                                                       DOM object, or
945 		 *                                                       jQuery object.
946 		 */
947 		setContent: function (html) {
948 			// We do this so that empty panels don't appear collapsed
949 			if (!html || html === '') {
950 				html = ' ';
951 			}
952 
953 			this.content.find('.aloha-sidebar-panel-content-inner-text').html(html);
954 			return this;
955 		},
956 
957 		rotateTitleIcon: function (angle, duration) {
958 			var arr = this.title.find('.aloha-sidebar-panel-title-arrow');
959 			arr.animate({
960 				angle: angle
961 			}, {
962 				duration: (typeof duration === 'number') ? duration : 500,
963 				easing: 'easeOutExpo',
964 				step: function (val, fx) {
965 					arr.css({
966 						'-o-transform': 'rotate(' + val + 'deg)',
967 						'-webkit-transform': 'rotate(' + val + 'deg)',
968 						'-moz-transform': 'rotate(' + val + 'deg)',
969 						'-ms-transform': 'rotate(' + val + 'deg)'
970 						// filter              : 'progid:DXImageTransform.Microsoft.BasicImage(rotation=' + (angle / 90) + ')'
971 					});
972 				}
973 			});
974 		},
975 
976 		/**
977 		 * Walks up the ancestors chain for the given effective element, and
978 		 * renders subpanels using the specified renderer function.
979 		 *
980 		 * @param {jQuery.<HTMLElement>} effective The effective element, whose
981 		 *                                         lineage we want to render.
982 		 * @param {function} renderer (Optional) function that will render each
983 		 *                                       element in the parental
984 		 *                                       lineage of the effective
985 		 *                                       element.
986 		 */
987 		renderEffectiveParents: function (effective, renderer) {
988 			var el = effective.first();
989 			var content = [];
990 			var path = [];
991 			var activeOn = this.activeOn;
992 			var l;
993 			var pathRev;
994 
995 			while (el.length > 0 && !el.is('.aloha-editable')) {
996 				if (activeOn(el)) {
997 					path.push('<span>' + el[0].tagName.toLowerCase() + '</span>');
998 					l = path.length;
999 					pathRev = [];
1000 					while (l--) {
1001 						pathRev.push(path[l]);
1002 					}
1003 					content.push(
1004 						'<div class="aloha-sidebar-panel-parent">'
1005 							+ '<div class="aloha-sidebar-panel-parent-path">'
1006 							+ pathRev.join('')
1007 							+ '</div>'
1008 							+ '<div class="aloha-sidebar-panel-parent-content'
1009 							+ 'aloha-sidebar-opened">'
1010 							+ ((typeof renderer === 'function') ? renderer(el) : '----') + '</div>' + '</div>'
1011 					);
1012 				}
1013 				el = el.parent();
1014 			}
1015 
1016 			this.setContent(content.join(''));
1017 
1018 			$('.aloha-sidebar-panel-parent-path').click(function () {
1019 				var $content = $(this).parent().find('.aloha-sidebar-panel-parent-content');
1020 				if ($content.hasClass('aloha-sidebar-opened')) {
1021 					$content.hide().removeClass('aloha-sidebar-opened');
1022 				} else {
1023 					$content.show().addClass('aloha-sidebar-opened');
1024 				}
1025 			});
1026 
1027 			this.content.height('auto').find('.aloha-sidebar-panel-content-inner').height('auto');
1028 		}
1029 
1030 	});
1031 
1032 	var left = new Sidebar({
1033 		position: 'left',
1034 		width: 250 // TODO define in config
1035 	});
1036 
1037 	var right = new Sidebar({
1038 		position: 'right',
1039 		width: 250 // TODO define in config
1040 	});
1041 
1042 	Aloha.Sidebar = {
1043 		left: left,
1044 		right: right
1045 	};
1046 
1047 	return Aloha.Sidebar;
1048 });
1049