silverstripe-framework/admin/javascript/LeftAndMain.Preview.js

851 lines
23 KiB
JavaScript
Raw Normal View History

(function($) {
$.entwine('ss.preview', function($){
/**
* Shows a previewable website state alongside its editable version in backend UI.
2011-12-17 04:06:56 +01:00
*
* Relies on the server responses to indicate if a preview is available for the
* currently loaded admin interface - signified by class ".cms-previewable" being present.
*
* The preview options at the bottom are constructured by grabbing a SilverStripeNavigator
* structure also provided by the backend.
*/
$('.cms-preview').entwine({
/**
* List of SilverStripeNavigator states (SilverStripeNavigatorItem classes) to search for.
* The order is significant - if the state is not available, preview will start searching the list
* from the beginning.
*/
AllowedStates: ['StageLink', 'LiveLink','ArchiveLink'],
/**
* API
* Name of the current preview state - one of the "AllowedStates".
*/
CurrentStateName: null,
/**
* API
* Current size selection.
*/
CurrentSizeName: 'auto',
/**
* Flags whether the preview is available on this CMS section.
*/
IsPreviewEnabled: false,
/**
* Mode in which the preview will be enabled.
*/
DefaultMode: 'split',
Sizes: {
auto: {
width: '100%',
height: '100%'
},
mobile: {
width: '335px', // add 15px for approx desktop scrollbar
height: '568px'
},
mobileLandscape: {
width: '583px', // add 15px for approx desktop scrollbar
height: '320px'
},
tablet: {
width: '783px', // add 15px for approx desktop scrollbar
height: '1024px'
},
tabletLandscape: {
width: '1039px', // add 15px for approx desktop scrollbar
height: '768px'
},
desktop: {
width: '1024px',
height: '800px'
}
},
/**
* API
* Switch the preview to different state.
* stateName can be one of the "AllowedStates".
*
* @param {String}
* @param {Boolean} Set to FALSE to avoid persisting the state
*/
changeState: function(stateName, save) {
var self = this, states = this._getNavigatorStates();
if(save !== false) {
$.each(states, function(index, state) {
self.saveState('state', stateName);
});
}
this.setCurrentStateName(stateName);
this._loadCurrentState();
this.redraw();
return this;
},
/**
* API
* Change the preview mode.
* modeName can be: split, content, preview.
*/
changeMode: function(modeName, save) {
var container = $('.cms-container');
if (modeName == 'split') {
container.entwine('.ss').splitViewMode();
this.setIsPreviewEnabled(true);
this._loadCurrentState();
} else if (modeName == 'content') {
container.entwine('.ss').contentViewMode();
this.setIsPreviewEnabled(false);
// Do not load content as the preview is not visible.
} else if (modeName == 'preview') {
container.entwine('.ss').previewMode();
this.setIsPreviewEnabled(true);
this._loadCurrentState();
} else {
throw 'Invalid mode: ' + modeName;
}
if(save !== false) this.saveState('mode', modeName);
this.redraw();
return this;
},
/**
* API
* Change the preview size.
* sizeName can be: auto, desktop, tablet, mobile.
*/
changeSize: function(sizeName) {
var sizes = this.getSizes();
this.setCurrentSizeName(sizeName);
this.removeClass('auto desktop tablet mobile').addClass(sizeName);
this.find('.preview-device-outer')
.width(sizes[sizeName].width)
.height(sizes[sizeName].height);
this.find('.preview-device-inner')
.width(sizes[sizeName].width);
this.saveState('size', sizeName);
this.redraw();
return this;
},
/**
* API
* Update the visual appearance to match the internal preview state.
*/
redraw: function() {
if(window.debug) console.log('redraw', this.attr('class'), this.get(0));
// Update preview state selector.
var currentStateName = this.getCurrentStateName();
if (currentStateName) {
this.find('.cms-preview-states').changeVisibleState(currentStateName);
}
// Update preview mode selectors.
var layoutOptions = $('.cms-container').entwine('.ss').getLayoutOptions();
if (layoutOptions) {
// There are two mode selectors that we need to keep in sync. Redraw both.
$('.preview-mode-selector').changeVisibleMode(layoutOptions.mode);
}
// Update preview size selector.
var currentSizeName = this.getCurrentSizeName();
if (currentSizeName) {
this.find('.preview-size-selector').changeVisibleSize(this.getCurrentSizeName());
}
return this;
},
/**
* Store the preview options for this page.
*/
saveState : function(name, value) {
Fix localStorage for Windows 8.1 IE11 desktop mode In IE11 windows 8 call to window.localStorage was throwing out an access denied error. Using try and catch manages the issue and allows the script to execute in IE 11 in desktop mode. I think it is a problem with IE11 rather than the way Silverstripe is implementing the preview via an iframe from what I have been reading. http://blogs.msdn.com/b/ieinternals/archive/2009/09/16/bugs-in-ie8-support-for-html5-postmessage-sessionstorage-and-localstorage.aspx. It seems that the way IE11 deals with localStorage is broken in certain cases but I am not 100% certain of the cause yet as I have not been able to find a definitive answer. I only noticed it was a problem when a new client said they couldn't view the admin screen properly in IE11. I took a look in IE11 and I was experiencing the same problem which makes the admin interface layout screw up and the preview doesn't work due the error mentioned in the first post. Instead of the original code I submitted I have amended it and added an additional function to test more robustly to see if localStorage is available and can be accessed properly. It is a copy of the code on a blog post Mathias Bynens has written about detecting if localStorage is available and can be used: https://mathiasbynens.be/notes/localstorage-pattern I have added a console.warn as you suggested if localStorage is not available so that at least you get a warning if localStorage tests fail. I have tested this on Windows 8.1: Firefox, Chrome & Mac: Firefox, Safari, Chrome and it seems to work as expected. On IE11 it displays the admin area correctly now but obviously doesn't save the preview settings between page loads if localStorage is not available.
2015-02-19 22:43:20 +01:00
if(this._supportsLocalStorage()) window.localStorage.setItem('cms-preview-state-' + name, value);
},
/**
* Load previously stored preferences
*/
loadState : function(name) {
Fix localStorage for Windows 8.1 IE11 desktop mode In IE11 windows 8 call to window.localStorage was throwing out an access denied error. Using try and catch manages the issue and allows the script to execute in IE 11 in desktop mode. I think it is a problem with IE11 rather than the way Silverstripe is implementing the preview via an iframe from what I have been reading. http://blogs.msdn.com/b/ieinternals/archive/2009/09/16/bugs-in-ie8-support-for-html5-postmessage-sessionstorage-and-localstorage.aspx. It seems that the way IE11 deals with localStorage is broken in certain cases but I am not 100% certain of the cause yet as I have not been able to find a definitive answer. I only noticed it was a problem when a new client said they couldn't view the admin screen properly in IE11. I took a look in IE11 and I was experiencing the same problem which makes the admin interface layout screw up and the preview doesn't work due the error mentioned in the first post. Instead of the original code I submitted I have amended it and added an additional function to test more robustly to see if localStorage is available and can be accessed properly. It is a copy of the code on a blog post Mathias Bynens has written about detecting if localStorage is available and can be used: https://mathiasbynens.be/notes/localstorage-pattern I have added a console.warn as you suggested if localStorage is not available so that at least you get a warning if localStorage tests fail. I have tested this on Windows 8.1: Firefox, Chrome & Mac: Firefox, Safari, Chrome and it seems to work as expected. On IE11 it displays the admin area correctly now but obviously doesn't save the preview settings between page loads if localStorage is not available.
2015-02-19 22:43:20 +01:00
if(this._supportsLocalStorage()) return window.localStorage.getItem('cms-preview-state-' + name);
},
/**
* Disable the area - it will not appear in the GUI.
* Caveat: the preview will be automatically enabled when ".cms-previewable" class is detected.
*/
disablePreview: function() {
this.setPendingURL(null);
this._loadUrl('about:blank');
this._block();
this.changeMode('content', false);
this.setIsPreviewEnabled(false);
return this;
},
/**
* Enable the area and start updating to reflect the content editing.
*/
enablePreview: function() {
if (!this.getIsPreviewEnabled()) {
this.setIsPreviewEnabled(true);
// Initialise mode.
if ($.browser.msie && $.browser.version.slice(0,3)<=7) {
// We do not support the split mode in IE < 8.
this.changeMode('content');
} else {
this.changeMode(this.getDefaultMode(), false);
}
}
return this;
},
/**
* Return a style element we can use in IE8 to fix fonts (see readystatechange binding in onadd below)
*/
getOrAppendFontFixStyleElement: function() {
var style = $('#FontFixStyleElement');
if (!style.length) {
style = $(
'<style type="text/css" id="FontFixStyleElement" disabled="disabled">'+
':before,:after{content:none !important}'+
'</style>'
).appendTo('head');
}
return style;
},
/**
* Initialise the preview element.
*/
2012-06-14 23:44:22 +02:00
onadd: function() {
var self = this, layoutContainer = this.parent(), iframe = this.find('iframe');
// Create layout and controls
iframe.addClass('center');
iframe.bind('load', function() {
self._adjustIframeForPreview();
// Load edit view for new page, but only if the preview is activated at the moment.
// This avoids e.g. force-redirections of the edit view on RedirectorPage instances.
self._loadCurrentPage();
2012-12-10 17:47:31 +01:00
$(this).removeClass('loading');
});
// If there's any webfonts in the preview, IE8 will start glitching. This fixes that.
if ($.browser.msie && 8 === parseInt($.browser.version, 10)) {
iframe.bind('readystatechange', function(e) {
if(iframe[0].readyState == 'interactive') {
self.getOrAppendFontFixStyleElement().removeAttr('disabled');
setTimeout(function(){ self.getOrAppendFontFixStyleElement().attr('disabled', 'disabled'); }, 0);
}
});
}
// Preview might not be available in all admin interfaces - block/disable when necessary
this.append('<div class="cms-preview-overlay ui-widget-overlay-light"></div>');
this.find('.cms-preview-overlay').hide();
this.disablePreview();
this._super();
},
Fix localStorage for Windows 8.1 IE11 desktop mode In IE11 windows 8 call to window.localStorage was throwing out an access denied error. Using try and catch manages the issue and allows the script to execute in IE 11 in desktop mode. I think it is a problem with IE11 rather than the way Silverstripe is implementing the preview via an iframe from what I have been reading. http://blogs.msdn.com/b/ieinternals/archive/2009/09/16/bugs-in-ie8-support-for-html5-postmessage-sessionstorage-and-localstorage.aspx. It seems that the way IE11 deals with localStorage is broken in certain cases but I am not 100% certain of the cause yet as I have not been able to find a definitive answer. I only noticed it was a problem when a new client said they couldn't view the admin screen properly in IE11. I took a look in IE11 and I was experiencing the same problem which makes the admin interface layout screw up and the preview doesn't work due the error mentioned in the first post. Instead of the original code I submitted I have amended it and added an additional function to test more robustly to see if localStorage is available and can be accessed properly. It is a copy of the code on a blog post Mathias Bynens has written about detecting if localStorage is available and can be used: https://mathiasbynens.be/notes/localstorage-pattern I have added a console.warn as you suggested if localStorage is not available so that at least you get a warning if localStorage tests fail. I have tested this on Windows 8.1: Firefox, Chrome & Mac: Firefox, Safari, Chrome and it seems to work as expected. On IE11 it displays the admin area correctly now but obviously doesn't save the preview settings between page loads if localStorage is not available.
2015-02-19 22:43:20 +01:00
/**
* Detect and use localStorage if available. In IE11 windows 8.1 call to window.localStorage was throwing out an access denied error in some cases which was causing the preview window not to display correctly in the CMS admin area.
*/
_supportsLocalStorage: function() {
var uid = new Date;
var storage;
var result;
try {
(storage = window.localStorage).setItem(uid, uid);
result = storage.getItem(uid) == uid;
storage.removeItem(uid);
return result && storage;
} catch (exception) {
console.warn('localStorge is not available due to current browser / system settings.');
}
},
onenable: function () {
var $viewModeSelector = $('.preview-mode-selector');
$viewModeSelector.removeClass('split-disabled');
$viewModeSelector.find('.disabled-tooltip').hide();
},
ondisable: function () {
var $viewModeSelector = $('.preview-mode-selector');
$viewModeSelector.addClass('split-disabled');
$viewModeSelector.find('.disabled-tooltip').show();
},
/**
* Set the preview to unavailable - could be still visible. This is purely visual.
*/
_block: function() {
this.addClass('blocked');
this.find('.cms-preview-overlay').show();
return this;
},
/**
* Set the preview to available (remove the overlay);
*/
_unblock: function() {
this.removeClass('blocked');
this.find('.cms-preview-overlay').hide();
return this;
},
/**
* Update the preview according to browser and CMS section capabilities.
*/
_initialiseFromContent: function() {
var mode, size;
if (!$('.cms-previewable').length) {
this.disablePreview();
} else {
mode = this.loadState('mode');
size = this.loadState('size');
this._moveNavigator();
if(!mode || mode != 'content') {
this.enablePreview();
this._loadCurrentState();
}
this.redraw();
// now check the cookie to see if we have any preview settings that have been
// retained for this page from the last visit
if(mode) this.changeMode(mode);
if(size) this.changeSize(size);
}
return this;
},
/**
* Update preview whenever any panels are reloaded.
*/
'from .cms-container': {
onafterstatechange: function(e, data) {
// Don't update preview if we're dealing with a custom redirect
if(data.xhr.getResponseHeader('X-ControllerURL')) return;
this._initialiseFromContent();
}
},
/** @var string A URL that should be displayed in this preview panel once it becomes visible */
PendingURL: null,
oncolumnvisibilitychanged: function() {
var url = this.getPendingURL();
if (url && !this.is('.column-hidden')) {
this.setPendingURL(null);
this._loadUrl(url);
this._unblock();
}
},
/**
* Update preview whenever a form is submitted.
* This is an alternative to the LeftAndmMain::loadPanel functionality which we already
* cover in the onafterstatechange handler.
*/
'from .cms-container .cms-edit-form': {
onaftersubmitform: function(){
this._initialiseFromContent();
}
},
/**
* Change the URL of the preview iframe (if its not already displayed).
*/
_loadUrl: function(url) {
this.find('iframe').addClass('loading').attr('src', url);
return this;
},
/**
* Fetch available states from the current SilverStripeNavigator (SilverStripeNavigatorItems).
* Navigator is supplied by the backend and contains all state options for the current object.
*/
_getNavigatorStates: function() {
// Walk through available states and get the URLs.
var urlMap = $.map(this.getAllowedStates(), function(name) {
var stateLink = $('.cms-preview-states .state-name[data-name=' + name + ']');
if(stateLink.length) {
return {
name: name,
url: stateLink.attr('data-link'),
active: stateLink.is(':radio') ? stateLink.is(':checked') : stateLink.is(':selected')
};
} else {
return null;
}
});
return urlMap;
},
/**
* Load current state into the preview (e.g. StageLink or LiveLink).
* We try to reuse the state we have been previously in. Otherwise we fall back
* to the first state available on the "AllowedStates" list.
*
* @returns New state name.
*/
_loadCurrentState: function() {
if (!this.getIsPreviewEnabled()) return this;
var states = this._getNavigatorStates();
var currentStateName = this.getCurrentStateName();
var currentState = null;
// Find current state within currently available states.
if (states) {
currentState = $.grep(states, function(state, index) {
return (
currentStateName === state.name ||
(!currentStateName && state.active)
);
});
}
var url = null;
if (currentState[0]) {
// State is available on the newly loaded content. Get it.
url = currentState[0].url;
} else if (states.length) {
// Fall back to the first available content state.
this.setCurrentStateName(states[0].name);
url = states[0].url;
} else {
// No state available at all.
this.setCurrentStateName(null);
}
// Mark url as a preview url so it can get special treatment
if (url) {
url += ((url.indexOf('?') === -1) ? '?' : '&') + 'CMSPreview=1';
}
// If this preview panel isn't visible at the moment, delay loading the URL until it (maybe) is later
if (this.is('.column-hidden')) {
this.setPendingURL(url);
this._loadUrl('about:blank');
this._block();
}
else {
this.setPendingURL(null);
if (url) {
this._loadUrl(url);
this._unblock();
}
else {
this._block();
}
}
return this;
},
/**
* Move the navigator from the content to the preview bar.
*/
_moveNavigator: function() {
var previewEl = $('.cms-preview .cms-preview-controls');
var navigatorEl = $('.cms-edit-form .cms-navigator');
if (navigatorEl.length && previewEl.length) {
// Navigator is available - install the navigator.
previewEl.html($('.cms-edit-form .cms-navigator').detach());
} else {
// Navigator not available.
this._block();
}
},
/**
* Loads the matching edit form for a page viewed in the preview iframe,
* based on metadata sent along with this document.
*/
_loadCurrentPage: function() {
if (!this.getIsPreviewEnabled()) return;
var doc = this.find('iframe')[0].contentDocument,
containerEl = $('.cms-container');
// Load this page in the admin interface if appropriate
var id = $(doc).find('meta[name=x-page-id]').attr('content');
2011-12-17 04:06:56 +01:00
var editLink = $(doc).find('meta[name=x-cms-edit-link]').attr('content');
var contentPanel = $('.cms-content');
if(id && contentPanel.find(':input[name=ID]').val() != id) {
// Ignore behaviour without history support (as we need ajax loading
2011-12-17 04:06:56 +01:00
// for the new form to load in the background)
if(window.History.enabled)
$('.cms-container').entwine('.ss').loadPanel(editLink);
}
},
/**
* Prepare the iframe content for preview.
*/
_adjustIframeForPreview: function() {
var iframe = this.find('iframe')[0];
if(iframe){
var doc = iframe.contentDocument;
}else{
return;
}
if(!doc) return;
// Open external links in new window to avoid "escaping" the internal page context in the preview
// iframe, which is important to stay in for the CMS logic.
var links = doc.getElementsByTagName('A');
for (var i = 0; i < links.length; i++) {
var href = links[i].getAttribute('href');
if(!href) continue;
if (href.match(/^http:\/\//)) links[i].setAttribute('target', '_blank');
}
// Hide the navigator from the preview iframe and use only the CMS one.
var navi = doc.getElementById('SilverStripeNavigator');
if(navi) navi.style.display = 'none';
var naviMsg = doc.getElementById('SilverStripeNavigatorMessage');
if(naviMsg) naviMsg.style.display = 'none';
// Trigger extensions.
this.trigger('afterIframeAdjustedForPreview', [ doc ]);
}
});
$('.cms-edit-form').entwine({
onadd: function() {
this._super();
$('.cms-preview')._initialiseFromContent();
}
});
/**
* "Preview state" functions.
* -------------------------------------------------------------------
*/
$('.cms-preview-states').entwine({
/**
* Change the appearance of the state selector.
*/
changeVisibleState: function(state) {
this.find('input[data-name="'+state+'"]').prop('checked', true);
2011-05-02 01:43:51 +02:00
}
});
$('.cms-preview-states .state-name').entwine({
/**
* Reacts to the user changing the state of the preview.
*/
onclick: function(e) {
//Add and remove classes to make switch work ok in old IE
this.parent().find('.active').removeClass('active');
this.next('label').addClass('active');
var targetStateName = $(this).attr('data-name');
// Reload preview with the selected state.
$('.cms-preview').changeState(targetStateName);
}
});
/**
* "Preview mode" functions
* -------------------------------------------------------------------
*/
$('.preview-mode-selector').entwine({
/**
* Change the appearance of the mode selector.
*/
changeVisibleMode: function(mode) {
this.find('select')
.val(mode)
.trigger('liszt:updated')
._addIcon();
}
});
$('.preview-mode-selector select').entwine({
/**
* Reacts to the user changing the preview mode.
*/
onchange: function(e) {
this._super(e);
e.preventDefault();
var targetStateName = $(this).val();
$('.cms-preview').changeMode(targetStateName);
}
});
$('.preview-mode-selector .chzn-results li').entwine({
/**
* IE8 doesn't support programatic access to onchange event
* so react on click
*/
onclick:function(e){
if ($.browser.msie) {
e.preventDefault();
var index = this.index();
var targetStateName = this.closest('.preview-mode-selector').find('select option:eq('+index+')').val();
//var targetStateName = $(this).val();
$('.cms-preview').changeMode(targetStateName);
}
}
});
/**
* Adjust the visibility of the preview-mode selector in the CMS part (hidden if preview is visible).
*/
$('.cms-preview.column-hidden').entwine({
onmatch: function() {
$('#preview-mode-dropdown-in-content').show();
// Alert the user as to why the preview is hidden
if ($('.cms-preview .result-selected').hasClass('font-icon-columns')) {
statusMessage(ss.i18n._t(
'LeftAndMain.DISABLESPLITVIEW',
"Screen too small to show site preview in split mode"),
"error");
}
this._super();
},
onunmatch: function() {
$('#preview-mode-dropdown-in-content').hide();
this._super();
}
});
/**
* Initialise the preview-mode selector in the CMS part (could be hidden if preview is visible).
*/
$('#preview-mode-dropdown-in-content').entwine({
onmatch: function() {
if ($('.cms-preview').is('.column-hidden')) {
this.show();
}
else {
this.hide();
}
this._super();
},
onunmatch: function() {
this._super();
}
});
/**
* "Preview size" functions
* -------------------------------------------------------------------
*/
$('.preview-size-selector').entwine({
/**
* Change the appearance of the size selector.
*/
changeVisibleSize: function(size) {
this.find('select')
.val(size)
.trigger('liszt:updated')
._addIcon();
}
});
$('.preview-size-selector select').entwine({
/**
* Trigger change in the preview size.
*/
onchange: function(e) {
e.preventDefault();
var targetSizeName = $(this).val();
$('.cms-preview').changeSize(targetSizeName);
}
});
/**
* "Chosen" plumbing.
* -------------------------------------------------------------------
*/
/*
* Add a class to the chzn select trigger based on the currently
* selected option. Update as this changes
*/
$('.preview-selector select.preview-dropdown').entwine({
'onliszt:showing_dropdown': function() {
this.siblings().find('.chzn-drop').addClass('open')._alignRight();
},
'onliszt:hiding_dropdown': function() {
this.siblings().find('.chzn-drop').removeClass('open')._removeRightAlign();
},
/**
* Trigger additional initial icon update when the control is fully loaded.
* Solves an IE8 timing issue.
*/
'onliszt:ready': function() {
this._super();
this._addIcon();
},
_addIcon: function(){
var selected = this.find(':selected');
var iconClass = selected.attr('data-icon');
var target = this.parent().find('.chzn-container a.chzn-single');
var oldIcon = target.attr('data-icon');
if(typeof oldIcon !== 'undefined'){
target.removeClass(oldIcon);
}
target.addClass(iconClass);
target.attr('data-icon', iconClass);
return this;
}
});
$('.preview-selector .chzn-drop').entwine({
_alignRight: function(){
var that = this;
$(this).hide();
/* Delay so styles applied after chosen applies css
(the line after we find out the dropdown is open)
*/
setTimeout(function(){
$(that).css({left:'auto', right:0});
$(that).show();
}, 100);
},
_removeRightAlign:function(){
$(this).css({right:'auto'});
}
});
/*
* Means of having extra styled data in chzn 'preview-selector' selects
* When chzn ul is ready, grab data-description from original select.
* If it exists, append to option and add description class to list item
*/
/*
Currently buggy (adds dexcription, then re-renders). This may need to
be done inside chosen. Chosen recommends to do this stuff in the css,
but that option is inaccessible and untranslatable
(https://github.com/harvesthq/chosen/issues/399)
$('.preview-selector .chzn-drop ul').entwine({
onmatch: function() {
this.extraData();
this._super();
},
onunmatch: function() {
this._super();
},
extraData: function(){
var that = this;
var options = this.closest('.preview-selector').find('select option');
$.each(options, function(index, option){
var target = $(that).find("li:eq(" + index + ")");
var description = $(option).attr('data-description');
if(description != undefined && !$(target).hasClass('description')){
$(target).append('<span>' + description + '</span>');
$(target).addClass('description');
}
});
}
}); */
$('.preview-mode-selector .chzn-drop li:last-child').entwine({
onmatch: function () {
if ($('.preview-mode-selector').hasClass('split-disabled')) {
this.parent().append('<div class="disabled-tooltip"></div>');
} else {
this.parent().append('<div class="disabled-tooltip" style="display: none;"></div>');
}
}
});
/**
* Recalculate the preview space to allow for horizontal scrollbar and the preview actions panel
*/
$('.preview-scroll').entwine({
/**
* Height of the preview actions panel
*/
ToolbarSize: 53,
_redraw: function() {
var toolbarSize = this.getToolbarSize();
if(window.debug) console.log('redraw', this.attr('class'), this.get(0));
var previewHeight = (this.height() - toolbarSize);
this.height(previewHeight);
},
onmatch: function() {
this._redraw();
this._super();
},
onunmatch: function() {
this._super();
}
// TODO: Need to recalculate on resize of browser
});
/**
* Rotate preview to landscape
*/
$('.preview-device-outer').entwine({
onclick: function () {
this.toggleClass('rotate');
}
});
});
}(jQuery));