prestaconcept / PrestaImageBundle

Allow to crop local and remote image before uploading them through a classic form.
MIT License
23 stars 19 forks source link

Added Bootstrap 5 Support + Small fix #79

Closed Mecanik closed 3 years ago

Mecanik commented 3 years ago

I don't know what's wrong with the permissions but I`m unable to create a pull request, sorry.

This adds BS 5 support without affecting previous BS versions. When using WebPack ensure you declare the bootstrap import properly (window.bootstrap = bootstrap;). A small fix is also made to prevent duplication of CropperJS when cancelling an upload and selecting a different image.

bootstrap_5.html.twig

{% trans_default_domain 'PrestaImageBundle' %}

{% block image_widget %}
{% apply spaceless %}
    <div class="cropper" data-cropper-options="{{ form.vars.cropper_options }}" data-max-width="{{ max_width }}" data-max-height="{{ max_height }}" data-mimetype="{{ upload_mimetype }}" data-quality="{{ upload_quality }}">

        <div class="row">
            {% if enable_locale %}
                <div class="col-4 cropper-local">
                    <input type="file" name="file" class="d-none" />
                    <button type="button" class="{{ upload_button_class }}">
                        <span class="{{ upload_button_icon }}"></span>
                        {{ 'btn_import_image_local'|trans }}
                    </button>
                </div>
            {% endif %}
            {% if enable_remote %}
                <div class="col-8 cropper-remote">
                    <div class="input-group">
                        <input type="url" class="image-url-input form-control form-control-sm" placeholder="{{ 'image_dist_placeholder'|trans }}" />
                        <div class="input-group-append">
                            <button type="button" class="btn btn-sm btn-primary btn-upload-dist" disabled="disabled" data-url="{{ path('presta_image_url_to_base64') }}">
                                <span class="fa fa-upload"></span>
                                {{ 'btn_import_image_remote'|trans }}
                            </button>
                            <div class="remote-loader spinner d-none">
                                <div class="rect1"></div>
                                <div class="rect2"></div>
                                <div class="rect3"></div>
                                <div class="rect4"></div>
                            </div>
                        </div>
                    </div>
                </div>
            {% endif %}
            {% if form.delete is defined %}
                <div class="col-12">
                    {{ form_row(form.delete) }}
                </div>
            {% endif %}
        </div>

        <div class="cropper-canvas-container mt-2{% if form.delete is defined %} cropper-canvas-has-delete{% endif %}" data-preview-width="{{ preview_width }}" data-preview-height="{{ preview_height }}">
            {% if form.vars.download_uri is defined and form.vars.download_uri %}
                <img id="pula" src="{{ asset(form.vars.download_uri) }}" style="max-width: {{ preview_width }}; max-height: {{ preview_height }};">
            {% endif %}
        </div>
        {{ form_row(form.base64) }}

        {% set show_aspect_ratios = aspect_ratios|length > 1 %}
        <div class="modal fade" id="presta_modal_bs5" name="presta_modal_bs5"  data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="modal-title-label" aria-hidden="true">
            <div class="modal-dialog modal-lg">
                <div class="modal-content">
                    <div class="modal-header">
                        <h5 class="modal-title" id="modal-title-label">{{ 'resize_image'|trans }}</h5>
                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ 'btn_cancel'|trans }}"></button>
                    </div>
                    <div class="modal-body">
                        <div class="row">
                            <div class="{% if show_aspect_ratios %}col-10{% else %}col-12{% endif %}">
                                <div class="cropper-preview"></div>
                            </div>
                            {% if show_aspect_ratios %}
                                <div class="col-2">
                                    <div class="btn-group-vertical float-right">
                                        {% for aspect_ratio in aspect_ratios %}
                                            <label class="btn btn-primary mb-0{% if aspect_ratio.checked %} active{% endif %}">
                                                <input type="radio" name="cropperAspectRatio" class="d-none" value="{{ aspect_ratio.value }}"{% if aspect_ratio.checked %} checked="checked"{% endif %}>
                                                {{ aspect_ratio.label|trans }}
                                            </label>
                                        {% endfor %}
                                    </div>
                                </div>
                            {% else %}
                                {% for aspect_ratio in aspect_ratios %}
                                    <input type="hidden" name="cropperAspectRatio" value="{{ aspect_ratio.value }}"{% if aspect_ratio.checked %} checked="checked"{% endif %}>
                                {% endfor %}
                            {% endif %}
                        </div>
                        {% if enable_rotation %}
                            <div class="row">
                                <div class="toolbar {% if show_aspect_ratios %}col-10{% else %}col-12{% endif %}">
                                    <button class="btn btn-default rotate" data-rotate="90"></button>
                                    <button class="btn btn-default rotate anti-rotate" data-rotate="-90"></button>
                                </div>
                            </div>
                        {% endif %}
                    </div>
                    <div class="modal-footer">
                        <button type="button" class="{{ cancel_button_class }}" data-bs-dismiss="modal" aria-label="{{ 'btn_cancel'|trans }}">{{ 'btn_cancel'|trans }}</button>
                        <button type="button" class="{{ save_button_class }}" data-method="getCroppedCanvas" aria-label="{{ 'btn_validate'|trans }}">{{ 'btn_validate'|trans }}</button>
                    </div>
                </div>
            </div>
        </div>
    </div>
{% endapply %}
{% endblock %}

cropper.js

const CropperJS = require('cropperjs');

(function(w, $) {

    'use strict';

    const Cropper = function($el) {
        this.$el = $el;
        this.options = $.extend({}, $el.data('cropper-options'));

        this
            .initElements()
            .initLocalEvents()
            .initRemoteEvents()
            .initCroppingEvents()
        ;
    };

    Cropper.prototype.initElements = function() {
        this.$modal     = this.$el.find('.modal');  
        this.$modal_bs5 = this.$el.find('#presta_modal_bs5');

        this.$aspectRatio = this.$modal.find('input[name="cropperAspectRatio"]');
        this.$rotator = this.$modal.find('.rotate');
        this.$input = this.$el.find('input.cropper-base64');

        this.$container = {
            $preview: this.$modal.find('.cropper-preview'),
            $canvas: this.$el.find('.cropper-canvas-container')
        };

        this.$local = {
            $btnUpload: this.$el.find('.cropper-local button'),
            $input: this.$el.find('.cropper-local input[type="file"]')
        };

        this.$remote = {
            $btnUpload: this.$el.find('.cropper-remote button'),
            $uploadLoader: this.$el.find('.cropper-remote .remote-loader'),
            $input: this.$el.find('.cropper-remote input[type="url"]')
        };

        this.options = $.extend(this.options, {
            aspectRatio: this.$aspectRatio.val()
        });

        this.cropper = null;
        this.b5modal = null;

        return this;
    };

    Cropper.prototype.initLocalEvents = function() {
        const self = this;

        // map virtual upload button to native input file element
        this.$local.$btnUpload.on('click', function() {
            self.$local.$input.trigger('click');
        });

        // start cropping process on input file "change"
        this.$local.$input.on('change', function() {
            const reader = new FileReader();

            // show a croppable preview image in a modal
            reader.onload = function(e) {
                self.prepareCropping(e.target.result);

                // clear input file so that user can select the same image twice and the "change" event keeps being triggered
                self.$local.$input.val('');
            };

            // trigger "reader.onload" with uploaded file
            reader.readAsDataURL(this.files[0]);
        });

        return this;
    };

    Cropper.prototype.initRemoteEvents = function() {
        const self = this;

        const $btnUpload = this.$remote.$btnUpload;
        const $uploadLoader = this.$remote.$uploadLoader;

        // handle distant image upload button state
        this.$remote.$input.on('change, input', function() {
            const url = $(this).val();

            self.$remote.$btnUpload.prop('disabled', url.length <= 0 || url.indexOf('http') === -1);
        });

        // start cropping process get image's base64 representation from local server to avoid cross-domain issues
        this.$remote.$btnUpload.on('click', function() {
            $btnUpload.hide();
            $uploadLoader.removeClass('hidden d-none');
            $.ajax({
                url: $btnUpload.data('url'),
                data: {
                    url: self.$remote.$input.val()
                },
                method: 'post'
            }).done(function(data) {
                self.prepareCropping(data.base64);
                $btnUpload.show();
                $uploadLoader.addClass('hidden d-none');
            });
        });

        return this;
    };

    Cropper.prototype.initCroppingEvents = function() {
        const self = this;

        // handle image cropping
        this.$modal.find('[data-method="getCroppedCanvas"]').on('click', function() {
            self.crop();
        });

        // handle "aspectRatio" switch
        this.$aspectRatio.on('change', function() {
            self.cropper.setAspectRatio($(this).val());
        });

        this.$rotator.on('click', function(e) {
            e.preventDefault();
            e.stopPropagation();

            self.cropper.rotate($(this).data('rotate'));
        });

        return this;
    };

    /**
     * Open cropper "editor" in a modal with the base64 uploaded image.
     */
    Cropper.prototype.prepareCropping = function(base64) {
        const self = this;

        // clean previous croppable image
        if (this.cropper) 
        {
            this.$container.$preview.children('img').attr('href', ''); // fixed
            this.$container.$preview.children('img').remove(); // fixed
            this.cropper.destroy();
        }

        // reset "aspectRatio" buttons
        this.$aspectRatio.each(function() {
            const $this = $(this);

            if ($this.val().length <= 0) {
                $this.trigger('click');
            }
        });

        // Do we use BS5?
        if(this.$modal_bs5) 
        {
            // clean previous bs5 modal     
            if(this.$bs5modal) {
                this.$bs5modal.dispose();
            }   

            // New BS5 Modal
            this.b5modal = new bootstrap.Modal(this.$modal_bs5);

            this.$modal_bs5
            .one('shown.bs.modal', function (event) {
                // (re)build croppable image once the modal is shown (required to get proper image width)
                $('<img>')
                    .attr('src', base64)
                    .on('load', function() {
                    self.cropper = new CropperJS(this, self.options)
                    })
                    .appendTo(self.$container.$preview);
            });

            this.b5modal.show();
        }
        else 
        {
            this.$modal
            .one('shown.bs.modal', function() {
                // (re)build croppable image once the modal is shown (required to get proper image width)
                $('<img>')
                    .attr('src', base64)
                    .on('load', function() {
                        self.cropper = new CropperJS(this, self.options)
                    })
                    .appendTo(self.$container.$preview);
            })
            .modal('show');
        }
    };

    /**
     * Create canvas from cropped image and fill in the hidden input with canvas base64 data.
     */
    Cropper.prototype.crop = function() {
        const data = this.cropper.getData(),
            image_width = Math.min(this.$el.data('max-width'), data.width),
            image_height = Math.min(this.$el.data('max-height'), data.height),
            preview_width = Math.min(this.$container.$canvas.data('preview-width'), data.width),
            preview_height = Math.min(this.$container.$canvas.data('preview-height'), data.height),

            // TODO: getCroppedCanvas seams to only consider one dimension when calculating the maximum size
            // in respect to the aspect ratio and always considers width first, so height is basically ignored!
            // To set a maximum height, no width parameter should be set.
            // Example of current wrong behavior:
            // source of 200x300 with resize to 150x200 results in 150x225 => WRONG (should be: 133x200)
            // source of 200x300 with resize to 200x150 results in 200x300 => WRONG (should be: 100x150)
            // This is an issue with cropper, not this library
            preview_canvas = this.cropper.getCroppedCanvas({
                width: preview_width,
                height: preview_height
            }),
            image_canvas = this.cropper.getCroppedCanvas({
                width: image_width,
                height: image_height
            });

        // fill canvas preview container with cropped image
        this.$container.$canvas.html(preview_canvas);

        // fill input with base64 cropped image
        this.$input.val(image_canvas.toDataURL(this.$el.data('mimetype'), this.$el.data('quality')));

        // hide the modal
        if(this.$modal_bs5 && this.b5modal) 
        {
            this.b5modal.hide();
        }
        else
        {
            this.$modal.modal('hide');
        }   
    };

    if (typeof module !== 'undefined' && 'exports' in module) {
        module.exports = Cropper;
    } else {
        window.Cropper = Cropper;
    }

})(window, jQuery);

76

J-Ben87 commented 3 years ago

Hi @Mecanik , and thank you for your contribution. I'll have a look at it as soon as possible and integrate the changes for you as it seems that you can't create a pull request yourself :slightly_smiling_face:

J-Ben87 commented 3 years ago

@Mecanik that should do, let me know if something is still not working