(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
    typeof define === 'function' && define.amd ? define(['exports'], factory) :
    (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Djblets = global.Djblets || {}));
})(this, (function (exports) { 'use strict';

    var _class$3;
    /**
     * Settings for the avatar configuration form.
     */



    /**
     * Attributes for the Settings model.
     *
     * Version Added:
     *     5.0
     */

    /**
     * Settings for the avatar configuration form.
     */
    let Settings = Spina.spina(_class$3 = class Settings extends Spina.BaseModel {
      /**
       * Return defaults for the model attributes.
       *
       * Returns:
       *     SettingsAttributes:
       *     Default values for the model attributes.
       */
      static defaults() {
        return {
          configuration: {},
          serviceID: null,
          services: {}
        };
      }
    }) || _class$3;

    var _class$2;
    /**
     * A base class for avatar service settings forms.
     */



    /**
     * A base class for avatar service settings forms.
     *
     * Subclasses should override this to provide additional behaviour for
     * previews, etc.
     */
    let ServiceSettingsFormView = Spina.spina(_class$2 = class ServiceSettingsFormView extends Spina.BaseView {
      /**
       * Validate the form.
       *
       * Returns:
       *     boolean:
       *     Whether or not the form is valid.
       */
      validate() {
        return true;
      }

      /**
       * Hide the form.
       *
       * This will set the disabled and hidden states.
       *
       * Version Changed:
       *     5.0:
       *     This no longer alters the ``display`` state of the element.
       *
       * Returns:
       *     ServiceSettingsFormView:
       *     This object, for chaining.
       */
      hide() {
        const el = this.el;
        el.disabled = true;
        el.hidden = true;
        return this;
      }

      /**
       * Show the form.
       *
       * This will remove the disabled and hidden states.
       *
       * Version Changed:
       *     5.0:
       *     This no longer alters the ``display`` state of the element.
       *
       * Returns:
       *     ServiceSettingsFormView:
       *     This object, for chaining.
       */
      show() {
        const el = this.el;
        el.disabled = false;
        el.hidden = false;
        return this;
      }
    }) || _class$2;

    var _class$1;
    const allowedMimeTypes = ['image/gif', 'image/jpeg', 'image/png'];

    /**
     * A file upload avatar settings form.
     *
     * This form provides a preview of the uploaded avatar.
     */
    let FileUploadSettingsFormView = Spina.spina(_class$1 = class FileUploadSettingsFormView extends ServiceSettingsFormView {
      /**
       * Events for the view.
       */
      static events = {
        'change #id_file-upload-avatar_upload': '_onFileChanged',
        'click .avatar-file-upload-browse': '_onBrowseClicked',
        'click .avatar-preview': '_onBrowseClicked',
        'dragenter .avatar-file-upload-config': '_onDragEnter',
        'dragleave .avatar-file-upload-config': '_onDragLeave',
        'dragover .avatar-file-upload-config': '_onDragOver',
        'drop .avatar-file-upload-config': '_onDrop'
      };

      /**********************
       * Instance variables *
       **********************/

      /** The main container for the form. */
      #$box;

      /** The preview image. */
      #$preview;

      /** The file input element. */
      #$fileInput;

      /**
       * Validate the form.
       *
       * If a file is selected, ensure it is has the correct MIME type.
       *
       * Returns:
       *     boolean:
       *     Whether the form is valid.
       */
      validate() {
        const file = this.#$fileInput[0].files[0];
        if (!file) {
          alert(gettext("You must choose a file."));
          return false;
        }
        if (!allowedMimeTypes.some(el => el === file.type)) {
          alert(gettext("This wasn't a valid image file format. Please provide a PNG, JPEG, or GIF file."));
          return false;
        }
        return true;
      }

      /**
       * Render the form.
       */
      onInitialRender() {
        this.#$box = this.$('.avatar-file-upload-config');
        this.#$preview = this.$('.avatar-preview');
        this.#$fileInput = this.$('#id_file-upload-avatar_upload');
      }

      /**
       * Handler for a click event on the "browse" instruction text.
       *
       * This will trigger opening the hidden file input.
       *
       * Args:
       *     e (jQuery.Event):
       *         The click event.
       */
      _onBrowseClicked(e) {
        e.preventDefault();
        e.stopPropagation();

        /*
         * Clicking on the file input itself is not reliable. There are ways
         * to make it work, but the browser actively avoids letting you do it
         * if it seems to be hidden. However, it works just fine universally to
         * click on the label.
         */
        this.$('#avatar-file-upload-browse-label').click();
      }

      /**
       * Handler for a drag enter event.
       *
       * If the configuration box is being hovered over, this will enable the
       * hover state, giving users an indication that they can drop the image
       * there.
       *
       * Args:
       *     e (jQuery.Event):
       *         The drag over event.
       */
      _onDragEnter(e) {
        e.preventDefault();
        e.stopPropagation();
        if (e.target === this.#$box[0]) {
          this.#$box.width(this.#$box.width()).addClass('drag-hover');
        }
      }

      /**
       * Handler for a drag over event.
       *
       * If the configuration box is being hovered over, this will set the drop
       * effect.
       *
       * Args:
       *     e (jQuery.Event):
       *         The drag over event.
       */
      _onDragOver(e) {
        e.preventDefault();
        e.stopPropagation();
        if (e.target === this.#$box[0]) {
          const dt = e.originalEvent.dataTransfer;
          if (dt) {
            dt.dropEffect = 'copy';
          }
        }
      }

      /**
       * Handler for a drag leave event.
       *
       * If the configuration box is being left, this will remove the hover state
       * and reset the drop effect.
       *
       * Args:
       *     e (jQuery.Event):
       *         The drag leave event.
       */
      _onDragLeave(e) {
        e.preventDefault();
        e.stopPropagation();
        if (e.target === this.#$box[0]) {
          this.#$box.removeClass('drag-hover').width('auto');
          const dt = e.originalEvent.dataTransfer;
          if (dt) {
            dt.dropEffect = 'none';
          }
        }
      }

      /**
       * Handler for a drop operation.
       *
       * This will remove the hover state and attempt to set the list of files
       * on the file input. If this fails (which will be the case on some
       * browsers with older behavior), the user will receive an alert telling
       * them it failed and to try browsing instead.
       *
       * If all goes well, the avatar will be ready for upload and the preview
       * image will be updated.
       *
       * Args:
       *     e (jQuery.Event):
       *         The drop event.
       */
      _onDrop(e) {
        e.preventDefault();
        e.stopPropagation();
        this.#$box.removeClass('drag-hover');
        const dt = e.originalEvent.dataTransfer;
        const files = dt && dt.files;
        if (!files || files.length === 0) {
          return;
        }
        if (files.length > 1) {
          alert(gettext("You can only set one file as your avatar. Please drag and drop a single file."));
          return;
        }
        const fileType = files[0].type;
        if (fileType !== 'image/png' && fileType !== 'image/jpeg' && fileType !== 'image/gif') {
          alert(gettext("This doesn't appear to be a compatible image file for avatars. Please upload a PNG, JPEG, or GIF file."));
          return;
        }
        try {
          this.#$fileInput[0].files = files;
        } catch (exc) {
          /*
           * While most modern browsers allow setting the `files` property of
           * an input field to the rest of a drag-and-drop operation, not all
           * do (I'm looking at you, IE/Edge). Older browsers will also
           * complain. So instead of outright failing, tell the user that
           * this won't work and suggest a workaround.
           */
          alert(gettext("Looks like dragging to upload a file isn't going to work with your browser. Try browsing for a file instead."));
          return;
        }
        this._setAvatarFromFile(files[0]);
      }

      /**
       * Handler for when the file input has changed.
       *
       * This will update the preview image.
       *
       * Args:
       *     e (jQuery.Event):
       *         The change event.
       */
      _onFileChanged(e) {
        const file = e.target.files[0];
        if (file) {
          this._setAvatarFromFile(file);
        }
      }

      /**
       * Set the avatar from the provided file upload.
       *
       * Args:
       *     file (File):
       *         The file that was uploaded.
       */
      _setAvatarFromFile(file) {
        const reader = new FileReader();
        reader.addEventListener('load', () => {
          this.#$preview.empty().removeClass('avatar-preview-unset').append($('<img>').attr({
            alt: gettext("Your new avatar"),
            src: reader.result
          }));
        });
        reader.readAsDataURL(file);
      }
    }) || _class$1;

    var _class;
    /**
     * A form for managing the settings of avatar services.
     */


    let resolveReady;
    const readyPromise = new Promise((resolve, reject) => {
      resolveReady = resolve;
    });

    /**
     * A form for managing the settings of avatar services.
     *
     * This form lets you select the avatar service you wish to use, as well as
     * configure the settings for that avatar service.
     */
    let SettingsFormView = Spina.spina(_class = class SettingsFormView extends Spina.BaseView {
      static events = {
        'change #id_avatar_service_id': '_onServiceChanged',
        'submit': '_onSubmit'
      };
      static instance = null;
      static ready = readyPromise;

      /**********************
       * Instance variables *
       **********************/

      /**
       * Add a configuration form to the instance.
       *
       * Args:
       *     serviceID (string):
       *         The unique ID for the avatar service.
       *
       *     formClass (constructor):
       *         The view to use for the form.
       */
      static addConfigForm(serviceID, formClass) {
        SettingsFormView.instance._configForms.set(serviceID, new formClass({
          el: $(`[data-avatar-service-id="${serviceID}"]`),
          model: SettingsFormView.instance.model
        }));
      }

      /**
       * Initialize the form.
       */
      initialize() {
        console.assert(SettingsFormView.instance === null);
        SettingsFormView.instance = this;
        this._configForms = new Map();
        this._$config = this.$('.avatar-service-configuration');
        this.listenTo(this.model, 'change:serviceID', () => this._showHideForms());

        /*
         * The promise continuations will only be executed once the stack is
         * unwound.
         */
        resolveReady();
      }

      /**
       * Validate the current form upon submission.
       *
       * Args:
       *     e (Event):
       *         The form submission event.
       */
      _onSubmit(e) {
        const serviceID = this.model.get('serviceID');
        const currentForm = this._configForms.get(serviceID);
        if (currentForm && !currentForm.validate()) {
          e.preventDefault();
        }
      }

      /**
       * Render the child forms.
       *
       * This will show the for the currently selected service if it has one.
       */
      renderForms() {
        for (const form of this._configForms.values()) {
          form.render();
        }

        /*
         * Ensure that if the browser sets the value of the <select> upon
         * refresh that we update the model accordingly.
         */
        this.$('#id_avatar_service_id').change();
        this._showHideForms();
      }

      /**
       * Show or hide the configuration form.
       */
      _showHideForms() {
        const serviceID = this.model.get('serviceID');
        const currentForm = this._configForms.get(serviceID);
        const previousID = this.model.previous('serviceID');
        const previousForm = previousID ? this._configForms.get(previousID) : undefined;
        if (previousForm && currentForm) {
          previousForm.hide();
          currentForm.show();
        } else if (previousForm) {
          previousForm.hide();
          this._$config.hide();
        } else if (currentForm) {
          currentForm.show();
          this._$config.show();
        }
      }

      /**
       * Handle the service being changed.
       *
       * Args:
       *     e (Event):
       *         The change event.
       */
      _onServiceChanged(e) {
        const $target = $(e.target);
        const serviceID = $target.val();
        this.model.set('serviceID', serviceID);
      }
    }) || _class;

    /* Define a namespace for Djblets.Avatars. */
    const Avatars = {
      FileUploadSettingsFormView,
      ServiceSettingsFormView,
      Settings,
      SettingsFormView
    };

    exports.Avatars = Avatars;

}));
//# sourceMappingURL=index.js.map
