neos/neos-development-collection

View on GitHub
Neos.Neos/Resources/Public/JavaScript/Content/Inspector/Editors/ImageEditor.js

Summary

Maintainability
F
1 wk
Test Coverage
define(
[
    'emberjs',
    'Library/jquery-with-dependencies',
    'Content/Inspector/Editors/FileUpload',
    'text!./ImageEditor.html',
    'text!./ImageEditorCrop.html',
    'Content/Inspector/Editors/BooleanEditor',
    'Library/spinjs/spin',
    'Content/Inspector/SecondaryInspectorController',
    'Shared/Notification',
    'Shared/Utility',
    'Shared/HttpClient',
    'Shared/I18n'
],
function (Ember, $, FileUpload, template, cropTemplate, BooleanEditor, Spinner, SecondaryInspectorController, Notification, Utility, HttpClient, I18n) {
    /**
     * The Image has to extend from fileUpload; as plupload just breaks with very weird
     * error messages otherwise.
     */
    return FileUpload.extend({

        /****************************************
         * GENERAL SETUP
         ***************************************/
        removeButtonLabel: function () {
            return I18n.translate('Neos.Neos:Main:remove', 'Remove');
        }.property(),
        uploadCancelLabel: function () {
            return I18n.translate('Neos.Neos:Main:cancel', 'Cancel');
        }.property(),
        cropLabel: function () {
            return I18n.translate('Neos.Neos:Main:crop', 'Crop');
        }.property(),
        mediaLabel: function () {
            return I18n.translate('Neos.Neos:Main:media', 'Media');
        }.property(),

        /**
         * Size of the image preview. Public configuration.
         */
        imagePreviewMaximumDimensions: {width: 288, height: 216},

        /**
         * Comma-separated list of allowed file types.
         * Public configuration.
         * TODO: Should probably be generated server-side depending on the configured imagine driver.
         */
        allowedFileTypes: 'jpg,jpeg,png,gif,svg',

        /**
         * Feature flags for this editor. Currently we have cropping, media browser and resize which can be enabled/disabled via NodeTypes editorOptions.
         */
        features: {
            crop: true,
            upload: true,
            mediaBrowser: true,
            resize: false
        },

        template: Ember.Handlebars.compile(template),
        BooleanEditor: BooleanEditor,
        SecondaryInspectorButton: SecondaryInspectorController.SecondaryInspectorButton,

        _defaultUploadPreviewImageSource: $('link[rel="neos-public-resources"]').attr('href') + 'Images/dummy-image.svg',

        /**
         * @var boolean
         * set to true as soon as the image is fully loaded and all image properties
         * are initialized. Is set when selecting an image or uploading a new one.
         */
        _imageFullyLoaded: false,

        /****************************************
         * IMAGE SIZING
         ***************************************/
        /**
         * This is the original object data used to update.
         */
        _object: null,

        /**
         * The URI to the image within the inspector.
         */
        _inspectorImageUri: null,

        /**
         * The URI to the original-sized image used for cropping preview inside the inspector.
         */
        _originalImageUri: null,

        /**
         * size of the original (base) image ("w" + "h")
         */
        _originalImageDimensions: null,

        /**
         * Current image adjustments
         */
        _adjustments: {},

        /**
         * The "preview" image is used for cropping.
         */
        _previewImageUri: null,

        /**
         * This is the size of the image being used for cropping
         * Object "w" + "h"
         */
        _previewImageDimensions: null,

        /**
         * Crop properties, as being used by jCrop editor.
         * ALL COORDINATES are relative to _previewImageDimensions.
         */
        _cropProperties: null,

        /**
         * Object representing the dimensions of the final image.
         *
         * As width and height are always connected through the aspect ratio, we cannot directly change
         * the height. Instead, we need to adjust the width as needed and let the bindings update the height
         * property instead.
         *
         * As a result, the input field for the image height is bound to "computedHeight", and not just
         * to "height".
         */
        _finalImageDimensions: null,

        /**
         * Contains the handler for the AJAX request loading the preview image
         */
        _loadPreviewImageHandler: null,

        /**
         * The url of the image service endpoint used for fetching image metadata
         */
        _imageServiceEndpointUri: null,

        /**
         * The url of the endpoint able to create new ImageVariants
         */
        _createImageVariantEndpointUri: null,

        loadingIndicator: null,

        init: function () {
            var that = this;
            this._super();

            // Create new instance per image editor to avoid side effects
            this._initializeMediaView();

            this.set('_imageServiceEndpointUri', $('link[rel="neos-images"]').attr('href'));
            this.set('_createImageVariantEndpointUri', $('link[rel="neos-imagevariant-create"]').attr('href'));

            this.set('_finalImageDimensions', Ember.Object.extend({
                width: null,
                height: null,

                _updateValueIfWidthAndHeightChange: function () {
                    that._updateValue();
                }.observes('width', 'height'),

                /**
                 * As width and height are always connected through the aspect ratio,
                 * we cannot directly change the height. Instead, we need to adjust the
                 * width as needed and let the bindings update the height property instead.
                 *
                 * As a result, the input field for the image height is bound to
                 * "computedHeight", and not just to "h".
                 */
                computedHeight: function (key, value) {
                    if (arguments.length === 1) {
                        return this.get('height');
                    } else {
                        this.set('width', Math.ceil(that.get('_cropProperties.aspectRatio') * value));
                        return value;
                    }
                }.property('height')
            }).create());

            /**
             * Object representing the parameters for a potential cropping operation
             */
            this.set('_cropProperties', Ember.Object.extend({
                x: null,
                y: null,
                width: null,
                height: null,

                full: function () {
                    return {
                        x: this.get('x'),
                        y: this.get('y'),
                        width: Math.ceil(this.get('width')),
                        height: Math.ceil(this.get('height'))
                    };
                }.property('width', 'height', 'x', 'y'),

                initialized: function () {
                    return this.get('width') !== null && this.get('height') !== null && this.get('x') !== null && this.get('y') !== null;
                }.property('width', 'height', 'x', 'y'),

                aspectRatio: function () {
                    var ratio = 1,
                        width = this.get('width'),
                        height = this.get('height');
                    if (width > 0 && height > 0) {
                        ratio = parseFloat(width) / parseFloat(height);
                    }

                    return ratio;
                }.property('width', 'height')
            }).create());
        },

        /**
         * Logic for the checkbox next to the image width text field
         */
        _imageWidthToggle: function (propertyName, value) {
            if (typeof value === 'boolean') {
                if (value === false) {
                    this.set('_finalImageDimensions.width', null);
                    this.set('_finalImageDimensions.height', null);
                } else {
                    this.set('_finalImageDimensions.width', this.get('_originalImageDimensions.width'));
                    this.set('_finalImageDimensions.height', this.get('_originalImageDimensions.height'));
                }
            }
            if (this.get('_finalImageDimensions.width') > 0) {
                return true;
            }
            this.set('_finalImageDimensions.width', null);
            this.set('_finalImageDimensions.height', null);
            return false;
        }.property('_finalImageDimensions.width'),

        /**
         * Logic for the checkbox next to the image height text field
         */
        _imageHeightToggle: function (propertyName, value) {
            if (typeof value === 'boolean') {
                if (value === false) {
                    this.set('_finalImageDimensions.width', null);
                    this.set('_finalImageDimensions.height', null);
                } else {
                    this.set('_finalImageDimensions.width', this.get('_originalImageDimensions.width'));
                    this.set('_finalImageDimensions.height', this.get('_originalImageDimensions.height'));
                }
            }
            if (this.get('_finalImageDimensions.width') > 0) {
                return true;
            }
            this.set('_finalImageDimensions.width', null);
            this.set('_finalImageDimensions.height', null);
            return false;
        }.property('_finalImageDimensions.height'),

        /**
         * If the aspect ratio changes, adjust the height based on the width and the aspect ratio
         */
        _aspectRatioChanged: function() {
            var finalImageWidth = this.get('_finalImageDimensions.width');
            if (finalImageWidth !== null) {
                this.set('_finalImageDimensions.height', parseInt(finalImageWidth / this.get('_cropProperties.aspectRatio'), 10));
            }
        }.observes('_finalImageDimensions.width', '_cropProperties.aspectRatio'),

        /****************************************
         * INITIALIZATION
         ***************************************/

        /**
         * Lifecycle callback; sets some CSS for the image preview area to sensible defaults,
         * and reads the image if possible
         */
        didInsertElement: function () {
            this._super();

            this.$().find('.neos-inspector-image-thumbnail-inner').css({
                width: this.get('imagePreviewMaximumDimensions.width') + 'px',
                height: this.get('imagePreviewMaximumDimensions.height') + 'px'
            });
            this.$().find('.neos-inspector-image-thumbnail-container').css({
                width: this.get('imagePreviewMaximumDimensions.width') + 'px',
                height: this.get('imagePreviewMaximumDimensions.height') + 'px'
            });
            this._readAndDeserializeValue();
        },

        /**
         * Hide the popover as soon as the image editor looses focus
         */
        willDestroyElement: function () {
            // Hide popover when the focus changes
            if (this.get('_loadPreviewImageHandler')) {
                this.get('_loadPreviewImageHandler').abort();
            }
        },

        /****************************************
         * MEDIA BROWSER
         ***************************************/
        _mediaBrowserView: null,

        _beforeMediaBrowserIsShown: function () {
            var that = this,
                value = null;

            try {
                if (this.get('value')) {
                    value = JSON.parse(this.get('value'));
                }
            } catch (exception) {
                console.log('Invalid JSON value in image editor', this.get('value'));
            }

            window.NeosMediaBrowserCallbacks = {
                _assetIdentifier: value && '__identity' in value ? value.__identity : null,
                _frameLoaded: false,
                _reloadPreviewImage: function() {
                    if (this._assetIdentifier) {
                        var originalPreviewImageResourceUri = that.get('_previewImageUri');

                        that._displayImageLoader();

                        that.set('_loadPreviewImageHandler', HttpClient.getResource(
                            that.get('_imageServiceEndpointUri') + '?image=' + this._assetIdentifier,
                            {dataType: 'json'}
                        ));
                        that.get('_loadPreviewImageHandler').then(function (result) {
                            if (originalPreviewImageResourceUri !== result.previewImageResourceUri) {
                                that.fileUploaded(result);
                            }
                            that._hideImageLoader();
                        });
                    }
                },
                onLoad: function(event, iframe) {
                    this._frameLoaded = true;
                    var notifications = $(iframe).contents().find('#neos-notifications-inline');
                    if (notifications.length > 0) {
                        $('li', notifications).each(function(index, notification) {
                            var title = $(notification).data('title');
                            Notification[$(notification).data('type')](title ? title : $(notification).text(), title ? $(notification).html() : '');
                        });
                    }
                },
                refreshThumbnail: function() {
                    if (this._frameLoaded) {
                        this._reloadPreviewImage();
                    }
                },
                assetChosen: function(assetIdentifier) {
                    if (assetIdentifier) {
                        this._assetIdentifier = assetIdentifier;
                        this._reloadPreviewImage();
                    }
                    that.set('mediaBrowserShown', false);
                },
                close: function() {
                    that.set('mediaBrowserShown', false);
                }
            };
        },

        _mediaBrowserEditView: null,

        _initializeMediaBrowserEditView: function () {
            this.set('_mediaBrowserEditView', Ember.View.extend({
                template: Ember.Handlebars.compile('<iframe style="width:100%; height: 100%" src="' + $('link[rel="neos-image-browser-edit"]').attr('href') + '?asset[__identity]=' + this.get("_object").__identity + '"></iframe>'),
                didInsertElement: function() {
                    this.$().find('iframe').on('load', function(event) {
                        if (window.NeosMediaBrowserCallbacks && window.NeosMediaBrowserCallbacks.onLoad) {
                            window.NeosMediaBrowserCallbacks.onLoad(event, this);
                        }
                    });
                }
            }));
        },

        _beforeMediaBrowserEditIsShown: function () {
            var that = this;
            window.NeosMediaBrowserCallbacks = {
                close: function () {
                    SecondaryInspectorController.hide(that.get('_mediaBrowserEditView'));
                    that._initializeMediaBrowserEditView();
                }
            };
        },

        /**
         * Computed property to decide if the Media Browser button should be displayed in the editor
         */
        shouldRenderMediaBrowser: function () {
            return (this.get('features.mediaBrowser'));
        }.property('features.mediaBrowser'),

        /****************************************
         * IMAGE REMOVE
         ***************************************/
        remove: function () {
            if (this.get('_mediaBrowserEditView')) {
                SecondaryInspectorController.hide(this.get('_mediaBrowserEditView'));
                this.set('_mediaBrowserEditView', null);
            }
            if (this.get('_mediaBrowserView')) {
                SecondaryInspectorController.hide(this.get('_mediaBrowserView'));
            }
            this.set('_object', null);
            this.set('_originalImageUri', null);
            this.set('_inspectorImageUri', null);
            this.set('_previewImageUri', null);
            this.set('_finalImageDimensions.width', null);
            this.set('_finalImageDimensions.height', null);
            this.set('value', '');
        },

        cancel: function () {
            if (this.get('value') !== '') {
                this._readAndDeserializeValue();
            }
            this.set('_uploadInProgress', false);
        },

        /****************************************
         * IMAGE UPLOAD
         ***************************************/
        /**
         * Display before-upload Image preview
         */
        filesScheduledForUpload: function (files) {
            if (files.length > 0) {
                this._displayImageLoader();
                this.upload();
            }
        },

        /**
         * Computed property to decide if the Upload button should be displayed in the editor
         */
        shouldRenderUpload: function () {
            return (this.get('features.upload'));
        }.property('features.upload'),

        /**
         * Callback after file upload is complete
         */
        fileUploaded: function (response) {
            var metadata;
            this._hideImageLoader();
            // if used directly as callback for in filesScheduledForUpload and not in the media
            // browser via getJSON() a string will be handed over.
            if (typeof response === 'string') {
                metadata = JSON.parse(response);
            } else {
                metadata = response;
            }
            if (response === null) {
                Notification.error('Tried to fetch image metadata: Unexpected result format.');
                return;
            }
            this._super();
            Ember.beginPropertyChanges();
            this._applyLoadedMetadata(metadata);
            this._resetCropPropertiesToCurrentPreviewImageDimensions();
            Ember.endPropertyChanges();
            this.set('_imageFullyLoaded', false);
            this.set('_imageFullyLoaded', true);
            this._updateValue();

            if (SecondaryInspectorController.get('_viewClass') === this.get('_cropView')) {
                // Set empty view for secondary inspector to re-initialize crop view
                SecondaryInspectorController.toggle(Ember.View.extend());
                SecondaryInspectorController.hide();
            }
        },

        _resetCropPropertiesToCurrentPreviewImageDimensions: function () {
            this.set('_cropProperties.width', this.get('_previewImageDimensions.width'));
            this.set('_cropProperties.height', this.get('_previewImageDimensions.height'));
            this.set('_cropProperties.x', 0);
            this.set('_cropProperties.y', 0);
        },

        _imageFullyLoadedDidChange: function () {
            // Apply locked aspect ratio if defined
            if (this.get('_imageFullyLoaded') === true && this.get('crop.aspectRatio.locked.width') > 0) {
                var lockedAspectRatioWidth = this.get('crop.aspectRatio.locked.width'),
                    lockedAspectRatioHeight = this.get('crop.aspectRatio.locked.height'),
                    lockedAspectRatio = lockedAspectRatioWidth / lockedAspectRatioHeight;
                // Check if the existing crop aspect ratio matches the locked aspect ratio (with a margin for rounded issues)
                if (Math.round(this.get('_cropProperties.aspectRatio') * 20) / 20 !== Math.round(lockedAspectRatio * 20) / 20) {
                    if (lockedAspectRatioWidth > lockedAspectRatioHeight) {
                        this.set('_cropProperties.height', parseInt(this.get('_cropProperties.height') / lockedAspectRatio * this.get('_cropProperties.aspectRatio'), 10));
                    } else if (lockedAspectRatioWidth === lockedAspectRatioHeight) {
                        if (this.get('_cropProperties.width') > this.get('_cropProperties.height')) {
                            this.set('_cropProperties.width', parseInt(this.get('_cropProperties.width') * lockedAspectRatio / this.get('_cropProperties.aspectRatio'), 10));
                        } else {
                            this.set('_cropProperties.height', parseInt(this.get('_cropProperties.height') / lockedAspectRatio * this.get('_cropProperties.aspectRatio'), 10));
                        }
                    } else {
                        this.set('_cropProperties.width', parseInt(this.get('_cropProperties.width') * lockedAspectRatio / this.get('_cropProperties.aspectRatio'), 10));
                    }
                }
            }
        }.observes('_imageFullyLoaded'),

        /****************************************
         * CROPPING
         ***************************************/
        _cropView: function () {
            var parent = this;

            return Ember.View.extend({
                classNames: ['neos-secondary-inspector-image-crop'],
                template: Ember.Handlebars.compile(cropTemplate),
                aspectRatioWidth: null,
                aspectRatioHeight: null,
                aspectRatioReducedNumerator: 0,
                aspectRatioReducedDenominator: 0,
                aspectRatioAllowCustom: false,
                aspectRatioLocked: false,
                aspectRatioDefaultOption: null,
                aspectRatioOptions: [],
                api: null,
                selection: null,
                initialized: false,

                init: function () {
                    this._super();
                    var that = this,
                        configuration = parent.get('crop');
                    if (configuration.aspectRatio) {
                        if (configuration.aspectRatio.locked && configuration.aspectRatio.locked.width > 0 && configuration.aspectRatio.locked.height > 0) {
                            this.setProperties({
                                'aspectRatioWidth': configuration.aspectRatio.locked.width,
                                'aspectRatioHeight': configuration.aspectRatio.locked.height
                            });
                            this.set('aspectRatioLocked', true);
                        } else {
                            if (parent._shouldApplyCrop(parent.get('_cropProperties.full'), parent.get('_previewImageDimensions.width'), parent.get('_previewImageDimensions.height'))) {
                                this.setProperties({
                                    'aspectRatioWidth': parent.get('_cropProperties.width'),
                                    'aspectRatioHeight': parent.get('_cropProperties.height')
                                });
                            }
                            if (configuration.aspectRatio.defaultOption) {
                                this.set('aspectRatioDefaultOption', configuration.aspectRatio.defaultOption);
                            }
                            var options = Ember.A(),
                                option = Ember.Object.extend({
                                    init: function () {
                                        this._super();
                                        var reducedRatio = that.reduceRatio(this.get('width'), this.get('height'));
                                        this.set('reducedNominator', reducedRatio[0]);
                                        this.set('reducedDenominator', reducedRatio[1]);
                                    },
                                    label: function () {
                                        return this.get('reducedNominator') + ':' + this.get('reducedDenominator');
                                    }.property()
                                });
                            // Add empty option to allow deselect
                            options.push(option.create({label: ''}));
                            if (configuration.aspectRatio.enableOriginal !== false) {
                                options.push(option.create({
                                    key: 'original',
                                    label: 'Original',
                                    width: parent.get('_originalImageDimensions.width'),
                                    height: parent.get('_originalImageDimensions.height')
                                }));
                            }
                            if (configuration.aspectRatio.allowCustom !== false) {
                                that.set('aspectRatioAllowCustom', true);
                                options.push(option.create({
                                    key: 'custom',
                                    label: 'Custom'
                                }));
                            }
                            for (var key in configuration.aspectRatio.options) {
                                if (configuration.aspectRatio.options.hasOwnProperty(key) && configuration.aspectRatio.options[key]) {
                                    options.push(option.create($.extend(configuration.aspectRatio.options[key], {key: key})));
                                }
                            }
                            this.set('aspectRatioOptions', options);
                        }
                    }
                },

                _selectionDidChange: function () {
                    var option = this.get('aspectRatioOptions').findBy('key', this.get('selection'));
                    if (!option) {
                        return;
                    }
                    clearTimeout(this.get('customTimeout'));
                    if (option.get('key') !== 'custom') {
                        this.setProperties({'aspectRatioWidth': option.width, 'aspectRatioHeight': option.height});
                    } else {
                        var that = this;
                        // Use timeout since selection is changed multiple times
                        this.set('customTimeout', setTimeout(function () {
                            if (that.$().find('input[type="number"]:focus').length === 0) {
                                that.$().find('.neos-image-editor-crop-aspect-ratio input[type="number"]:first').focus();
                            }
                        }, 50));
                    }
                }.observes('selection'),

                aspectRatioReduced: function () {
                    var aspectRatioReducedNumerator = this.get('aspectRatioReducedNumerator'),
                        aspectRatioReducedDenominator = this.get('aspectRatioReducedDenominator');
                    return aspectRatioReducedNumerator ? aspectRatioReducedNumerator + ':' + aspectRatioReducedDenominator : '';
                }.property('aspectRatioReducedNumerator', 'aspectRatioReducedDenominator'),

                originalAspectRatio: function () {
                    var aspectRatioWidth = this.get('aspectRatioWidth'),
                        aspectRatioHeight = this.get('aspectRatioHeight'),
                        originalImageWidth = parent.get('_originalImageDimensions.width'),
                        originalImageHeight = parent.get('_originalImageDimensions.height');
                    return aspectRatioWidth / aspectRatioHeight === originalImageWidth / originalImageHeight;
                }.property('aspectRatioWidth', 'aspectRatioHeight'),

                selectedAspectRatioOption: function () {
                    var aspectRatioWidth = this.get('aspectRatioWidth'),
                        aspectRatioHeight = this.get('aspectRatioHeight'),
                        aspectRatio = aspectRatioWidth / aspectRatioHeight,
                        matchesOption = false,
                        options = this.get('aspectRatioOptions');
                    options.forEach(function (option) {
                        // First iteration with a strict match
                        if (Math.round(option.width / option.height * 50) / 50 === Math.round(aspectRatio * 50) / 50) {
                            option.set('active', true);
                            matchesOption = true;
                        } else {
                            option.set('active', false);
                        }
                    });
                    if (!matchesOption) {
                        // Secondary iteration if no matches were found with a less strict match
                        options.forEach(function (option) {
                            if (Math.round(option.width / option.height * 20) / 20 === Math.round(aspectRatio * 20) / 20) {
                                option.set('active', true);
                                matchesOption = true;
                            } else {
                                option.set('active', false);
                            }
                        });
                    }
                    // Select custom if allowed + it's not the original aspect ratio + no other options matched + aspect ratio width & height set + not during initialization
                    if (this.get('aspectRatioAllowCustom') && !this.get('originalAspectRatio') && !matchesOption && aspectRatioWidth > 0 && aspectRatioHeight > 0 && this.get('initialized')) {
                        options.findBy('label', 'Custom').set('active', true);
                    }
                    var activeOption = options.findBy('active', true);
                    if (activeOption) {
                        this.set('selection', activeOption.get('key'));
                    } else {
                        this.set('selection', null);
                    }
                    var that = this;
                    Ember.run.next(function () {
                        that.$().find('select').trigger('change');
                    });
                }.observes('aspectRatioWidth', 'aspectRatioHeight').on('init'),

                _aspectRatioDidChange: function () {
                    parent._swapPreviewWithOriginal();
                    var aspectRatioWidth = this.get('aspectRatioWidth'),
                        aspectRatioHeight = this.get('aspectRatioHeight'),
                        api = this.get('api');
                    if (api) {
                        api.setOptions({aspectRatio: aspectRatioWidth > 0 && aspectRatioHeight > 0 ? aspectRatioWidth / aspectRatioHeight : 0});
                    }
                    var reducedRatio;
                    if (aspectRatioWidth && aspectRatioHeight) {
                        reducedRatio = this.reduceRatio(aspectRatioWidth, aspectRatioHeight);
                    }
                    this.set('aspectRatioReducedNumerator', reducedRatio ? reducedRatio[0] : null);
                    this.set('aspectRatioReducedDenominator', reducedRatio ? reducedRatio[1] : null);
                }.observes('aspectRatioWidth', 'aspectRatioHeight').on('init'),

                actions: {
                    exchangeAspectRatio: function () {
                        var aspectRatioWidth = this.get('aspectRatioWidth'),
                            aspectRatioHeight = this.get('aspectRatioHeight');
                        this.setProperties({
                            'aspectRatioWidth': aspectRatioHeight,
                            'aspectRatioHeight': aspectRatioWidth
                        });
                    }
                },

                /**
                 * Reduce a numerator and denominator to it's smallest, integer ratio using Euclid's Algorithm
                 */
                reduceRatio: function (numerator, denominator) {
                    var temp,
                        divisor,
                        isInteger = function (n) {
                            return n % 1 === 0;
                        },
                        greatestCommonDivisor = function (a, b) {
                            if (b === 0) {
                                return a;
                            }
                            return greatestCommonDivisor(b, a % b);
                        };

                    if (numerator === denominator) {
                        return [1, 1];
                    }

                    // make sure numerator is always the larger number
                    if (+numerator < +denominator) {
                        temp = numerator;
                        numerator = denominator;
                        denominator = temp;
                    }

                    divisor = greatestCommonDivisor(+numerator, +denominator);

                    if (typeof temp === 'undefined') {
                        return [numerator / divisor, denominator / divisor];
                    } else {
                        return [denominator / divisor, numerator / divisor];
                    }
                },

                didInsertElement: function () {
                    var $image = this.$().find('img');
                    $image.attr('src', parent.get('_previewImageUri'));

                    var update = function (previewImageCoordinates) {
                            var imageWidthBeforeChange = parent.get('_finalImageDimensions.width');
                            var imageWidthScalingFactor = previewImageCoordinates.w / parent.get('_cropProperties.width');
                            Ember.beginPropertyChanges();
                            if (imageWidthBeforeChange) {
                                parent.set('_finalImageDimensions.width', Math.round(imageWidthBeforeChange * imageWidthScalingFactor));
                            }

                            parent.set('_cropProperties.width', previewImageCoordinates.w);
                            parent.set('_cropProperties.height', previewImageCoordinates.h);
                            parent.set('_cropProperties.x', previewImageCoordinates.x);
                            parent.set('_cropProperties.y', previewImageCoordinates.y);
                            Ember.endPropertyChanges();
                            parent._updateValue();
                        },
                        settings = {
                            boxWidth: 600,
                            boxHeight: 600,
                            // Triggered when the selection is finished or updated
                            onSelect: update,
                            onChange: update
                        };

                    var cropOptions = parent.get('_cropProperties.full');
                    // If we have all crop options set, we preselect this in the cropping tool.
                    if (parent.get('_cropProperties.initialized')) {

                        settings.setSelect = [
                            cropOptions.x,
                            cropOptions.y,
                            cropOptions.x + cropOptions.width,
                            cropOptions.y + cropOptions.height
                        ];
                    }

                    if (this.get('aspectRatioWidth') && this.get('aspectRatioHeight')) {
                        settings.aspectRatio = this.get('aspectRatioWidth') / this.get('aspectRatioHeight');
                    } else if (this.get('aspectRatioDefaultOption') && !parent._shouldApplyCrop(cropOptions, parent.get('_previewImageDimensions.width'), parent.get('_previewImageDimensions.height'))) {
                        var defaultOption = this.get('aspectRatioOptions').findBy('key', this.get('aspectRatioDefaultOption'));
                        defaultOption.set('active', true);
                        this.set('selection', defaultOption.get('key'));
                        settings.aspectRatio = defaultOption.width / defaultOption.height;
                    }

                    var that = this;
                    $image.Jcrop(settings, function () {
                        that.set('api', this);
                    });

                    this.$().find('select').select2({
                        maximumSelectionSize: 1,
                        minimumResultsForSearch: 10,
                        allowClear: true,
                        placeholder: 'Aspect ratio',
                        dropdownCssClass: 'neos-select2-large'
                    });

                    this.set('initialized', true);
                }
            });
        }.property(),

        /**
         * Update the preview image when the crop options change or the preview image
         * is initially loaded. This includes:
         *
         * - set the preview bounding box size
         * - set the preview bounding box offset such that the image is centered
         * - scale the preview image and set the offsets correctly
         */
        _updateCropPreviewImage: function () {
            var that = this;
            // Make sure the image has been updated before altering styles
            Ember.run.next(function () {
                var cropProperties = that.get('_cropProperties.full'),
                    container = that.$().find('.neos-inspector-image-thumbnail-inner'),
                    image = container.find('img');

                if (that.get('_inspectorImageUri') && that._shouldApplyCrop(cropProperties, that.get('_previewImageDimensions.width'), that.get('_previewImageDimensions.height'))) {
                    var scalingFactorX = that.imagePreviewMaximumDimensions.width / cropProperties.width,
                        scalingFactorY = that.imagePreviewMaximumDimensions.height / cropProperties.height,
                        overallScalingFactor = Math.min(scalingFactorX, scalingFactorY),
                        previewBoundingBoxDimensions = {
                            width: Math.floor(cropProperties.width * overallScalingFactor),
                            height: Math.floor(cropProperties.height * overallScalingFactor)
                        };

                    // show original image in inspector if cropping took place
                    that._swapPreviewWithOriginal();

                    // Update size of preview bounding box and center preview image thumbnail
                    container.css({
                        width: previewBoundingBoxDimensions.width + 'px',
                        height: previewBoundingBoxDimensions.height + 'px',
                        position: 'absolute',
                        left: ((that.imagePreviewMaximumDimensions.width - previewBoundingBoxDimensions.width) / 2 ) + 'px',
                        top: ((that.imagePreviewMaximumDimensions.height - previewBoundingBoxDimensions.height) / 2) + 'px'
                    }).addClass('neos-inspector-image-thumbnail-cropped');

                    // Scale Preview image and update relative image position
                    image.css({
                        width: Math.floor(that.get('_previewImageDimensions').width * overallScalingFactor) + 'px',
                        height: Math.floor(that.get('_previewImageDimensions').height * overallScalingFactor) + 'px',
                        marginLeft: '-' + (cropProperties.x * overallScalingFactor) + 'px',
                        marginTop: '-' + (cropProperties.y * overallScalingFactor) + 'px'
                    });
                } else {
                    container.attr('style', null).removeClass('neos-inspector-image-thumbnail-cropped');
                    image.attr('style', null);
                }
            });
        }.observes('_cropProperties.x', '_cropProperties.y', '_cropProperties.width', '_cropProperties.height', '_object'),

        /****************************************
         * Saving / Loading
         ***************************************/

        /**
         * This function must be triggered *explicitly* when either* _originalImage, _cropProperties or
         * _finalImageDimensions are modified, as it writes these changes back into a JSON object.
         *
         * We don't use value observing here, as this might end up with a circular dependency.
         */
        _updateValue: function () {
            if (!this.get('_imageFullyLoaded')) {
                return;
            }

            var currentObject = this.get('_object'),
                newImageVariantData = {},
                that = this;

            this._applyEditorChangesToAdjustments();
            if (currentObject !== null && Object.keys(this.get('_adjustments')).length > 0) {

                newImageVariantData = {
                    adjustments: this.get('_adjustments'),
                    __type: 'Neos\\Media\\Domain\\Model\\ImageVariant',
                    // we need to transform Image into an ImageVariant if we added adjustments.
                    originalAsset: currentObject.originalAsset ? currentObject.originalAsset.__identity : currentObject.__identity
                };
            }

            if (newImageVariantData.__type === 'Neos\\Media\\Domain\\Model\\ImageVariant') {
                delete newImageVariantData.__type;
                this.set('value', {
                    _propertyChangePromiseClosure: function () {
                    return HttpClient.createResource(that.get('_createImageVariantEndpointUri'), {data: {asset: newImageVariantData}});
                }});
            } else {
                this.set('value', JSON.stringify(currentObject));
            }
        },

        /**
         * On startup, we deserialize the JSON string found in the editor's "value" property and fill _originalImage,
         * _scaleOptions and _cropOptions with their respective values.
         */
        _readAndDeserializeValue: function () {
            var that = this,
                imageVariant = this.get('value');

            if (!imageVariant || !Utility.isValidJsonString(imageVariant)) {
                return;
            }
            try {
                imageVariant = JSON.parse(imageVariant);
            } catch (e) {
                // In case we do not have valid JSON here, let's silently return
                return;
            }
            if (imageVariant) {
                // We now load more detailed data for the image variant, and as soon as we have it fully initialize
                // the widget.
                this._displayImageLoader();

                that.set('_loadPreviewImageHandler', HttpClient.getResource(
                    that.get('_imageServiceEndpointUri') + '?image=' + imageVariant.__identity,
                    {dataType: 'json'}
                ));
                that.get('_loadPreviewImageHandler').then(function (metadata) {
                    that._hideImageLoader();
                    that.beginPropertyChanges();
                    that._applyLoadedMetadata(metadata);
                    that._updateAdjustmentsFromObject();
                    that._resetCropPropertiesToCurrentPreviewImageDimensions();
                    that.endPropertyChanges();
                    that._updateFinalImageDimensions();
                    that.set('_imageFullyLoaded', true);
                });
            }
        },

        _applyLoadedMetadata: function (metadata) {
            this.set('_object', metadata.object);
            this.set('_originalImageDimensions', metadata.originalDimensions);
            this.set('_inspectorImageUri', metadata.previewImageResourceUri);
            this.set('_originalImageUri', metadata.originalImageResourceUri);
            this.set('_previewImageDimensions', metadata.previewDimensions);
            this.set('_previewImageUri', metadata.previewImageResourceUri);

            // FIXME: Make less hardcoded... Currently svg is probably the most important format to support, but other formats could also need disabling. Find a better way to define formats vs. features.
            if (metadata.mediaType === 'image/svg+xml') {
                this.set('features', {crop: false, resize: false});
            }
        },

        _applyEditorChangesToAdjustments: function () {
            // Prevent the user from setting width and height to empty
            var finalWidth = this.get('_finalImageDimensions.width'),
                finalHeight = this.get('_finalImageDimensions.height'),
                originalWidth = this.get('_originalImageDimensions.width'),
                originalHeight = this.get('_originalImageDimensions.height'),
                cropProperties = this._convertCropOptionsFromPreviewImageCoordinates(this.get('_cropProperties.full'));

            if ((finalWidth > 0 && finalHeight > 0) && (this._adjustments['Neos\\Media\\Domain\\Model\\Adjustment\\ResizeImageAdjustment'] || (finalWidth !== originalWidth || finalHeight !== originalHeight))) {
                this._applyResizeAdjustment(finalWidth, finalHeight);
            }

            if (this._adjustments['Neos\\Media\\Domain\\Model\\Adjustment\\CropImageAdjustment'] || this._shouldApplyCrop(cropProperties, originalWidth, originalHeight)) {
                this._applyCropAdjustment(cropProperties);
            }
        },

        _applyResizeAdjustment: function (finalWidth, finalHeight) {
            // in order for change detection to work the order of these properties needs to be exactly like received from the server side (which is alphabetically ordered)
            this._adjustments['Neos\\Media\\Domain\\Model\\Adjustment\\ResizeImageAdjustment'] = {
                allowUpScaling: null,
                height: finalHeight,
                maximumHeight: null,
                maximumWidth: null,
                minimumHeight: null,
                minimumWidth: null,
                position: 20,
                ratioMode: null,
                width: finalWidth
            };
        },

        _shouldApplyCrop: function (cropProperties, imageWidth, imageHeight) {
            if (imageWidth == null || imageHeight == null) {
                return false;
            }

            return !(
                cropProperties.width === imageWidth &&
                cropProperties.height === imageHeight &&
                cropProperties.x === 0 &&
                cropProperties.y === 0
            );
        },

        _applyCropAdjustment: function (cropProperties) {
            // in order for change detection to work the order of these properties needs to be exactly like received from the server side (which is alphabetically ordered)
            this._adjustments['Neos\\Media\\Domain\\Model\\Adjustment\\CropImageAdjustment'] = {
                height: cropProperties.height,
                position: 10,
                width: cropProperties.width,
                x: cropProperties.x,
                y: cropProperties.y
            };
        },

        _updateImageEditLink: function () {
            var that = this;
            if (!this.get('_object')) {
                this.set('_mediaBrowserEditView', null);
                return;
            }

            this._initializeMediaBrowserEditView();

            this.$().find('.neos-inspector-image-thumbnail').click(function () {
                var view = that.get('_mediaBrowserEditView');
                if (view === null) {
                    return;
                }
                if (!SecondaryInspectorController._viewIsActive(view)) {
                    that._beforeMediaBrowserEditIsShown();
                }
                SecondaryInspectorController.toggle(view);
            });
        }.observes('_imageFullyLoaded'),

        /**
         *
         */
        _updateAdjustmentsFromObject: function () {
            var that = this,
                adjustments = {};
            if (this.get('_object.adjustments')) {
                adjustments = this.get('_object.adjustments');
                $.each(adjustments, function (index, adjustment) {
                    if (index === 'Neos\\Media\\Domain\\Model\\Adjustment\\CropImageAdjustment') {
                        var finalSizeCropProperties = {
                            height: Ember.get(adjustment, 'height'),
                            width: Ember.get(adjustment, 'width'),
                            x: Ember.get(adjustment, 'x'),
                            y: Ember.get(adjustment, 'y')
                        };

                        var previewImageCropProperties = that._convertCropOptionsToPreviewImageCoordinates(finalSizeCropProperties);

                        that.set('_cropProperties.height', previewImageCropProperties.height);
                        that.set('_cropProperties.width', previewImageCropProperties.width);
                        that.set('_cropProperties.x', previewImageCropProperties.x);
                        that.set('_cropProperties.y', previewImageCropProperties.y);
                    }
                    if (index === 'Neos\\Media\\Domain\\Model\\Adjustment\\ResizeImageAdjustment') {
                        that.set('_finalImageDimensions.width', Ember.get(adjustment, 'width'));
                        that.set('_finalImageDimensions.height', Ember.get(adjustment, 'height'));
                    }
                });
            }

            this.set('_adjustments', adjustments);
        }.observes('_object'),

        /**
         * This is only to be used after loading image metadata because we want the final dimensions to be exactly like the resize image adjustment
         */
        _updateFinalImageDimensions: function() {
            var adjustments = this.get('_adjustments'),
                resizeAdjustment = adjustments['Neos\\Media\\Domain\\Model\\Adjustment\\ResizeImageAdjustment'];
            if (resizeAdjustment) {
                this.set('_finalImageDimensions.width', resizeAdjustment.width);
                this.set('_finalImageDimensions.height', resizeAdjustment.height);
            }
        },

        /**
         * Helper
         *
         * Convert the crop options from the *preview image* coordinate system to the
         * *master image* coordinate system which is stored persistently.
         *
         * The inverse function to this method is _convertCropOptionsToPreviewImageCoordinates
         */
        _convertCropOptionsFromPreviewImageCoordinates: function (previewImageCoordinates) {
            var previewImageDimensions = this.get('_previewImageDimensions'),
                originalImageDimensions = this.get('_originalImageDimensions');

            return {
                x: Math.round(previewImageCoordinates.x * (originalImageDimensions.width / previewImageDimensions.width)),
                y: Math.round(previewImageCoordinates.y * (originalImageDimensions.height / previewImageDimensions.height)),
                width: Math.round(previewImageCoordinates.width * (originalImageDimensions.width / previewImageDimensions.width)),
                height: Math.round(previewImageCoordinates.height * (originalImageDimensions.height / previewImageDimensions.height))
            };
        },

        /**
         * Helper
         *
         * Convert the crop options from the *master image* coordinate system to the
         * *preview image* coordinate system. We need this as the *preview image* used as
         * basis for cropping might be smaller than the original one.
         *
         * The inverse function to this method is _convertCropOptionsFromPreviewImageCoordinates
         */
        _convertCropOptionsToPreviewImageCoordinates: function (coordinates) {
            var previewImageDimensions = this.get('_previewImageDimensions'),
                originalImageDimensions = this.get('_originalImageDimensions');

            return {
                x: Math.round(coordinates.x / (originalImageDimensions.width / previewImageDimensions.width)),
                y: Math.round(coordinates.y / (originalImageDimensions.height / previewImageDimensions.height)),
                width: Math.round(coordinates.width / (originalImageDimensions.width / previewImageDimensions.width)),
                height: Math.round(coordinates.height / (originalImageDimensions.height / previewImageDimensions.height))
            };
        },

        _initializeMediaView: function () {
            this.set('_mediaBrowserView', Ember.View.extend({
                template: Ember.Handlebars.compile('<iframe style="width:100%; height: 100%" src="' + $('link[rel="neos-image-browser"]').attr('href') + '"></iframe>'),
                didInsertElement: function() {
                    this.$().find('iframe').on('load', function(event) {
                        if (window.NeosMediaBrowserCallbacks && window.NeosMediaBrowserCallbacks.onLoad) {
                            window.NeosMediaBrowserCallbacks.onLoad(event, this);
                        }
                    });
                }
            }));
        },

        _initializeUploader: function () {
            this._super();

            this._uploader.bind('BeforeUpload', function (uploader, file) {
                uploader.settings.multipart_params.metadata = 'Image';
            });

            var that = this;
            this._uploader.bind('Error', function (uploader, error) {
                that.cancel();
            });
        },

        /**
         * Computed property to decide if cropping is available in the editor
         */
        shouldRenderCrop: function () {
            return (this.get('features.crop') && this.get('_originalImageUri'));
        }.property('features.crop', '_originalImageUri'),

        /**
         * Computed property to decide if resizing is available in the editor
         */
        shouldRenderResize: function () {
            return (this.get('features.resize') && this.get('_originalImageUri'));
        }.property('features.resize', '_originalImageUri'),

        /**
         * Swaps the small preview inspector image with the original for cropping preview
         */
        _swapPreviewWithOriginal: function() {
                this.set('_inspectorImageUri', this.get('_originalImageUri'));
        },

        /**
         * Image Loader
         */
        _displayImageLoader: function () {
            if (this.loadingIndicator !== null) {
                this.loadingIndicator.spin(this.$().find('.neos-inspector-image-thumbnail').get(0));
                return;
            }
            this.loadingIndicator = new Spinner({
                lines: 13, // The number of lines to draw
                length: 15, // The length of each line
                width: 4, // The line thickness
                radius: 10, // The radius of the inner circle
                corners: 1, // Corner roundness (0..1)
                rotate: 0, // The rotation offset
                color: '#fff', // #rgb or #rrggbb
                speed: 1, // Rounds per second
                trail: 64, // Afterglow percentage
                shadow: false, // Whether to render a shadow
                hwaccel: false, // Whether to use hardware acceleration
                className: 'spinner', // The CSS class to assign to the spinner
                zIndex: 2e9, // The z-index (defaults to 2000000000)
                top: 'auto', // Top position relative to parent in px
                left: 'auto' // Left position relative to parent in px
            }).spin(this.$().find('.neos-inspector-image-thumbnail').get(0));
        },

        _hideImageLoader: function () {
            this.loadingIndicator.stop();
        },

        initializeCropButtonTooltip: function() {
            // Re-initialize crop button tooltip after re-render
            Ember.run.next(this, function() {
                this.$('[data-neos-tooltip]:not([data-original-title])').tooltip({container: '#neos-application'});
            });
        }.observes('shouldRenderCrop')
    });
});