--- /dev/null
+/**
+ * @file
+ * Defines the behaviors needed for cropper integration.
+ */
+
+(function ($, Drupal) {
+ 'use strict';
+
+ /**
+ * @class Drupal.ImageWidgetCropType
+ *
+ * @param {Drupal.ImageWidgetCrop} instance
+ * The main ImageWidgetCrop instance that created this one.
+ *
+ * @param {HTMLElement|jQuery} element
+ * The wrapper element.
+ */
+ Drupal.ImageWidgetCropType = function (instance, element) {
+
+ /**
+ * The ImageWidgetCrop instance responsible for creating this type.
+ *
+ * @type {Drupal.ImageWidgetCrop}
+ */
+ this.instance = instance;
+
+ /**
+ * The Cropper plugin wrapper element.
+ *
+ * @type {jQuery}
+ */
+ this.$cropperWrapper = $();
+
+ /**
+ * The wrapper element.
+ *
+ * @type {jQuery}
+ */
+ this.$wrapper = $(element);
+
+ /**
+ * The table element, if any.
+ *
+ * @type {jQuery}
+ */
+ this.$table = this.$wrapper.find(this.selectors.table);
+
+ /**
+ * The image element.
+ *
+ * @type {jQuery}
+ */
+ this.$image = this.$wrapper.find(this.selectors.image);
+
+ /**
+ * The reset element.
+ *
+ * @type {jQuery}
+ */
+ this.$reset = this.$wrapper.find(this.selectors.reset);
+
+ /**
+ * @type {Cropper}
+ */
+ this.cropper = null;
+
+ /**
+ * Flag indicating whether this instance is enabled.
+ *
+ * @type {Boolean}
+ */
+ this.enabled = true;
+
+ /**
+ * The hard limit of the crop.
+ *
+ * @type {{height: Number, width: Number, reached: {height: Boolean, width: Boolean}}}
+ */
+ this.hardLimit = {
+ height: null,
+ width: null,
+ reached: {
+ height: false,
+ width: false
+ }
+ };
+
+ /**
+ * The unique identifier for this ImageWidgetCrop type.
+ *
+ * @type {String}
+ */
+ this.id = null;
+
+ /**
+ * Flag indicating whether the instance has been initialized.
+ *
+ * @type {Boolean}
+ */
+ this.initialized = false;
+
+ /**
+ * An object of recorded setInterval instances.
+ *
+ * @type {Object.<Number, jQuery>}
+ */
+ this.intervals = {};
+
+ /**
+ * The delta ratio of image based on its natural dimensions.
+ *
+ * @type {Number}
+ */
+ this.naturalDelta = null;
+
+ /**
+ * The natural height of the image.
+ *
+ * @type {Number}
+ */
+ this.naturalHeight = null;
+
+ /**
+ * The natural width of the image.
+ *
+ * @type {Number}
+ */
+ this.naturalWidth = null;
+
+ /**
+ * The original height of the image.
+ *
+ * @type {Number}
+ */
+ this.originalHeight = 0;
+
+ /**
+ * The original width of the image.
+ *
+ * @type {Number}
+ */
+ this.originalWidth = 0;
+
+ /**
+ * The current Cropper options.
+ *
+ * @type {Cropper.options}
+ */
+ this.options = {};
+
+ /**
+ * Flag indicating whether to show the default crop.
+ *
+ * @type {Boolean}
+ */
+ this.showDefaultCrop = true;
+
+ /**
+ * The soft limit of the crop.
+ *
+ * @type {{height: Number, width: Number, reached: {height: Boolean, width: Boolean}}}
+ */
+ this.softLimit = {
+ height: null,
+ width: null,
+ reached: {
+ height: false,
+ width: false
+ }
+ };
+
+ /**
+ * The numeric representation of a ratio.
+ *
+ * @type {Number}
+ */
+ this.ratio = NaN;
+
+ /**
+ * The value elements.
+ *
+ * @type {Object.<String, jQuery>}
+ */
+ this.values = {
+ applied: this.$wrapper.find(this.selectors.values.applied),
+ height: this.$wrapper.find(this.selectors.values.height),
+ width: this.$wrapper.find(this.selectors.values.width),
+ x: this.$wrapper.find(this.selectors.values.x),
+ y: this.$wrapper.find(this.selectors.values.y)
+ };
+
+ /**
+ * Flag indicating whether the instance is currently visible.
+ *
+ * @type {Boolean}
+ */
+ this.visible = false;
+
+ // Initialize the instance.
+ this.init();
+ };
+
+ /**
+ * The prefix used for all Image Widget Crop data attributes.
+ *
+ * @type {RegExp}
+ */
+ Drupal.ImageWidgetCropType.prototype.dataPrefix = /^drupalIwc/;
+
+ /**
+ * Default options to pass to the Cropper plugin.
+ *
+ * @type {Object}
+ */
+ Drupal.ImageWidgetCropType.prototype.defaultOptions = {
+ autoCropArea: 1,
+ background: false,
+ responsive: false,
+ viewMode: 1,
+ zoomable: false
+ };
+
+ /**
+ * The selectors used to identify elements for this module.
+ *
+ * @type {Object}
+ */
+ Drupal.ImageWidgetCropType.prototype.selectors = {
+ image: '[data-drupal-iwc=image]',
+ reset: '[data-drupal-iwc=reset]',
+ table: '[data-drupal-iwc=table]', // @todo is this even used anymore?
+ values: {
+ applied: '[data-drupal-iwc-value=applied]',
+ height: '[data-drupal-iwc-value=height]',
+ width: '[data-drupal-iwc-value=width]',
+ x: '[data-drupal-iwc-value=x]',
+ y: '[data-drupal-iwc-value=y]'
+ }
+ };
+
+ /**
+ * The "built" event handler for the Cropper plugin.
+ */
+ Drupal.ImageWidgetCropType.prototype.built = function () {
+ this.$cropperWrapper = this.$wrapper.find('.cropper-container');
+ this.updateHardLimits();
+ this.updateSoftLimits();
+ };
+
+ /**
+ * The "cropend" event handler for the Cropper plugin.
+ */
+ Drupal.ImageWidgetCropType.prototype.cropEnd = function () {
+ // Immediately return if there is no cropper instance (for whatever reason).
+ if (!this.cropper) {
+ return;
+ }
+
+ // Retrieve the cropper data.
+ var data = this.cropper.getData();
+
+ // Ensure the applied state is enabled.
+ data.applied = 1;
+
+ // Data returned by Cropper plugin should be multiplied with delta in order
+ // to get the proper crop sizes for the original image.
+ this.setValues(data, this.naturalDelta);
+
+ // Trigger summary updates.
+ this.$wrapper.trigger('summaryUpdated');
+ };
+
+ /**
+ * The "cropmove" event handler for the Cropper plugin.
+ */
+ Drupal.ImageWidgetCropType.prototype.cropMove = function () {
+ this.updateSoftLimits();
+ };
+
+ /**
+ * Destroys this instance.
+ */
+ Drupal.ImageWidgetCropType.prototype.destroy = function () {
+ this.destroyCropper();
+
+ this.$image.off('.iwc');
+ this.$reset.off('.iwc');
+
+ // Clear any intervals that were set.
+ for (var interval in this.intervals) {
+ if (this.intervals.hasOwnProperty(interval)) {
+ clearInterval(interval);
+ delete this.intervals[interval];
+ }
+ }
+ };
+
+ /**
+ * Destroys the Cropper plugin instance.
+ */
+ Drupal.ImageWidgetCropType.prototype.destroyCropper = function () {
+ this.$image.off('.iwc.cropper');
+ if (this.cropper) {
+ this.cropper.destroy();
+ this.cropper = null;
+ }
+ };
+
+ /**
+ * Disables this instance.
+ */
+ Drupal.ImageWidgetCropType.prototype.disable = function () {
+ if (this.cropper) {
+ this.cropper.disable();
+ }
+ this.$table.removeClass('responsive-enabled--opened');
+ };
+
+ /**
+ * Enables this instance.
+ */
+ Drupal.ImageWidgetCropType.prototype.enable = function () {
+ if (this.cropper) {
+ this.cropper.enable();
+ }
+ this.$table.addClass('responsive-enabled--opened');
+ };
+
+ /**
+ * Retrieves a crop value.
+ *
+ * @param {'applied'|'height'|'width'|'x'|'y'} name
+ * The name of the crop value to retrieve.
+ * @param {Number} [delta]
+ * The delta amount to divide value by, if any.
+ *
+ * @return {Number}
+ * The crop value.
+ */
+ Drupal.ImageWidgetCropType.prototype.getValue = function (name, delta) {
+ var value = 0;
+ if (this.values[name] && this.values[name][0]) {
+ value = parseInt(this.values[name][0].value, 10) || 0;
+ }
+ return name !== 'applied' && value && delta ? Math.round(value / delta) : value;
+ };
+
+ /**
+ * Retrieves all crop values.
+ *
+ * @param {Number} [delta]
+ * The delta amount to divide value by, if any.
+ *
+ * @return {{applied: Number, height: Number, width: Number, x: Number, y: Number}}
+ * The crop value key/value pairs.
+ */
+ Drupal.ImageWidgetCropType.prototype.getValues = function (delta) {
+ var values = {};
+ for (var name in this.values) {
+ if (this.values.hasOwnProperty(name)) {
+ values[name] = this.getValue(name, delta);
+ }
+ }
+ return values;
+ };
+
+ /**
+ * Initializes the instance.
+ */
+ Drupal.ImageWidgetCropType.prototype.init = function () {
+ // Immediately return if already initialized.
+ if (this.initialized) {
+ return;
+ }
+
+ // Set the default options.
+ this.options = $.extend({}, this.defaultOptions);
+
+ // Extend this instance with data from the wrapper.
+ var data = this.$wrapper.data();
+ for (var i in data) {
+ if (data.hasOwnProperty(i) && this.dataPrefix.test(i)) {
+ // Remove Drupal + module prefix and lowercase the first letter.
+ var prop = i.replace(this.dataPrefix, '');
+ prop = prop.charAt(0).toLowerCase() + prop.slice(1);
+
+ // Check if data attribute exists on this object.
+ if (prop && this.hasOwnProperty(prop)) {
+ var value = data[i];
+
+ // Parse the ratio value.
+ if (prop === 'ratio') {
+ value = this.parseRatio(value);
+ }
+ this[prop] = typeof value === 'object' ? $.extend(true, {}, this[prop], value) : value;
+ }
+ }
+ }
+
+ // Bind necessary events.
+ this.$image
+ .on('visible.iwc', function () {
+ this.visible = true;
+ this.naturalHeight = parseInt(this.$image.prop('naturalHeight'), 10);
+ this.naturalWidth = parseInt(this.$image.prop('naturalWidth'), 10);
+ // Calculate delta between original and thumbnail images.
+ this.naturalDelta = this.originalHeight && this.naturalHeight ? this.originalHeight / this.naturalHeight : null;
+ }.bind(this))
+ // Only initialize the cropper plugin once.
+ .one('visible.iwc', this.initializeCropper.bind(this))
+ .on('hidden.iwc', function () {
+ this.visible = false;
+ }.bind(this))
+ ;
+
+ this.$reset
+ .on('click.iwc', this.reset.bind(this))
+ ;
+
+ // Star polling visibility of the image that should be able to be cropped.
+ this.pollVisibility(this.$image);
+
+ // Bind the drupalSetSummary callback.
+ this.$wrapper.drupalSetSummary(this.updateSummary.bind(this));
+
+ // Trigger the initial summaryUpdate event.
+ this.$wrapper.trigger('summaryUpdated');
+ };
+
+ /**
+ * Initializes the Cropper plugin.
+ */
+ Drupal.ImageWidgetCropType.prototype.initializeCropper = function () {
+ // Calculate minimal height for cropper container (minimal width is 200).
+ var minDelta = (this.originalWidth / 200);
+ this.options.minContainerHeight = this.originalHeight / minDelta;
+
+ // Only autoCrop if 'Show default crop' is checked.
+ this.options.autoCrop = this.showDefaultCrop;
+
+ // Set aspect ratio.
+ this.options.aspectRatio = this.ratio;
+
+ // Initialize data.
+ var values = this.getValues(this.naturalDelta);
+ this.options.data = this.options.data || {};
+ if (values.applied) {
+ // Remove the "applied" value as it has no meaning in Cropper.
+ delete values.applied;
+
+ // Merge in the values.
+ this.options.data = $.extend(true, this.options.data, values);
+
+ // Enforce autoCrop if there's currently a crop applied.
+ this.options.autoCrop = true;
+ }
+
+ this.options.data.rotate = 0;
+ this.options.data.scaleX = 1;
+ this.options.data.scaleY = 1;
+
+ this.$image
+ .on('built.iwc.cropper', this.built.bind(this))
+ .on('cropend.iwc.cropper', this.cropEnd.bind(this))
+ .on('cropmove.iwc.cropper', this.cropMove.bind(this))
+ .cropper(this.options)
+ ;
+
+ this.cropper = this.$image.data('cropper');
+ this.options = this.cropper.options;
+
+ // If "Show default crop" is checked apply default crop.
+ if (this.showDefaultCrop) {
+ // All data returned by cropper plugin multiple with delta in order to get
+ // proper crop sizes for original image.
+ this.setValue(this.$image.cropper('getData'), this.naturalDelta);
+ this.$wrapper.trigger('summaryUpdated');
+ }
+ };
+
+ /**
+ * Creates a poll that checks visibility of an item.
+ *
+ * @param {HTMLElement|jQuery} element
+ * The element to poll.
+ *
+ * @todo Perhaps replace once vertical tabs have proper events?
+ *
+ * @see https://www.drupal.org/node/2653570
+ */
+ Drupal.ImageWidgetCropType.prototype.pollVisibility = function (element) {
+ var $element = $(element);
+
+ // Immediately return if there's no element.
+ if (!$element[0]) {
+ return;
+ }
+
+ var isElementVisible = function (el) {
+ var rect = el.getBoundingClientRect();
+ var vWidth = window.innerWidth || document.documentElement.clientWidth;
+ var vHeight = window.innerHeight || document.documentElement.clientHeight;
+
+ // Immediately Return false if it's not in the viewport.
+ if (rect.right < 0 || rect.bottom < 0 || rect.left > vWidth || rect.top > vHeight) {
+ return false;
+ }
+
+ // Return true if any of its four corners are visible.
+ var efp = function (x, y) {
+ return document.elementFromPoint(x, y);
+ };
+ return (
+ el.contains(efp(rect.left, rect.top))
+ || el.contains(efp(rect.right, rect.top))
+ || el.contains(efp(rect.right, rect.bottom))
+ || el.contains(efp(rect.left, rect.bottom))
+ );
+ };
+
+ var value = null;
+ var interval = setInterval(function () {
+ var visible = isElementVisible($element[0]);
+ if (value !== visible) {
+ $element.trigger((value = visible) ? 'visible.iwc' : 'hidden.iwc');
+ }
+ }, 250);
+ this.intervals[interval] = $element;
+ };
+
+ /**
+ * Parses a ration value into a numeric one.
+ *
+ * @param {String} ratio
+ * A string representation of the ratio.
+ *
+ * @return {Number.<float>|NaN}
+ * The numeric representation of the ratio.
+ */
+ Drupal.ImageWidgetCropType.prototype.parseRatio = function (ratio) {
+ if (ratio && /:/.test(ratio)) {
+ var parts = ratio.split(':');
+ var num1 = parseInt(parts[0], 10);
+ var num2 = parseInt(parts[1], 10);
+ return num1 / num2;
+ }
+ return parseFloat(ratio);
+ };
+
+ /**
+ * Reset cropping for an element.
+ *
+ * @param {Event} e
+ * The event object.
+ */
+ Drupal.ImageWidgetCropType.prototype.reset = function (e) {
+ if (!this.cropper) {
+ return;
+ }
+
+ if (e instanceof Event || e instanceof $.Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ this.options = $.extend({}, this.cropper.options, this.defaultOptions);
+
+ var delta = null;
+
+ // Retrieve all current values and zero (0) them out.
+ var values = this.getValues();
+ for (var name in values) {
+ if (values.hasOwnProperty(name)) {
+ values[name] = 0;
+ }
+ }
+
+ // If 'Show default crop' is not checked just re-initialize the cropper.
+ if (!this.showDefaultCrop) {
+ this.destroyCropper();
+ this.initializeCropper();
+ }
+ // Reset cropper to the original values.
+ else {
+ this.cropper.reset();
+ this.cropper.options = this.options;
+
+ // Set the delta.
+ delta = this.naturalDelta;
+
+ // Merge in the original cropper values.
+ values = $.extend(values, this.cropper.getData());
+ }
+
+ this.setValues(values, delta);
+ this.$wrapper.trigger('summaryUpdated');
+ };
+
+ /**
+ * The "resize" event handler proxied from the main instance.
+ *
+ * @see Drupal.ImageWidgetCrop.prototype.resize
+ */
+ Drupal.ImageWidgetCropType.prototype.resize = function () {
+ // Immediately return if currently not visible.
+ if (!this.visible) {
+ return;
+ }
+
+ // Get previous data for cropper.
+ var canvasDataOld = this.$image.cropper('getCanvasData');
+ var cropBoxData = this.$image.cropper('getCropBoxData');
+
+ // Re-render cropper.
+ this.$image.cropper('render');
+
+ // Get new data for cropper and calculate resize ratio.
+ var canvasDataNew = this.$image.cropper('getCanvasData');
+ var ratio = 1;
+ if (canvasDataOld.width !== 0) {
+ ratio = canvasDataNew.width / canvasDataOld.width;
+ }
+
+ // Set new data for crop box.
+ $.each(cropBoxData, function (index, value) {
+ cropBoxData[index] = value * ratio;
+ });
+ this.$image.cropper('setCropBoxData', cropBoxData);
+
+ this.updateHardLimits();
+ this.updateSoftLimits();
+ this.$wrapper.trigger('summaryUpdated');
+ };
+
+ /**
+ * Sets a single crop value.
+ *
+ * @param {'applied'|'height'|'width'|'x'|'y'} name
+ * The name of the crop value to set.
+ * @param {Number} value
+ * The value to set.
+ * @param {Number} [delta]
+ * A delta to modify the value with.
+ */
+ Drupal.ImageWidgetCropType.prototype.setValue = function (name, value, delta) {
+ if (!this.values.hasOwnProperty(name) || !this.values[name][0]) {
+ return;
+ }
+ value = value ? parseInt(value, 10) : 0;
+ if (delta && name !== 'applied') {
+ value = Math.round(value * delta);
+ }
+ this.values[name][0].value = value;
+ this.values[name].trigger('change.iwc, input.iwc');
+ };
+
+ /**
+ * Sets multiple crop values.
+ *
+ * @param {{applied: Number, height: Number, width: Number, x: Number, y: Number}} obj
+ * An object of key/value pairs of values to set.
+ * @param {Number} [delta]
+ * A delta to modify the value with.
+ */
+ Drupal.ImageWidgetCropType.prototype.setValues = function (obj, delta) {
+ for (var name in obj) {
+ if (!obj.hasOwnProperty(name)) {
+ continue;
+ }
+ this.setValue(name, obj[name], delta);
+ }
+ };
+
+ /**
+ * Converts horizontal and vertical dimensions to canvas dimensions.
+ *
+ * @param {Number} x - horizontal dimension in image space.
+ * @param {Number} y - vertical dimension in image space.
+ */
+ Drupal.ImageWidgetCropType.prototype.toCanvasDimensions = function (x, y) {
+ var imageData = this.cropper.getImageData();
+ return {
+ width: imageData.width * (x / this.originalWidth),
+ height: imageData.height * (y / this.originalHeight)
+ }
+ };
+
+ /**
+ * Converts horizontal and vertical dimensions to image dimensions.
+ *
+ * @param {Number} x - horizontal dimension in canvas space.
+ * @param {Number} y - vertical dimension in canvas space.
+ */
+ Drupal.ImageWidgetCropType.prototype.toImageDimensions = function (x, y) {
+ var imageData = this.cropper.getImageData();
+ return {
+ width: x * (this.originalWidth / imageData.width),
+ height: y * (this.originalHeight / imageData.height)
+ }
+ };
+
+ /**
+ * Update hard limits.
+ */
+ Drupal.ImageWidgetCropType.prototype.updateHardLimits = function () {
+ // Immediately return if there is no cropper plugin instance or hard limits.
+ if (!this.cropper || !this.hardLimit.width || !this.hardLimit.height) {
+ return;
+ }
+
+ var options = this.cropper.options;
+
+ // Limits works in canvas so we need to convert dimensions.
+ var converted = this.toCanvasDimensions(this.hardLimit.width, this.hardLimit.height);
+ options.minCropBoxWidth = converted.width;
+ options.minCropBoxHeight = converted.height;
+
+ // After updating the options we need to limit crop box.
+ this.cropper.limitCropBox(true, false);
+ };
+
+ /**
+ * Update soft limits.
+ */
+ Drupal.ImageWidgetCropType.prototype.updateSoftLimits = function () {
+ // Immediately return if there is no cropper plugin instance or soft limits.
+ if (!this.cropper || !this.softLimit.width || !this.softLimit.height) {
+ return;
+ }
+
+ // We do comparison in image dimensions so lets convert first.
+ var cropBoxData = this.cropper.getCropBoxData();
+ var converted = this.toImageDimensions(cropBoxData.width, cropBoxData.height);
+
+ var dimensions = ['width', 'height'];
+ for (var i = 0, l = dimensions.length; i < l; i++) {
+ var dimension = dimensions[i];
+ if (converted[dimension] < this.softLimit[dimension]) {
+ if (!this.softLimit.reached[dimension]) {
+ this.softLimit.reached[dimension] = true;
+ }
+ }
+ else if (this.softLimit.reached[dimension]) {
+ this.softLimit.reached[dimension] = false;
+ }
+ this.$cropperWrapper.toggleClass('cropper--' + dimension + '-soft-limit-reached', this.softLimit.reached[dimension]);
+ }
+ this.$wrapper.trigger('summaryUpdated');
+ };
+
+ /**
+ * Updates the summary of the wrapper.
+ */
+ Drupal.ImageWidgetCropType.prototype.updateSummary = function () {
+ var summary = [];
+ if (this.getValue('applied')) {
+ summary.push(Drupal.t('Cropping applied.'));
+ }
+ if (this.softLimit.reached.height || this.softLimit.reached.width) {
+ summary.push(Drupal.t('Soft limit reached.'));
+ }
+ return summary.join('<br>');
+ };
+
+}(jQuery, Drupal));