/** * File: LeftAndMain.js */ (function($) { $.metadata.setType('html5'); // setup jquery.entwine $.entwine.warningLevel = $.entwine.WARN_LEVEL_BESTPRACTISE; $.entwine('ss', function($) { /** * Position the loading spinner animation below the ss logo */ var positionLoadingSpinner = function() { var offset = 120; // offset from the ss logo var spinner = $('.ss-loading-screen .loading-animation'); var top = ($(window).height() - spinner.height()) / 2; spinner.css('top', top + offset); spinner.show(); } $(window).bind('resize', positionLoadingSpinner).trigger('resize'); // global ajax error handlers $.ajaxSetup({ error: function(xmlhttp, status, error) { var msg = (xmlhttp.getResponseHeader('X-Status')) ? xmlhttp.getResponseHeader('X-Status') : xmlhttp.statusText; statusMessage(msg, 'bad'); } }); /** * Main LeftAndMain interface with some control panel and an edit form. * * Events: * ajaxsubmit - ... * validate - ... * reloadeditform - ... */ $('.cms-container').entwine({ CurrentXHR: null, /** * Constructor: onmatch */ onmatch: function() { var self = this; // Browser detection if($.browser.msie && parseInt($.browser.version, 10) < 7) { $('.ss-loading-screen').append( '

' + 'Your browser is not compatible with the CMS interface. Please use Internet Explorer 7+, Google Chrome 10+ or Mozilla Firefox 3.5+.' + '

' ).css('z-index', $('.ss-loading-screen').css('z-index')+1); $('.loading-animation').remove(); return; } // Initialize layouts this.redraw(); // Monitor window resizes, panel changes and edit form loads for layout changes. // Also triggers redraw through handleStateChange() $(window).resize(function() { self.redraw(); }); $('.cms-panel').live('toggle', function() { self.redraw(); }); $('.cms-edit-form').live('reloadeditform', function(e, data) { // Simulates a redirect on an ajax response - just exchange the URL without re-requesting it if(window.History.enabled) { var url = data.xmlhttp.getResponseHeader('X-ControllerURL'); if(url) window.history.replaceState({}, '', url); } self.redraw() }); // Remove loading screen $('.ss-loading-screen').hide(); $('body').removeClass('loading'); $(window).unbind('resize', positionLoadingSpinner); History.Adapter.bind(window,'statechange',function(){ self.handleStateChange(); }); this._super(); }, redraw: function() { // Move from inner to outer layouts. Some of the elements might not exist. // Not all edit forms are layouted, so qualify by their data value. this.find('.cms-edit-form[data-layout]').redraw(); this.find('.cms-preview').redraw(); // Only redraw the content area if its not the same as the edit form var contentEl = this.find('.cms-content'); if(!contentEl.is('.cms-edit-form')) contentEl.redraw(); this.layout({resize: false}); this.find('.cms-panel-layout').redraw(); // sidebar panels. }, /** * Proxy around History.pushState() which handles non-HTML5 fallbacks, * as well as global change tracking. Change tracking needs to be synchronous rather than event/callback * based because the user needs to be able to abort the action completely. * * See handleStateChange() for more details. * * Parameters: * - {String} url * - {String} title New window title * - {Object} data Any additional data passed through to History.pushState() */ loadPanel: function(url, title, data) { var data = data || {}; var selector = data.selector || '.cms-content' var contentEl = $(selector); // Check change tracking (can't use events as we need a way to cancel the current state change) var trackedEls = contentEl.find(':data(changetracker)').add(contentEl.filter(':data(changetracker)')); if(trackedEls.length) { var abort = false; trackedEls.each(function() { if(!$(this).confirmUnsavedChanges()) abort = true; }); if(abort) return; } if(window.History.enabled) { // Active menu item is set based on X-Controller ajax header, // which matches one class on the menu window.History.pushState(data, title, url); } else { window.location = url; } }, /** * Handles ajax loading of new panels through the window.History object. * To trigger loading, pass a new URL to window.History.pushState(). * Use loadPanel() as a pushState() wrapper as it provides some additional functionality * like global changetracking and user aborts. * * Due to the nature of history management, no callbacks are allowed. * Use the 'beforestatechange' and 'afterstatechange' events instead, * or overwrite the beforeLoad() and afterLoad() methods on the * DOM element you're loading the new content into. * Although you can pass data into pushState(), it shouldn't contain * DOM elements or callback closures. * * The passed URL should allow reconstructing important interface state * without additional parameters, in the following use cases: * - Explicit loading through History.pushState() * - Implicit loading through browser navigation event triggered by the user (forward or back) * - Full window refresh without ajax * For example, a ModelAdmin search event should contain the search terms * as URL parameters, and the result display should automatically appear * if the URL is loaded without ajax. * * Alternatively, you can load new content via $('.cms-content').loadForm(). * In this case, the action won't be recorded in the browser history. */ handleStateChange: function() { var self = this, h = window.History, state = h.getState(); // Don't allow parallel loading to avoid edge cases if(this.getCurrentXHR()) this.getCurrentXHR().abort(); var selector = state.data.selector || '.cms-content', contentEl = $(selector); this.trigger('beforestatechange', { state: state, element: contentEl }); contentEl.addClass('loading'); var xhr = $.ajax({ url: state.url, success: function(data, status, xhr) { // Update title var title = xhr.getResponseHeader('X-Title'); if(title) document.title = title; // Update panels var newContentEl = $(data); if(newContentEl.find('.cms-container').length) { throw 'Content loaded via ajax is not allowed to contain tags matching the ".cms-container" selector to avoid infinite loops'; } // Set loading state and store element state newContentEl.addClass('loading'); var origStyle = contentEl.attr('style'); var layoutClasses = ['east', 'west', 'center', 'north', 'south']; var elemClasses = contentEl.attr('class'); var origLayoutClasses = $.grep( elemClasses.split(' '), function(val) { return ($.inArray(val, layoutClasses) >= 0); } ); newContentEl .removeClass(layoutClasses.join(' ')) .addClass(origLayoutClasses.join(' ')); if(origStyle) newContentEl.attr('style', origStyle) newContentEl.css('visibility', 'hidden'); // Allow injection of inline styles, as they're not allowed in the document body. // Not handling this through jQuery.ondemand to avoid parsing the DOM twice. var styles = newContentEl.find('style').detach(); if(styles.length) $(document).find('head').append(styles); // Replace panel completely (we need to override the "layout" attribute, so can't replace the child instead) contentEl.replaceWith(newContentEl); // Unset loading and restore element state (to avoid breaking existing panel visibility, e.g. with preview expanded) self.redraw(); newContentEl.css('visibility', 'visible'); newContentEl.removeClass('loading'); // Simulates a redirect on an ajax response - just exchange the URL without re-requesting it if(window.History.enabled) { var url = xhr.getResponseHeader('X-ControllerURL'); if(url) window.history.replaceState({}, '', url); } self.trigger('afterstatechange', {data: data, status: status, xhr: xhr, element: newContentEl}); }, error: function(xhr, status, e) { contentEl.removeClass('loading'); } }); this.setCurrentXHR(xhr); } }); /** * Make all buttons "hoverable" with jQuery theming. * Also sets the clicked button on a form submission, making it available through * a new 'clickedButton' property on the form DOM element. */ $('.cms input[type="submit"], .cms button, .cms input[type="reset"]').entwine({ onmatch: function() { this.addClass('ss-ui-button'); this.redraw(); this._super(); } }); /** * Class: a#profile-link * * Link for editing the profile for a logged-in member through a modal dialog. */ $('.cms-container .profile-link').entwine({ DialogPadding: 40, MaxHeight: 800, MaxWidth: 800, MinHeight: 120, MinWidth: 120, /** * Constructor: onmatch */ onmatch: function() { this.bind('click', function(e) { return self._openPopup(); }); var self = this; $('body').append( '
' + '' + '
' ); $('#ss-ui-dialog-iframe').bind('load', function(e) { self._resize(); }); $(window).bind('resize', function() { self._resize(); }); self.redraw(); }, /** * Function: redraw * * Returns: void */ redraw: function() { var self = this; var useCookie = false; var cookieVal = false; if(useCookie && jQuery.cookie && jQuery.cookie('ss-ui-dialog')) { cookieVal = JSON.parse(jQuery.cookie('ss-ui-dialog')); } $("#ss-ui-dialog").dialog(jQuery.extend({ autoOpen: false, bgiframe: true, modal: true, width: self._width(), height: self._height(), position: 'center', resizeStop: function(e, ui) { self._resize(); }, dragStop: function(e, ui) { self._saveState(); }, // TODO i18n title: 'Edit Profile' }, cookieVal)).css('overflow', 'hidden'); }, /** * Function: _popupHeight * * Returns a value > minHeight < max height * Returns: Int */ _height: function() { var marginTop = parseInt($(this).css('margin-top').replace('px', '')); var marginBottom = parseInt($(this).css('margin-bottom').replace('px', '')); var body = $("body").height(); var height = body - (marginTop + marginBottom) - (this.getDialogPadding() * 2); if(height > this.getMaxHeight()) return this.getMaxHeight(); else if(height < this.getMinHeight()) return this.getMinHeight(); return height; }, /** * Function: _popupWidth * * Returns: Int */ _width: function() { var body = $("body").width(); var width = body - (this.getDialogPadding() * 2); if(width > this.getMaxWidth()) return this.getMaxWidth(); else if(width < this.getMinWidth()) return this.getMinWidth(); return width; }, /** * Function: _openPopup */ _openPopup: function(e) { $('#ss-ui-dialog-iframe').attr('src', this.attr('href')); $("#ss-ui-dialog").dialog('open'); return false; }, /** * Function: _resize */ _resize: function() { var iframe = $('#ss-ui-dialog-iframe'); var container = $('#ss-ui-dialog'); container.dialog("option", "width", this._width()); container.dialog("option", "height", this._height()); container.dialog('option', 'position', 'center'); iframe.attr('width', container.innerWidth() - parseFloat(container.css('paddingLeft')) - parseFloat(container.css('paddingRight')) ); iframe.attr('height', container.innerHeight() - parseFloat(container.css('paddingTop')) - parseFloat(container.css('paddingBottom')) ); this._saveState(); }, /** * Function: _saveState */ _saveState: function() { var container = $('#ss-ui-dialog'); // save size in cookie (optional) if(jQuery.cookie && container.width() && container.height()) { jQuery.cookie( 'ss-ui-dialog', JSON.stringify({ width: parseInt(container.width(), 10), height: parseInt(container.height(), 10) }), { expires: 30, path: '/'} ); } } }); /** * Add styling to all contained buttons, and create buttonsets if required. */ $('.cms-container .Actions').entwine({ onmatch: function() { this.find('.ss-ui-button').click(function() { var form = this.form; // forms don't natively store the button they've been triggered with if(form) { form.clickedButton = this; // Reset the clicked button shortly after the onsubmit handlers // have fired on the form setTimeout(function() {form.clickedButton = null;}, 10); } }); this.redraw(); this._super(); }, redraw: function() { // Needs to be in the same execution frame as the buttonset logic below, // to avoid re-adding rounded corners (default button styling) after removing them this.find('.ss-ui-button').button(); // Remove whitespace to avoid gaps with inline elements this.contents().filter(function() { return (this.nodeType == 3 && !/\S/.test(this.nodeValue)); }).remove(); // Emulate jQuery UI buttonsets based on HTML5 data attributes var sets = [], self = this; this.find('.action[buttonset]').each(function() { cl = $(this).attr('buttonset'); if($.inArray(cl, sets) == -1) sets.push(cl); }); $.each(sets, function(i, set) { // Gather buttons in set until no siblings are matched. // This avoids "split" sets where a new button without a buttonset is inserted somewhere in the middle. self.find('.action[buttonset="' + set + '"]:first') .nextUntil('.action[buttonset!="' + set + '"]').andSelf() .removeClass('ui-corner-all').addClass('buttonset') .first().addClass('ui-corner-left').end() .last().addClass('ui-corner-right'); }); } }); /** * Duplicates functionality in DateField.js, but due to using entwine we can match * the DOM element on creation, rather than onclick - which allows us to decorate * the field with a calendar icon */ $('.cms-container .field.date input.text').entwine({ onmatch: function() { var holder = $(this).parents('.field.date:first'), config = holder.metadata({type: 'class'}); if(!config.showcalendar) return; config.showOn = 'button'; if(config.locale && $.datepicker.regional[config.locale]) { config = $.extend(config, $.datepicker.regional[config.locale], {}); } $(this).datepicker(config); // // Unfortunately jQuery UI only allows configuration of icon images, not sprites // this.next('button').button('option', 'icons', {primary : 'ui-icon-calendar'}); this._super(); } }); /** * Styled dropdown select fields via chosen. Allows things like search and optgroup * selection support. Rather than manually adding classes to selects we want * styled, we style everything but the ones we tell it not to. * * For the CMS we also need to tell the parent div that his has a select so * we can fix the height cropping. */ $('.cms-container .field.dropdown').entwine({ onmatch: function() { $(this).find("select:not(.no-chzn)").chosen(); $(this).addClass("has-chzn"); this._super(); } }); $(".cms-panel-layout").entwine({ redraw: function() { this.layout({ resize: false }); } }); }); }(jQuery)); // Backwards compatibility var statusMessage = function(text, type) { jQuery.noticeAdd({text: text, type: type}); }; var errorMessage = function(text) { jQuery.noticeAdd({text: text, type: 'error'}); }; returnFalse = function() { return false; }; /** * Find and enable TinyMCE on all htmleditor fields * Pulled in from old tinymce.template.js */ function nullConverter(url) { return url; };