responsive_preview-8.x-1.0/js/responsive-preview.js
js/responsive-preview.js
/**
* @file
* Provides a component that previews the page in various device dimensions.
*/
(function ($, Backbone, Drupal, drupalSettings, Popper, undefined) {
"use strict";
var strings = {
close: Drupal.t('Close'),
orientation: Drupal.t('Change orientation'),
portrait: Drupal.t('Portrait'),
landscape: Drupal.t('Landscape')
};
var options = $.extend({
gutter: 60,
// The width of the device border around the iframe. This value is critical
// to determine the size and placement of the preview iframe container,
// therefore it must be defined here instead of in the CSS file.
bleed: 30
}, drupalSettings.responsivePreview);
/**
* Attaches behaviors to the toolbar tab and preview containers.
*/
Drupal.behaviors.responsivePreview = {
attach: function (context) {
// jQuery.once() returns a jQuery set. It will be empty if no unprocessed
// elements are found. window and window.parent are equivalent unless the
// Drupal page is itself wrapped in an iframe.
var $body = $(once('responsive-preview', window.parent.document.body));
if ($body.length) {
// If this window is itself in an iframe it must be marked as processed.
// Its parent window will have been processed above.
// When attach() is called again for the preview iframe, it will check
// its parent window and find it has been processed. In most cases, the
// following code will have no effect.
$(once('responsive-preview', window.document.body));
var envModel = Drupal.responsivePreview.models.envModel = new Drupal.responsivePreview.EnvironmentModel({
dir: document.documentElement.getAttribute('dir')
});
var tabModel = Drupal.responsivePreview.models.tabModel = new Drupal.responsivePreview.TabStateModel();
var previewModel = Drupal.responsivePreview.models.previewModel = new Drupal.responsivePreview.PreviewStateModel();
// Manages the PreviewView.
Drupal.responsivePreview.views.appView = new Drupal.responsivePreview.AppView({
// The previewView model.
model: previewModel,
envModel: envModel,
// Gutter size around preview frame.
gutter: options.gutter,
// Preview device frame width.
bleed: options.bleed,
strings: strings
});
// The toolbar tab view.
var $tab = $(once('responsive-preview', '#responsive-preview-toolbar-tab'));
if ($tab.length > 0) {
Drupal.responsivePreview.views.tabView = new Drupal.responsivePreview.TabView({
el: $tab.get(),
model: previewModel,
tabModel: tabModel,
envModel: envModel,
// Gutter size around preview frame.
gutter: options.gutter,
// Preview device frame width.
bleed: options.bleed
});
}
// The control block view.
var $block = $(once('responsive-preview', '#block-responsivepreviewcontrols'));
if ($block.length > 0) {
Drupal.responsivePreview.views.blockView = new Drupal.responsivePreview.BlockView({
el: $block.get(),
model: previewModel,
envModel: envModel,
// Gutter size around preview frame.
gutter: options.gutter,
// Preview device frame width.
bleed: options.bleed
});
}
// Keyboard controls view.
Drupal.responsivePreview.views.keyboardView = new Drupal.responsivePreview.KeyboardView({
el: $block.get(),
model: previewModel
});
/**
* Sets the viewport width and height dimensions on the envModel.
*/
var setViewportDimensions = function () {
envModel.set({
'viewportWidth': document.documentElement.clientWidth,
'viewportHeight': document.documentElement.clientHeight
});
};
$(window)
// Update the viewport width whenever it is resized, but max 4 times/s.
.on('resize.responsivepreview', Drupal.debounce(setViewportDimensions, 250));
$(document)
// Respond to viewport offsetting elements like the Toolbar.
.on('drupalViewportOffsetChange.responsivepreview', function (event, offsets) {
envModel.set('offsets', offsets);
})
.on('keyup.responsivepreview', function (event) {
// Close the preview if the Esc key is pressed.
if (event.keyCode === 27) {
previewModel.set('isActive', false);
}
})
// Close the preview if the overlay is opened.
.on('drupalOverlayOpen.responsivepreview', function () {
previewModel.set('isActive', false);
});
// Allow other scripts to respond to responsive preview mode changes.
previewModel.listenTo(previewModel, 'change:isActive', function (model, isActive) {
tabModel.set('isActive', isActive);
$(document).trigger((isActive) ? 'drupalResponsivePreviewStarted' : 'drupalResponsivePreviewStopped');
});
// Initialization: set the current viewport width.
setViewportDimensions();
}
// The main window is equivalent to window.parent and window.self. Inside,
// an iframe, these objects are not equivalent. If the parent window is
// itself in an iframe, check that the parent window has been processed.
// If it has been, this invocation of attach() is being called on the
// preview iframe, not its parent.
if (window.parent !== window.self) {
// Test for empty object (seems to happen occasionally).
if(!window.self.hasOwnProperty('document')) {
return;
}
var $frameBody = $(window.self.document.body);
if ($frameBody.length > 0) {
$frameBody.addClass('responsive-preview-frame');
// Call Drupal.displace in the next process frame to relayout the page
// in the iframe. This will ensure that no gaps in the presentation
// exist from elements that are hidden, such as the toolbar.
var win = window;
window.setTimeout(function () {
win.Drupal.displace();
}, 0);
}
}
},
detach: function (context, settings, trigger) {
/**
* Loops through object properties; applies a callback function.
*/
function looper(obj, iterator) {
for (var prop in obj) {
if (obj.hasOwnProperty(prop)) {
iterator.call(null, prop, obj[prop]);
}
}
}
var app = Drupal.responsivePreview.views.appView || null;
// Detach only if the app view is unloading.
if (app && context === app && trigger === 'unload') {
// Remove listeners on the window and document.
$(window).add(document).off('.responsivepreview');
// Remove and delete the view references.
looper(Drupal.responsivePreview.views, function (label, view) {
view.remove();
Drupal.responsivePreview.views[label] = undefined;
});
// Reset models, remove listeners and delete the model references.
looper(Drupal.responsivePreview.models, function (label, model) {
model.set(model.defaults);
model.stopListening();
Drupal.responsivePreview.models[label] = undefined;
});
}
}
};
Drupal.responsivePreview = Drupal.responsivePreview || {
// Storage for view instances.
views: {},
// Storage for model instances.
models: {},
/**
* Backbone Model for the environment in which the Responsive Preview operates.
*/
EnvironmentModel: Backbone.Model.extend({
defaults: {
// The viewport width, within which the preview will have to fit.
viewportWidth: null,
// The viewport height, within which the preview will have to fit.
viewportHeight: null,
// Text direction of the document, affects some positioning.
dir: 'ltr',
// Viewport offset values.
offsets: {
top: 0,
right: 0,
bottom: 0,
left: 0
}
}
}),
/**
* Backbone Model for the Responsive Preview toolbar tab state.
*/
TabStateModel: Backbone.Model.extend({
defaults: {
// The state of toolbar list of available device previews.
isDeviceListOpen: false
}
}),
/**
* Backbone Model for the Responsive Preview preview state.
*/
PreviewStateModel: Backbone.Model.extend({
defaults: {
// The state of the preview.
isActive: false,
// Indicates whether the preview iframe has been built.
isBuilt: false,
// Indicates whether the device is portrait (false) or landscape (true).
isRotated: false,
// Indicates of the device details are visible in the preview frame.
isDetailsExpanded: false,
// The number of devices that fit the current viewport (i.e. previewable).
fittingDeviceCount: 0,
// Currently selected device link.
activeDevice: null,
// Dimensions of the currently selected device to preview.
dimensions: {
// The width of the device to preview.
width: null,
// The height of the device to preview.
height: null,
// The dots per pixel of the device to preview.
dppx: null
}
},
/**
* {@inheritdoc}
*/
initialize: function () {
this.listenTo(this, 'change:isActive', this.reset);
},
/**
* Puts the model back into a ready state where no device is active.
*
* @param Backbone.Model model
* This model.
* @param Boolean isActive
* Whether the responsive preview is currently active.
*/
reset: function (model, isActive) {
// Reset the model when it is deactivated.
if (!isActive) {
// Process this model change after any views have had the chance to
// react to the change of isActive.
var that = this;
window.setTimeout(function () {
that.set({
isRotated: false,
activeDevice: null,
dimensions: {
width: null,
height: null,
dppx: null
}
}, {silent: true});
}, 0);
}
}
}),
/**
* Manages the PreviewView.
*/
AppView: Backbone.View.extend({
/**
* {@inheritdoc}
*/
initialize: function (options) {
this.envModel = options.envModel;
this.gutter = options.gutter;
this.bleed = options.bleed;
this.strings = options.strings;
// Listen to changes on the previewModel.
this.listenTo(this.model, 'change:isActive', this.render);
},
/**
* {@inheritdoc}
*/
render: function (previewModel, isActive, options) {
// The preview container view.
if (isActive && !Drupal.responsivePreview.views.previewView) {
// Holds the Backbone View of the preview. This view is created and destroyed
// when the preview is enabled or disabled respectively.
Drupal.responsivePreview.views.previewView = new Drupal.responsivePreview.PreviewView({
el: Drupal.theme('responsivePreviewContainer'),
// The previewView model.
model: this.model,
envModel: this.envModel,
// Gutter size around preview frame.
gutter: this.gutter,
// Preview device frame width.
bleed: this.bleed,
strings: this.strings
});
// Remove the inlined opacity style so that the CSS opacity transition
// will fade in the preview view.
window.setTimeout(function () {
Drupal.responsivePreview.views.previewView.el.style.opacity = null;
}, 0);
}
else if (!isActive && Drupal.responsivePreview.views.previewView) {
// The transitionEnd event is still heavily vendor-prefixed.
var transitionEnd = "transitionEnd.responsivepreview webkitTransitionEnd.responsivepreview transitionend.responsivepreview msTransitionEnd.responsivepreview oTransitionEnd.responsivepreview";
// When the fade transition is complete, remove the view.
Drupal.responsivePreview.views.previewView.$el.on(transitionEnd, function (event) {
Drupal.responsivePreview.views.previewView.remove();
delete Drupal.responsivePreview.views.previewView;
});
// Fade out the preview.
Drupal.responsivePreview.views.previewView.el.style.opacity = 0;
}
}
}),
/**
* Handles responsive preview toolbar tab interactions.
*/
TabView: Backbone.View.extend({
events: {
'click .responsive-preview-trigger': 'toggleDeviceList',
'mouseleave': 'toggleDeviceList'
},
/**
* {@inheritdoc}
*/
initialize: function (options) {
this.gutter = options.gutter;
this.bleed = options.bleed;
this.tabModel = options.tabModel;
this.envModel = options.envModel;
var handler;
// Curry the 'this' object in order to pass it as an argument to the
// selectDevice function.
handler = selectDevice.bind(null, this);
this.$el.on('click.responsivepreview', '.responsive-preview-device', handler);
handler = openPreview.bind(null, this);
this.$el.on('open-preview', '.responsive-preview-device', handler);
this.listenTo(this.model, 'change:activeDevice', this.render);
this.listenTo(this.model, 'change:isActive', this.render);
this.listenTo(this.tabModel, 'change:isDeviceListOpen', this.render);
// Curry the 'this' object in order to pass it as an argument to the
// updateDeviceList function.
handler = updateDeviceList.bind(null, this);
this.listenTo(this.envModel, 'change:viewportWidth', handler);
this.listenTo(this.envModel, 'change:viewportWidth', this.correctDeviceListEdgeCollision);
},
/**
* {@inheritdoc}
*/
render: function () {
var name = this.model.get('activeDevice');
var isActive = this.model.get('isActive');
var isDeviceListOpen = this.tabModel.get('isDeviceListOpen');
this.$el
// Render the visibility of the toolbar tab.
.toggle(this.model.get('fittingDeviceCount') > 0)
// Toggle the display of the device list.
.toggleClass('open', isDeviceListOpen);
// Render the state of the toolbar tab button.
this.$el
.find('> button')
.toggleClass('active', isActive)
.attr('aria-pressed', isActive);
// Clean the active class from the device list.
this.$el
.find('.responsive-preview-device.active')
.removeClass('active');
this.$el
.find('[data-responsive-preview-name="' + name + '"]')
.toggleClass('active', isActive);
// When the preview is active, a class on the body is necessary to impose
// styling to aid in the display of the preview element.
$('body').toggleClass('responsive-preview-active', isActive);
// The list of devices might render outside the window.
if (isDeviceListOpen) {
this.correctDeviceListEdgeCollision();
}
return this;
},
/**
* Toggles the list of devices available to preview from the toolbar tab.
*
* @param jQuery.Event event
*/
toggleDeviceList: function (event) {
// Force the options list closed on mouseleave.
if (event.type === 'mouseleave') {
this.tabModel.set('isDeviceListOpen', false);
}
else {
this.tabModel.set('isDeviceListOpen', !this.tabModel.get('isDeviceListOpen'));
}
event.preventDefault();
event.stopPropagation();
},
/**
* Model change handler; corrects possible device list window edge collision.
*/
correctDeviceListEdgeCollision: function () {
// The position of the dropdown depends on the language direction.
var dir = this.envModel.get('dir');
var edge = (dir === 'rtl') ? 'start' : 'end';
var referenceElement = this.$el[0];
var popperElement = this.$el.find('.responsive-preview-item-list')[0];
if (typeof Popper.createPopper === 'function') {
Popper.createPopper(referenceElement, popperElement, {
placement: 'top-' + edge
});
} else {
new Popper(referenceElement, popperElement, {
placement: 'top-' + edge,
modifiers: {
computeStyle: {
gpuAcceleration: false
}
},
});
}
}
}),
/**
* Handles responsive preview control block interactions.
*/
BlockView: Backbone.View.extend({
/**
* {@inheritdoc}
*/
initialize: function (options) {
this.gutter = options.gutter;
this.bleed = options.bleed;
this.envModel = options.envModel;
var handler;
// Curry the 'this' object in order to pass it as an argument to the
// selectDevice function.
handler = selectDevice.bind(null, this);
this.$el.on('click.responsivepreview', '.responsive-preview-device', handler);
handler = openPreview.bind(null, this);
this.$el.on('open-preview', '.responsive-preview-device', handler);
this.listenTo(this.model, 'change:activeDevice', this.render);
// Curry the 'this' object in order to pass it as an argument to the
// updateDeviceList function.
handler = updateDeviceList.bind(null, this);
this.listenTo(this.envModel, 'change:viewportWidth', handler);
},
/**
* {@inheritdoc}
*/
render: function () {
var name = this.model.get('activeDevice');
var isActive = this.model.get('isActive');
this.$el
// Render the visibility of the toolbar block.
.toggle(this.model.get('fittingDeviceCount') > 0)
.find('.responsive-preview-device.active')
.removeClass('active');
this.$el
.find('[data-responsive-preview-name="' + name + '"]')
.addClass('active');
// When the preview is active, a class on the body is necessary to impose
// styling to aid in the display of the preview element.
$('body').toggleClass('responsive-preview-active', isActive);
return this;
}
}),
/**
* Handles keyboard input.
*/
KeyboardView: Backbone.View.extend({
/*
* {@inheritdoc}
*/
initialize: function () {
$(document).on('keyup.responsivepreview', _.bind(this.onKeypress, this));
},
/**
* Responds to esc key press events.
*
* @param jQuery.Event event
*/
onKeypress: function (event) {
if (event.keyCode === 27) {
this.model.set('isActive', false);
}
},
/**
* Removes a listener on the document; calls the standard Backbone remove.
*/
remove: function () {
// Unbind the keyup listener.
$(document).off('keyup.responsivepreview');
// Call the standard remove method on this.
Backbone.View.prototype.remove.call(this);
}
}),
/**
* Handles the responsive preview element interactions.
*/
PreviewView: Backbone.View.extend({
events: {
'click #responsive-preview-close': 'shutdown',
'click #responsive-preview-modal-background': 'shutdown',
'click #responsive-preview-scroll-pane': 'shutdown',
'click #responsive-preview-orientation': 'rotate',
'click #responsive-preview-frame-label': 'revealDetails'
},
/**
* {@inheritdoc}
*/
initialize: function (options) {
this.gutter = options.gutter;
this.bleed = options.bleed;
this.strings = options.strings;
this.envModel = options.envModel;
this.listenTo(this.model, 'change:isRotated change:activeDevice', this.render);
// Recalculate the size of the preview container when the window resizes.
this.listenTo(this.envModel, 'change:viewportWidth change:viewportHeight change:offsets', this.render);
// Build the preview.
this._build();
// Call an initial render.
this.render();
},
/**
* {@inheritdoc}
*/
render: function () {
// Refresh the preview.
this._refresh();
Drupal.displace();
// Render the state of the preview.
var that = this;
// Wrap the call in a setTimeout so that it invokes in the next compute
// cycle, causing the CSS animations to render in the first pass.
window.setTimeout(function () {
that.$el.toggleClass('active', that.model.get('isActive'));
}, 0);
var $container = this.$el.find('#responsive-preview-frame-container');
var $frame = $container.find('#responsive-preview-frame');
$frame.get(0).contentWindow.location = drupalSettings.responsive_preview.url;
return this;
},
/**
* Closes the preview.
*
* @param jQuery.Event event
*/
shutdown: function (event) {
this.model.set('isActive', false);
},
/**
* Removes a listener on the document; calls the standard Backbone remove.
*/
remove: function () {
// Unbind transition listeners.
this.$el.off('.responsivepreview');
// Call the standard remove method on this.
Backbone.View.prototype.remove.call(this);
},
/**
* Responds to rotation button presses.
*
* @param jQuery.Event event
*/
rotate: function (event) {
this.model.set('isRotated', !this.model.get('isRotated'));
event.stopPropagation();
},
/**
* Responds to clicks on the device frame label.
*
* @param jQuery.Event event
*/
revealDetails: function (event) {
this.model.set('isDetailsExpanded', !this.model.get('isDetailsExpanded'));
event.stopPropagation();
},
/**
* Builds the preview iframe.
*/
_build: function () {
var offsets = this.envModel.get('offsets');
var $frameContainer = $(Drupal.theme('responsivePreviewFrameContainer', this.strings))
// The padding around the frame must be known in order to position it
// correctly, so the style property is defined in JavaScript rather than
// CSS.
.css('padding', this.bleed);
// Attach the iframe that will hold the preview.
var $frame = $(Drupal.theme('responsivePreviewFrame'))
// Load the current page URI into the preview iframe.
.on('load.responsivepreview', this._refresh.bind(this))
// Add the frame to the preview container.
.appendTo($frameContainer);
// Wrap the frame container in a pair of divs that will allow for
// scrolling.
$frameContainer = $frameContainer.wrap(Drupal.theme('responsivePreviewScrollContainer'))
.closest('#responsive-preview-scroll-track');
// Apply padding to the scroll pane.
$frameContainer.find('#responsive-preview-scroll-pane')
.css({
'padding-bottom': this.bleed,
'padding-top': this.bleed
});
// Insert the container into the DOM.
this.$el
.css({
'top': offsets.top,
'right': offsets.right,
'left': offsets.left
})
// Apend the frame container.
.append($frameContainer)
// Append the container to the body to initialize the iframe document.
.appendTo('body');
// Load the path into the iframe.
$frame.get(0).contentWindow.location = Drupal.url(drupalSettings.path.currentPath);
// Mark the preview element processed.
this.model.set('isBuilt', true);
},
/**
* Refreshes the preview based on the current state (device & viewport width).
*/
_refresh: function () {
var isRotated = this.model.get('isRotated');
var $deviceLink = $('[data-responsive-preview-name="' + this.model.get('activeDevice') + '"]').eq(0);
var $container = this.$el.find('#responsive-preview-frame-container');
var $frame = $container.find('#responsive-preview-frame');
var $scrollPane = this.$el.find('#responsive-preview-scroll-pane');
var offsets = this.envModel.get('offsets');
// Get the static state.
var edge = (this.envModel.get('dir') === 'rtl') ? 'right' : 'left';
var minGutter = this.gutter;
// Get current (dynamic) state.
var dimensions = this.model.get('dimensions');
var viewportWidth = this.envModel.get('viewportWidth') - (offsets.left + offsets.right);
// Calculate preview width & height. If the preview is rotated, swap width
// and height.
var displayWidth = dimensions[(isRotated) ? 'height' : 'width'];
var displayHeight = dimensions[(isRotated) ? 'width' : 'height'];
var width = displayWidth / dimensions.dppx;
var height = displayHeight / dimensions.dppx;
// Get the container padding and border width for both dimensions.
var bleed = this.bleed;
var widthSpread = width + (bleed * 2);
// Calculate how much space is required to the right and left of the
// preview container in order to center it.
var gutterPercent = (1 - (widthSpread / viewportWidth)) / 2;
var gutter = gutterPercent * viewportWidth;
gutter = (gutter < minGutter) ? minGutter : gutter;
// The device dimension size plus gutters must fit within the viewport
// area for that dimension. The spread is how much room the preview
// needs for that dimension.
width = Math.ceil((viewportWidth - (gutter * 2) < widthSpread) ? viewportWidth - (gutter * 2) - (bleed * 2) : width);
// Updated the state of the rotated icon.
this.$el.find('.responsive-preview-control.responsive-preview-orientation').toggleClass('rotated', isRotated);
// Reposition the preview root.
this.$el.css({
top: offsets.top,
right: offsets.right,
left: offsets.left,
height: document.documentElement.clientHeight - (offsets.top + offsets.bottom)
});
// Position the frame.
var position = {};
// Position depends on text direction.
position[edge] = (gutter > minGutter) ? gutter : minGutter;
$frame
.css({
width: width,
height: height
});
// Position the frame container.
$container.css(position);
// Resize the scroll pane.
var paneHeight = height + (this.bleed * 2);
// If the height of the pane that contains the preview frame is higher
// than the available viewport area, then make it scroll.
if ((paneHeight + $container.position().top) > (document.documentElement.clientHeight - offsets.top - offsets.bottom)) {
$scrollPane
.css({
height: paneHeight
})
// Select the parent container that constrains the overflow.
.parent()
.css({
overflow: 'scroll'
});
}
// If the height of the viewport area is sufficient to display the preview
// frame, remove the scroll styling.
else {
$scrollPane.css({
height: 'auto'
})
// Select the parent container that constrains the overflow.
.parent()
.css({
overflow: 'visible'
});
}
// Scale if not responsive.
this._scaleIfNotResponsive();
// Update the text in the device label.
var $label = $container.find('.responsive-preview-device-label');
$label
.find('.responsive-preview-device-label-text')
.text(Drupal.t('@label', {
'@label': $deviceLink.text()
}));
// The device details are appended to the device label node in a separate
// node so that their presentation can be varied independent of the label.
$label
.find('.responsive-preview-device-label-details')
.text(Drupal.t('@displayWidth@width by @displayHeight, @dpi, @orientation', {
'@displayWidth': displayWidth + 'px',
// If the width of the preview element is not equivalent to the
// configured display width, display the actual width of the preview
// in parentheses.
'@width': (displayWidth !== Math.floor(width * dimensions.dppx)) ? ' (' + (Math.floor(width * dimensions.dppx)) + 'px)' : '',
'@displayHeight': displayHeight + 'px',
'@dpi': dimensions.dppx + 'ppx',
'@orientation': (isRotated) ? this.strings.landscape : this.strings.portrait
}));
// Expose the details if the user has expanded the label.
var isDetailsExpanded = this.model.get('isDetailsExpanded');
$label
.toggleClass('responsive-preview-expanded', isDetailsExpanded)
.find('.responsive-preview-device-label-details')
.toggleClass('visually-hidden', !isDetailsExpanded);
},
/**
* Applies scaling in order to better approximate content display on a device.
*/
_scaleIfNotResponsive: function () {
var scalingCSS = this._calculateScalingCSS();
if (scalingCSS === false) {
return;
}
// Step 0: find DOM nodes we'll need to modify.
var $frame = this.$el.find('#responsive-preview-frame');
var doc = $frame[0].contentDocument || ($frame[0].contentWindow && $frame[0].contentWindow.document);
// No document has been loaded into the iframe yet.
if (!doc) {
return;
}
var $html = $(doc).find('html');
// Step 1: When scaling (as we're about to do), the background (color and
// image) doesn't scale along. Fortunately, we can fix things in case of
// background color.
// @todo: figure out a work-around for background images, or somehow
// document this explicitly.
function isTransparent(color) {
// TRICKY: edge case for Firefox' "transparent" here; this is a
// browser bug: https://bugzilla.mozilla.org/show_bug.cgi?id=635724
return (color === 'rgba(0, 0, 0, 0)' || color === 'transparent');
}
var htmlBgColor = $html.css('background-color');
var bodyBgColor = $html.find('body').css('background-color');
if (!isTransparent(htmlBgColor) || !isTransparent(bodyBgColor)) {
var bgColor = isTransparent(htmlBgColor) ? bodyBgColor : htmlBgColor;
$frame.css('background-color', bgColor);
}
// Step 2: apply scaling.
$html.css(scalingCSS);
},
/**
* Calculates scaling based on device dimensions and <meta name="viewport" />.
*
* Websites that don't indicate via <meta name="viewport" /> that their width
* is identical to the device width will be rendered at a larger size: at the
* layout viewport's default width. This width exceeds the visual viewport on
* the device, and causes it to scale it down.
*
* This function checks whether the underlying web page is responsive, and if
* it's not, then it will calculate a CSS scaling transformation, to closely
* approximate how an actual mobile device would render the web page.
*
* We assume all mobile devices' layout viewport's default width is 980px. It
* is the value used on all iOS and Android >=4.0 devices.
*
* Related reading:
* - http://www.quirksmode.org/mobile/viewports.html
* - http://www.quirksmode.org/mobile/viewports2.html
* - https://developer.apple.com/library/safari/#documentation/AppleApplications/Reference/SafariWebContent/UsingtheViewport/UsingtheViewport.html
* - http://tripleodeon.com/2011/12/first-understand-your-screen/
* - http://tripleodeon.com/wp-content/uploads/2011/12/table.html?r=android40window.innerw&c=980
*/
_calculateScalingCSS: function () {
var isRotated = this.model.get('isRotated');
var settings = this._parseViewportMetaTag();
var defaultLayoutWidth = 980, initialScale = 1;
var layoutViewportWidth, layoutViewportHeight;
var visualViewPortWidth; // The visual viewport width === the preview width.
if (settings.width) {
if (settings.width === 'device-width') {
// Don't scale if the page is marked to be as wide as the device.
return false;
}
else {
layoutViewportWidth = parseInt(settings.width, 10);
}
}
else {
layoutViewportWidth = defaultLayoutWidth;
}
if (settings.height && settings.height !== 'device-height') {
layoutViewportHeight = parseInt(settings.height, 10);
}
if (settings['initial-scale']) {
initialScale = parseFloat(settings['initial-scale'], 10);
if (initialScale < 1) {
layoutViewportWidth = defaultLayoutWidth;
}
}
// Calculate the scale, prevent excesses (ensure the (0.25, 1) range).
var dimensions = this.model.get('dimensions');
// If the preview is rotated, width and height are swapped.
visualViewPortWidth = dimensions[(isRotated) ? 'height' : 'width'] / dimensions.dppx;
var scale = initialScale * (100 / layoutViewportWidth) * (visualViewPortWidth / 100);
scale = Math.min(scale, 1);
scale = Math.max(scale, 0.25);
var transform = "scale(" + scale + ")";
var xOrigin = (this.envModel.get('dir') === 'rtl') ? layoutViewportWidth : '0';
var origin = xOrigin + "px 0px";
return {
'min-width': layoutViewportWidth + 'px',
'min-height': layoutViewportHeight + 'px',
'-webkit-transform': transform,
'-ms-transform': transform,
'transform': transform,
'-webkit-transform-origin': origin,
'-ms-transform-origin': origin,
'transform-origin': origin
};
},
/**
* Parses <meta name="viewport" /> tag's "content" attribute, if any.
*
* Parses something like this:
* <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, minimum-scale=1, user-scalable=yes">
* into this:
* {
* width: 'device-width',
* initial-scale: '1',
* maximum-scale: '5',
* minimum-scale: '1',
* user-scalable: 'yes'
* }
*
* @return Object
* Parsed viewport settings, or {}.
*/
_parseViewportMetaTag: function () {
var settings = {};
var $viewportMeta = $(document).find('meta[name=viewport][content]');
if ($viewportMeta.length > 0) {
$viewportMeta
.attr('content')
// Reduce multiple parts of whitespace to a single space.
.replace(/\s+/g, '')
// Split on comma (which separates the different settings).
.split(',')
.map(function (setting) {
setting = setting.split('=');
settings[setting[0]] = setting[1];
});
}
return settings;
}
})
};
/**
* Functions that are common to both the TabView and BlockView.
*/
/**
* Model change handler; hides devices that don't fit the current viewport.
*
* @param Backbone.View view
* The View curried to this handler. This function is used in multiple Views,
* so we bind it as an argument to the handler function in order to avoid
* having to reference it through a 'this' object which will trigger 'Possible
* strict violation' warning messages in JSHint.
*/
function updateDeviceList(view) {
var gutter = view.gutter;
var bleed = view.bleed;
var viewportWidth = view.envModel.get('viewportWidth');
var $devices = view.$el.find('.responsive-preview-device');
var fittingDeviceCount = $devices.length;
// Remove devices whose previews won't fit the current viewport.
$devices.each(function (index, element) {
var $this = $(this);
var width = parseInt($this.data('responsive-preview-width'), 10);
var dppx = parseFloat($this.data('responsive-preview-dppx'), 10);
var previewWidth = width / dppx;
var fits = ((previewWidth + (gutter * 2) + (bleed * 2)) <= viewportWidth);
if (!fits) {
fittingDeviceCount--;
}
// Set the button to disabled if the device doesn't fit in the current
// viewport.
// Toggle between the prop() and removeProp() methods.
$this.prop('disabled', !fits)
.attr('aria-disabled', !fits);
});
// Set the number of devices that fit the current viewport.
view.model.set('fittingDeviceCount', fittingDeviceCount);
}
/**
* Wrapper for openPreview that takes in account if responsive preview is
* triggered on edit form. Available only for node entity type.
*
* @param Backbone.View view
* The View curried to this handler. This function is used in multiple Views,
* so we bind it as an argument to the handler function in order to avoid
* having to reference it through a 'this' object which will trigger 'Possible
* strict violation' warning messages in JSHint.
* @param jQuery.Event event
*/
function selectDevice(view, event) {
var config = drupalSettings.responsive_preview;
if (config && config.ajax_responsive_preview && view.model.get('isActive') === false) {
var $previewTriggerElement = $(config.ajax_responsive_preview);
var deviceId = $(event.target).data('responsive-preview-name');
if ($previewTriggerElement.length) {
$previewTriggerElement.val(deviceId);
$previewTriggerElement.trigger('show-responsive-preview');
}
}
else {
return openPreview(view, event);
}
}
/**
* Updates the model to reflect the properties of the chosen device.
*
* @param Backbone.View view
* The View curried to this handler. This function is used in multiple Views,
* so we bind it as an argument to the handler function in order to avoid
* having to reference it through a 'this' object which will trigger 'Possible
* strict violation' warning messages in JSHint.
* @param jQuery.Event event
*/
function openPreview(view, event) {
var $link = $(event.target);
var name = $link.data('responsive-preview-name');
// If the clicked link is already active, then shut down the preview.
if (view.model.get('activeDevice') === name) {
view.model.set('isActive', false);
return;
}
// Update the device dimensions.
view.model.set({
'activeDevice': name,
'dimensions': {
'width': parseInt($link.data('responsive-preview-width'), 10),
'height': parseInt($link.data('responsive-preview-height'), 10),
'dppx': parseFloat($link.data('responsive-preview-dppx'), 10)
}
});
// Toggle the preview on.
view.model.set('isActive', true);
event.preventDefault();
}
/**
* Registers theme templates with Drupal.theme().
*/
$.extend(Drupal.theme, {
/**
* Theme function for the preview container element.
*
* @return
* The corresponding HTML.
*/
responsivePreviewContainer: function () {
return '<div id="responsive-preview" class="responsive-preview" style="opacity: 0;"><div id="responsive-preview-modal-background" class="responsive-preview-modal-background"></div></div>';
},
/**
* Theme function for the close button for the preview container.
*
* @param Object strings
* A hash of strings to use in the template.
*
* @return
* The corresponding HTML.
*/
responsivePreviewFrameContainer: function (strings) {
return '<div id="responsive-preview-frame-container" class="responsive-preview-frame-container" aria-describedby="responsive-preview-frame-label">' +
'<label id="responsive-preview-frame-label" class="responsive-preview-device-label" for="responsive-preview-frame-container">' +
'<span class="responsive-preview-device-label-text"></span>' +
// The space is necessary to prevent screen readers from pronouncing a
// run-on word between the last word of the label and the first word
// of the details.
'<span> </span>' +
'<span class="responsive-preview-device-label-details visually-hidden"></span></label>' +
'<button id="responsive-preview-close" title="' + strings.close + '" role="button" class="responsive-preview-icon responsive-preview-icon-close responsive-preview-control responsive-preview-close" aria-pressed="false"><span class="visually-hidden">' + strings.close + '</span></button>' +
'<button id="responsive-preview-orientation" title="' + strings.orientation + '" role="button" class="responsive-preview-icon responsive-preview-icon-orientation responsive-preview-control responsive-preview-orientation" aria-pressed="false"><span class="visually-hidden">' + strings.orientation + '</span></button>' +
'</div>';
},
/**
* Theme function for the scrolling wrapper of the preview container.
*
* @return
* The corresponding HTML.
*/
responsivePreviewScrollContainer: function () {
return '<div id="responsive-preview-scroll-track"><div id="responsive-preview-scroll-pane"></div></div>';
},
/**
* Theme function for a responsive preview iframe element.
*
* @return
* The corresponding HTML.
*/
responsivePreviewFrame: function () {
return '<iframe id="responsive-preview-frame" width="100%" height="100%" frameborder="0" scrolling="auto" allowtransparency="true"></iframe>';
}
});
}(jQuery, Backbone, Drupal, drupalSettings, Popper, once));
