jam-cloud/web/app/assets/javascripts/layout.js

910 lines
28 KiB
JavaScript

/*
* View framework for JamKazam.
*
* Processes proprietary attributes in markup to convert a set of HTML elements
* into the JamKazam screen layout. This module is only responsible for size
* and position. All other visual aspects should be elsewhere.
*
* See the layout-example.html file for a simple working example.
*/
(function (context, $) {
"use strict";
context.JK = context.JK || {};
// Static function to hide the 'curtain' which hides the underlying
// stuff until we can get it laid out. Called from both the main
// client as well as the landing page.
context.JK.hideCurtain = function (duration) {
context.setTimeout(function () {
$('.curtain').fadeOut(2 * duration);
}, duration);
};
context.JK.Layout = function () {
// privates
var logger = context.JK.logger;
var me = null; // Reference to this instance for context sanity.
var opts = {
headerHeight: 75,
sidebarWidth: 300,
notifyHeight: 150,
notifyGutter: 10,
collapsedSidebar: 30,
panelHeaderHeight: 36,
gutter: 60, // Margin around the whole UI
screenMargin: 0, // Margin around screens (not headers/sidebar)
gridOuterMargin: 6, // Outer margin on Grids (added to screenMargin if screen)
gridPadding: 8, // Padding around grid cells. Added to outer margin.
animationDuration: 400,
allowBodyOverflow: false, // Allow tests to disable the body-no-scroll policy
sizeOverlayToContent: false // if true, use the size of <body> tag to decide overlay size everytime overlay is shown. should be used in non-client settings
};
var width = $(context).width();
var height = $(context).height();
var resizing = null;
var sidebarVisible = true;
var expandedPanel = null;
var previousScreen = null;
var currentScreen = null;
var currentHash = null;
var screenBindings = {};
var dialogBindings = {};
var wizardShowFunctions = {};
var openDialogs = []; // FIFO stack
var resettingHash = false;
function setup() {
requiredStyles();
hideAll();
setInitialExpandedSidebarPanel();
sizeScreens(width, height, '[layout="screen"]', true);
positionOffscreens(width, height);
$('[layout="sidebar"]').show();
$('[layout="panel"]').show();
layout();
}
function setInitialExpandedSidebarPanel() {
expandedPanel = $('[layout="panel"]').first().attr("layout-id");
}
function layout() {
width = $(context).width();
height = $(context).height();
// TODO
// Work on naming. File is layout, class is Layout, this method
// is layout and every other method starts with 'layoutX'. Perhaps
// a little redundant?
layoutCurtain(width, height);
layoutDialogOverlay(width, height);
layoutScreens(width, height);
layoutSidebar(width, height);
layoutHeader(width, height);
layoutNotify(width, height);
layoutFooter(width, height);
}
function layoutCurtain(screenWidth, screenHeight) {
var curtainStyle = {
position: 'absolute',
width: screenWidth + 'px',
height: screenHeight + 'px'
};
$('.curtain').css(curtainStyle);
}
function layoutDialogOverlay(screenWidth, screenHeight) {
var style = {
position: 'absolute',
width: screenWidth + 'px',
height: screenHeight + 'px'
};
$('.dialog-overlay').css(style);
}
function layoutScreens(screenWidth, screenHeight) {
var previousScreenSelector = '[layout-id="' + previousScreen + '"]';
var currentScreenSelector = '[layout-id="' + currentScreen + '"]';
$(currentScreenSelector).show();
var width = screenWidth - (2 * opts.gutter + 2 * opts.screenMargin);
var left = -1 * width - 100;
if (currentScreenSelector === previousScreenSelector) {
left = $(currentScreenSelector).css("left");
if (left) {
left = left.split("px")[0];
}
}
$(previousScreenSelector).animate({left: left}, {duration: opts.animationDuration, queue: false});
sizeScreens(screenWidth, screenHeight, '[layout="screen"]');
positionOffscreens(screenWidth, screenHeight);
positionOnscreen(screenWidth, screenHeight);
}
function sizeScreens(screenWidth, screenHeight, selector, immediate) {
var duration = opts.animationDuration;
if (immediate) {
duration = 0;
}
var width = screenWidth - (2 * opts.gutter + 2 * opts.screenMargin);
if (sidebarVisible) {
width -= (opts.sidebarWidth + 2 * opts.gridPadding);
} else {
width -= opts.collapsedSidebar + 2 * opts.gridPadding;
width += opts.gutter; // Add back in the right gutter width.
}
var height = screenHeight - opts.headerHeight - (2 * opts.gutter + 2 * opts.screenMargin);
var css = {
width: width,
height: height
};
var $screens = $(selector);
$screens.animate(css, {duration: duration, queue: false});
layoutHomeScreen(width, height);
}
/**
* Postition all screens that are not the current screen.
*/
function positionOffscreens(screenWidth, screenHeight) {
var top = opts.headerHeight + opts.gutter + opts.screenMargin;
var left = -1 * (screenWidth + 2 * opts.gutter);
var $screens = $('[layout="screen"]').not('[layout-id="' + currentScreen + '"]');
$screens.css({
top: top,
left: left
});
}
/**
* Position the current screen
*/
function positionOnscreen(screenWidth, screenHeight, immediate) {
var duration = opts.animationDuration;
if (immediate) {
duration = 0;
}
var top = opts.headerHeight + opts.gutter + opts.screenMargin;
var left = opts.gutter + opts.screenMargin;
var $screen = $('[layout-id="' + currentScreen + '"]');
$screen.animate({
top: top,
left: left,
overflow: 'auto'
}, duration);
}
function layoutHomeScreen(homeScreenWidth, homeScreenHeight) {
var $grid = $('[layout-grid]');
var gridWidth = homeScreenWidth;
var gridHeight = homeScreenHeight;
$grid.css({width: gridWidth, height: gridHeight});
var layout = $grid.attr('layout-grid');
if (!layout)
return;
var gridRows = layout.split('x')[0];
var gridCols = layout.split('x')[1];
$grid.children().each(function () {
var childPosition = $(this).attr("layout-grid-position");
var childRow = childPosition.split(',')[1];
var childCol = childPosition.split(',')[0];
var childRowspan = $(this).attr("layout-grid-rows");
var childColspan = $(this).attr("layout-grid-columns");
var childLayout = me.getCardLayout(gridWidth, gridHeight, gridRows, gridCols,
childRow, childCol, childRowspan, childColspan);
$(this).animate({
width: childLayout.width,
height: childLayout.height,
top: childLayout.top,
left: childLayout.left
}, opts.animationDuration);
});
}
function layoutSidebar(screenWidth, screenHeight) {
var width = opts.sidebarWidth;
var expanderHeight = $('[layout-sidebar-expander]').height();
var height = screenHeight - opts.headerHeight - 2 * opts.gutter + expanderHeight;
var right = opts.gutter;
if (!sidebarVisible) {
// Negative right to hide most of sidebar
right = (0 - opts.sidebarWidth) + opts.collapsedSidebar;
}
var top = opts.headerHeight + opts.gutter - expanderHeight;
var css = {
width: width,
height: height,
top: top,
right: right
};
$('[layout="sidebar"]').animate(css, opts.animationDuration);
layoutPanels(width, height);
if (sidebarVisible) {
$('[layout-panel="collapsed"]').hide();
$('[layout-panel="expanded"]').show();
$('[layout-sidebar-expander="hidden"]').hide();
$('[layout-sidebar-expander="visible"]').show();
} else {
$('[layout-panel="collapsed"]').show();
$('[layout-panel="expanded"]').hide();
$('[layout-sidebar-expander="hidden"]').show();
$('[layout-sidebar-expander="visible"]').hide();
}
}
function layoutPanels(sidebarWidth, sidebarHeight) {
// TODO - don't like the accordian - poor usability. Requires longest mouse
// reach when switching panels. Probably better to do tabs.
if (!sidebarVisible) {
return;
}
var $expandedPanelContents = $('[layout-id="' + expandedPanel + '"] [layout-panel="contents"]');
var combinedHeaderHeight = $('[layout-panel="contents"]').length * opts.panelHeaderHeight;
var searchHeight = $('.sidebar .search').first().height();
var expanderHeight = $('[layout-sidebar-expander]').height();
var expandedPanelHeight = sidebarHeight - (combinedHeaderHeight + expanderHeight + searchHeight);
$('[layout-panel="contents"]').hide();
$('[layout-panel="contents"]').css({"height": "1px"});
$expandedPanelContents.show();
$expandedPanelContents.animate({"height": expandedPanelHeight + "px"}, opts.animationDuration);
}
function layoutHeader(screenWidth, screenHeight) {
var width = screenWidth - 2 * opts.gutter;
var height = opts.headerHeight - opts.gutter;
var top = opts.gutter;
var left = opts.gutter;
var css = {
width: width + "px",
height: height + "px",
top: top + "px",
left: left + "px"
};
$('[layout="header"]').css(css);
}
function layoutNotify(screenWidth, screenHeight) {
var $notify = $('[layout="notify"]');
var nHeight = $notify.height();
var notifyStyle = {
bottom: '0px',
position: 'fixed'
};
$notify.css(notifyStyle);
}
function layoutFooter(screenWidth, screenHeight) {
if (!opts.layoutFooter) {
return;
}
var $footer = $('#footer');
$footer.show();
var nHeight = $footer.height();
var footerStyle = {
top: (screenHeight - 80) + 'px'
};
var width = screenWidth - (2 * opts.gutter + 2 * opts.screenMargin);
var left = -1 * width - 100;
$footer.animate({ "left": opts.gutter, "width": width, "top": (screenHeight - 78) + "px"}, opts.animationDuration);
}
function requiredLayoutStyles() {
var layoutStyle = {
position: 'absolute',
margin: '0px',
padding: '0px'
};
$('[layout]').css(layoutStyle);
// JW: Setting z-index of notify to 1001, so it will appear above the dialog overlay.
// This allows dialogs to use the notification.
$('[layout="notify"]').css({"z-index": "1001", "padding": "20px"});
$('[layout="panel"]').css({position: 'relative'});
$('[layout-panel="expanded"] [layout-panel="header"]').css({
margin: "0px",
padding: "0px",
height: opts.panelHeaderHeight + "px"
});
$('[layout-grid]').css({
position: "relative"
});
$('[layout-grid]').children().css({
position: "absolute"
});
}
function requiredStyles() {
var bodyStyle = {
margin: '0px',
padding: '0px',
overflow: 'hidden'
};
if (opts.allowBodyOverflow) {
delete bodyStyle.overflow;
}
$('body').css(bodyStyle);
requiredLayoutStyles();
var curtainStyle = {
position: "absolute",
margin: '0px',
padding: '0px',
overflow: 'hidden',
zIndex: 100
};
$('.curtain').css(curtainStyle);
}
function hideAll() {
$('[layout]').hide();
$('[layout="header"]').show();
}
function showSidebar() {
sidebarVisible = true;
layout();
}
function hideSidebar() {
sidebarVisible = false;
layout();
}
function toggleSidebar() {
if (sidebarVisible) {
hideSidebar();
} else {
showSidebar();
}
}
function hideDialogs() {
// TODO - may need dialogEvents here for specific dialogs.
$('[layout="dialog"]').hide();
$('.dialog-overlay').hide();
}
function tabClicked(evt) {
evt.preventDefault();
var destination = $(evt.currentTarget).attr('tab-target');
$('[tab-target]').removeClass('selected');
$(evt.currentTarget).addClass('selected');
$('.tab').hide();
$('[tab-id="' + destination + '"]').show();
}
function linkClicked(evt) {
evt.preventDefault();
var $currentTarget = $(evt.currentTarget);
// allow links to be disabled
if ($currentTarget.hasClass("disabled")) {
return;
}
var destination = $(evt.currentTarget).attr('layout-link');
var destinationType = $('[layout-id="' + destination + '"]').attr("layout");
if (destinationType === "screen") {
context.location = '/client#/' + destination;
} else if (destinationType === "dialog") {
showDialog(destination);
}
}
function close(evt) {
var $target = $(evt.currentTarget).closest('[layout]');
var layoutId = $target.attr('layout-id');
var isDialog = ($target.attr('layout') === 'dialog');
if (isDialog) {
closeDialog(layoutId);
} else {
$target.hide();
}
return false;
}
function closeDialog(dialog) {
logger.debug("closing dialog: " + dialog);
var $dialog = $('[layout-id="' + dialog + '"]');
dialogEvent(dialog, 'beforeHide');
var $overlay = $('.dialog-overlay');
unstackDialogs($overlay);
$dialog.hide();
dialogEvent(dialog, 'afterHide');
}
function screenEvent(screen, evtName, data) {
if (screen && screen in screenBindings) {
if (evtName in screenBindings[screen]) {
return screenBindings[screen][evtName].call(me, data);
}
}
}
function dialogEvent(dialog, evtName, data) {
if (dialog && dialog in dialogBindings) {
if (evtName in dialogBindings[dialog]) {
var result = dialogBindings[dialog][evtName].call(me, data);
if (result === false) {
return false;
}
}
}
return true;
}
function onHashChange(e, postFunction) {
if(currentHash == context.location.hash) { return }
if(resettingHash) {
resettingHash = false;
e.preventDefault();
return false;
}
try {
var location = context.RouteMap.parse(context.location.hash);
}
catch (e) {
// this is nowhere in the rich client; just let it go through
return postFunction(e);
}
var screen = location.page.substring(1); // remove leading slash
var accepted = screenEvent(currentScreen, 'beforeLeave', {screen:screen, hash: context.location.hash});
if(accepted === false) {
logger.debug("navigation to " + context.location.hash + " rejected by " + currentScreen);
//resettingHash = true;
// reset the hash to where it just was
context.location.hash = currentHash;
}
else {
// not rejected by the screen; let it go
return postFunction(e);
}
}
function changeToScreen(screen, data) {
if (screen === currentScreen) {
return;
}
changeScreen(screen, data);
}
function changeScreen(screen, data) {
previousScreen = currentScreen;
currentScreen = screen;
currentHash = context.location.hash;
var accepted = screenEvent(previousScreen, 'beforeHide', data);
if(accepted === false) return;
logger.debug("Changing screen to " + currentScreen);
screenEvent(currentScreen, 'beforeShow', data);
// For now -- it seems we want it open always.
// TODO - support user preference here? Remember how they left it?
sidebarVisible = true;
/*
var openSidebarScreens = [
'home', 'session', 'createSession',
'findSession', 'searchResults'
];
$.each(openSidebarScreens, function() {
logger.debug("comparing " + this + " to " + currentScreen);
if (this === currentScreen) {
sidebarVisible = true;
return false;
}
});
*/
layout();
screenEvent(previousScreen, 'afterHide', data);
screenEvent(currentScreen, 'afterShow', data);
// Show any requested dialog
if ("d" in data) {
showDialog(data.d, data);
}
}
/**
* Responsible for keeping N dialogs in correct stacked order,
* also moves the .dialog-overlay such that it hides/obscures all dialogs except the highest one
*/
function stackDialogs($dialog, $overlay) {
// don't push a dialog on the stack that is already on there; remove it from where ever it is currently
// and the rest of the code will make it end up at the top
var layoutId = $dialog.attr('layout-id');
for (var i = openDialogs.length - 1; i >= 0; i--) {
if (openDialogs[i].attr('layout-id') === layoutId) {
openDialogs.splice(i, 1);
}
}
// pull out a topmost one, if present
var topMost = null;
for (var i = openDialogs.length - 1; i >= 0; i--) {
if (openDialogs[i].attr('topmost') === 'true') {
topMost = openDialogs[i];
openDialogs.splice(i, 1);
}
}
openDialogs.push($dialog);
if(topMost) openDialogs.push(topMost);
var zIndex = 1000;
for (var i in openDialogs) {
var $openDialog = openDialogs[i];
$openDialog.css('zIndex', zIndex);
zIndex++;
}
$overlay.css('zIndex', zIndex - 1);
}
function unstackDialogs($overlay) {
if (openDialogs.length > 0) {
openDialogs.pop();
}
var zIndex = 1000 + openDialogs.length;
$overlay.css('zIndex', zIndex - 1);
if (openDialogs.length == 0) {
$overlay.hide();
}
}
function showDialog(dialog, options) {
if (!dialogEvent(dialog, 'beforeShow', options)) {
return;
}
var $overlay = $('.dialog-overlay')
if (opts.sizeOverlayToContent) {
var $body = $('body')
$('.dialog-overlay').css({
width: $body.width() + 'px',
height: $body.height() + 'px'
});
}
$overlay.show();
centerDialog(dialog);
var $dialog = $('[layout-id="' + dialog + '"]');
stackDialogs($dialog, $overlay);
$dialog.show();
dialogEvent(dialog, 'afterShow', options);
}
function centerDialog(dialog) {
var $dialog = $('[layout-id="' + dialog + '"]');
$dialog.css({
left: width / 2 - ($dialog.width() / 2) + "px",
top: height / 2 - ($dialog.height() / 2) + "px"
});
}
function panelHeaderClicked(evt) {
evt.preventDefault();
expandedPanel = $(evt.currentTarget).closest('[layout="panel"]').attr("layout-id");
layout();
return false;
}
function wizardLinkClicked(evt) {
evt.preventDefault();
var targetStepId = $(evt.currentTarget).attr("layout-wizard-link");
setWizardStep(targetStepId);
return false;
}
function startNewFtue() {
var step = 0;
setWizardStep(step);
wizardShowFunctions[step]();
showDialog('ftue');
}
function setWizardStep(targetStepId) {
var selector = '[layout-wizard-step="' + targetStepId + '"]';
var $targetStep = $(selector);
var stepDialogTitle = $targetStep.attr("dialog-title");
if (stepDialogTitle) {
var $myDialog = $targetStep.closest('[layout="dialog"]');
var $myTitle = $('.content-head h1', $myDialog);
$myTitle.html(stepDialogTitle);
}
// Hide all steps:
// Invoke the 'show' function, if present prior to actually showing.
if (context._.contains(context._.keys(wizardShowFunctions), targetStepId)) {
wizardShowFunctions[targetStepId]();
}
$('[layout-wizard-step]').hide();
$targetStep.show();
var ftuePurpose = $targetStep.attr("dialog-purpose");
context.JK.GA.trackFTUECompletion(ftuePurpose, context.JK.detectOS());
}
function trackLocationChange(e) {
context.JK.GA.virtualPageView(location.pathname + location.search + location.hash);
}
function handleDialogState() {
var rawDialogState = $.cookie('dialog_state');
try {
var dialogState = JSON.parse(rawDialogState);
if (!dialogState) {
$.removeCookie('dialog_state');
return;
}
}
catch (e) {
$.removeCookie('dialog_state');
return;
}
var dialogName = dialogState['name'];
if (dialogName) {
setTimeout(function () {
// TODO: we need a 'everything is initialized' event
showDialog(dialogName);
}, 0);
}
$.removeCookie('dialog_state');
}
// on next page load, a dialog of this name will show
function queueDialog(name) {
$.cookie('dialog_state', JSON.stringify({name: name}))
}
function events() {
$(context).resize(function () {
if (resizing) {
context.clearTimeout(resizing);
}
resizing = context.setTimeout(layout, 80);
});
$('body').on('click', '[layout-link]', linkClicked);
$('[layout-action="close"]').on('click', close);
$('[layout-sidebar-expander]').on('click', toggleSidebar);
$('[layout-panel="expanded"] [layout-panel="header"]').on('click', panelHeaderClicked);
$('[layout-wizard-link]').on('click', wizardLinkClicked);
$('[tab-target]').on('click', tabClicked);
$(context).on('hashchange', trackLocationChange);
}
// public functions
this.getOpts = function () {
return opts;
};
// used for concurrent notifications
var notifyQueue = [];
var firstNotification = false;
var notifyDetails;
this.notify = function (message, descriptor) {
var $notify = $('[layout="notify"]');
if (notifyQueue.length === 0) {
firstNotification = true;
setNotificationInfo(message, descriptor, $notify);
}
notifyQueue.push({message: message, descriptor: descriptor});
// JW - speeding up the in/out parts of notify. Extending non-moving time.
$notify.slideDown(250)
.delay(4000)
.slideUp({
duration: 400,
queue: true,
complete: function () {
notifyDetails = notifyQueue.shift();
// shift 1 more time if this is first notification being displayed
if (firstNotification) {
notifyDetails = notifyQueue.shift();
firstNotification = false;
}
if (notifyDetails !== undefined) {
setNotificationInfo(notifyDetails.message, notifyDetails.descriptor, $notify);
}
notifyDetails = {};
}
});
};
function setNotificationInfo(message, descriptor, notificationSelector) {
var $notify = $('[layout="notify"]');
$('h2', $notify).text(message.title);
$('p', $notify).empty();
if (message.text instanceof jQuery) {
$('p', $notify).append(message.text)
}
else {
$('p', $notify).html(message.text);
}
if (message.icon_url) {
$('#avatar', $notify).attr('src', message.icon_url);
$('#notify-avatar', $notify).show();
}
else {
$('#notify-avatar', $notify).hide();
}
if (message.detail) {
$('div.detail', $notify).html(message.detail).show();
}
else {
$('div.detail', $notify).hide();
}
$('#ok-button', $notify).unbind('click');
$('#cancel-button', $notify).unbind('click');
if (descriptor) {
if (descriptor.ok_text) {
$('#ok-button', $notify).html(descriptor.ok_text);
}
else {
$('#ok-button', $notify).html("OKAY");
}
$('#ok-button', $notify).click(function () {
if (descriptor.ok_callback !== undefined) {
if (descriptor.ok_callback_args) {
descriptor.ok_callback(descriptor.ok_callback_args);
return false;
}
else {
descriptor.ok_callback();
return false;
}
}
else {
notificationSelector.hide();
}
});
if (descriptor.cancel_text) {
$('#cancel-button', $notify).html(descriptor.cancel_text);
}
else {
if (descriptor.no_cancel) {
$('#cancel-button', $notify).hide();
}
else {
$('#cancel-button', $notify).html("CANCEL");
}
}
$('#cancel-button', $notify).click(function () {
if (descriptor.cancel_callback !== undefined) {
if (descriptor.cancel_callback_args) {
descriptor.cancel_callback(descriptor.cancel_callback_args);
return false;
}
else {
descriptor.cancel_callback();
return false;
}
}
else {
notificationSelector.hide();
}
});
}
// by default OKAY and CANCEL should just hide the notification
else {
$('#ok-button', $notify).html("OKAY");
$('#ok-button', $notify).click(function () {
notificationSelector.hide();
});
$('#cancel-button', $notify).html("CANCEL");
$('#cancel-button', $notify).click(function () {
notificationSelector.hide();
});
}
}
this.setWizardStep = setWizardStep;
this.startNewFtue = startNewFtue;
this.changeToScreen = function (screen, data) {
changeToScreen(screen, data);
};
this.onHashChange = function(e, postFunction) {
return onHashChange(e, postFunction);
}
this.showDialog = function (dialog, options) {
showDialog(dialog, options);
};
this.close = function (evt) {
close(evt);
};
this.closeDialog = closeDialog;
this.handleDialogState = handleDialogState;
this.queueDialog = queueDialog;
/**
* Given information on a grid, and a given card's grid settings, use the
* margin options and return a list of [top, left, width, height]
* for the cell.
*/
this.getCardLayout = function (gridWidth, gridHeight, gridRows, gridCols, row, col, rowspan, colspan) {
var _gridWidth = gridWidth + 3 * opts.gridPadding;
var _gridHeight = gridHeight + 3 * opts.gridPadding;
var cellWidth, cellHeight, top, left, width, height;
cellWidth = Math.floor((_gridWidth - 2 * opts.gridOuterMargin) / gridCols);
cellHeight = Math.floor((_gridHeight - 2 * opts.gridOuterMargin) / gridRows);
width = colspan * cellWidth - 2 * opts.gridPadding;
height = rowspan * cellHeight - 2 * opts.gridPadding;
top = row * cellHeight;// + opts.gridOuterMargin; // + opts.gridPadding;
left = col * cellWidth;// + opts.gridOuterMargin; // + opts.gridPadding;
return {
top: top,
left: left,
width: width,
height: height
};
};
this.bindScreen = function (screen, handler) {
screenBindings[screen] = handler;
};
this.bindDialog = function (dialog, handler) {
dialogBindings[dialog] = handler;
};
this.registerWizardStepFunction = function (stepId, showFunction) {
wizardShowFunctions[stepId] = showFunction;
};
this.initialize = function (inOpts) {
me = this;
opts = $.extend(opts, inOpts);
setup();
events();
};
return this;
};
}(window, jQuery));