Source: src/js/views/ImageUploaderView.js

define([
  "underscore",
  "jquery",
  "backbone",
  "models/DataONEObject",
  "collections/ObjectFormats",
  "Dropzone",
  "text!templates/imageUploader.html",
  "corejs",
], function (
  _,
  $,
  Backbone,
  DataONEObject,
  ObjectFormats,
  Dropzone,
  Template,
  corejs,
) {
  /**
   * @class ImageUploaderView
   * @classdesc A view that allows a person to upload an image to the repository
   * @classcategory Views
   * @extends Backbone.View
   */
  var ImageUploaderView = Backbone.View.extend(
    /** @lends ImageUploaderView.prototype */ {
      /**
       * The type of View this is
       * @type {string}
       */
      type: "ImageUploader",

      /**
       * The HTML tag name to use for this view's element
       * @type {string}
       */
      tagName: "div",

      /**
       * The HTML classes to use for this view's element
       * @type {string}
       */
      className: "image-uploader",

      /**
       * The DataONEObject or PortalImage that is being edited
       * @type {DataONEObject|PortalImage}
       */
      model: undefined,

      /**
       * The URL for the image. If a DataONEObject model is provided to the view
       * instead, the url is automatically set to the output of DataONEObject.url()
       * @type {string}
       */
      url: undefined,

      /**
       * Text to instruct the user how to upload an image
       * @type {string[]}
       */
      uploadInstructions: ["Drag & drop an image or click here to upload"],

      /**
       * The maximum display height of the image preview. This is only used for the
       * css height propery, and doesn't influence the size of the saved image. If
       * set to false, no css height property is set.
       * @type {number}
       */
      height: false,

      /**
       * The display width of the image preview. This is only used for the
       * css width propery, and doesn't influence the size of the saved image. If
       * set to false, no css width property is set.
       * @type {number}
       */
      width: false,

      /**
       * The minimum required height of the image file. If set, the uploader will
       * reject images that are shorter than this. If null, any image height is
       * accepted.
       * @type {number}
       */
      minHeight: null,

      /**
       * The minimum required height of the image file. If set, the uploader will
       * reject images that are shorter than this. If null, any image height is
       * accepted.
       * @type {number}
       */
      minWidth: null,

      /**
       * The maximum height for uploaded files. If a file is taller than this, it
       * will be resized without warning before being uploaded. If set to null,
       * the image won't be resized based on height (but might be depending on
       * maxWidth).
       * @type {number}
       */
      maxHeight: null,

      /**
       * The maximum width for uploaded files. If a file is wider than this, it
       * will be resized without warning before being uploaded. If set to null,
       * the image won't be resized based on width (but might be depending on
       * maxHeight).
       * @type {number}
       */
      maxWidth: null,

      /**
       * The HTML tag name to insert the uploaded image into. Options are "img",
       * in which case the image is inserted as an HTML <img>, or "div", in which
       * case the image is inserted as the background of a div.
       * @type {string}
       */
      imageTagName: "div",

      /**
       * References to templates for this view. HTML files are converted to Underscore.js templates
       */
      template: _.template(Template),

      /**
       * The events this view will listen to and the associated function to call.
       * @type {Object}
       */
      events: {
        "mouseover .icon-remove.remove": "previewImageRemove",
        "mouseout  .icon-remove.remove": "previewImageRemove",
      },

      /**
       * Creates a new ImageUploaderView
       * @param {Object} options - A literal object with options to pass to the view
       * @property {DataONEObject}  options.model - Gets set as ImageUploaderView.model
       * @property {string[]}  options.uploadInstructions - Gets set as ImageUploaderView.uploadInstructions
       * @property {string}  options.url - Gets set as ImageUploaderView.url
       * @property {string}  options.imageTagName - Gets set as ImageUploaderView.imageTagName
       * @property {number}  options.height - Gets set as ImageUploaderView.height
       * @property {number}  options.width - Gets set as ImageUploaderView.width
       * @property {number}  options.minWidth - Gets set as ImageUploaderView.minWidth
       * @property {number}  options.minHeight - Gets set as ImageUploaderView.minHeight
       * @property {number}  options.maxWidth - Gets set as ImageUploaderView.maxWidth
       * @property {number}  options.maxHeight - Gets set as ImageUploaderView.maxHeight
       */
      initialize: function (options) {
        try {
          if (typeof options == "object") {
            this.model = options.model;
            this.uploadInstructions = options.uploadInstructions;
            this.url = options.url;
            this.imageTagName = options.imageTagName;
            this.height = options.height;
            this.width = options.width;
            this.minHeight = options.minHeight;
            this.minWidth = options.minWidth;
            this.maxHeight = options.maxHeight;
            this.maxWidth = options.maxWidth;

            if (!this.model) {
              this.model = new DataONEObject({
                synced: true,
              });
            }

            if (!this.url && this.model) {
              this.url = this.model.url();
            }
          }

          // Ensure the object formats are cached for uploader's use
          if (typeof MetacatUI.objectFormats === "undefined") {
            MetacatUI.objectFormats = new ObjectFormats();
            MetacatUI.objectFormats.fetch();
          }

          // Bug fix: Overwrite a dropzone function that causes a bug in Edge 16 &
          // 17 browser. If we update our dropzone with a fallback, this function
          // should return the fallback element.
          Dropzone.prototype.getExistingFallback = function () {
            return false;
          };

          // Identify which zones should be drag & drop manually
          Dropzone.autoDiscover = false;
        } catch (e) {
          console.log(
            "ImageUploaderView failed to initialize. Error message: " + e,
          );
        }
      },

      /**
       * Renders this view
       */
      render: function () {
        try {
          // Reference to the view
          var view = this,
            // The overall template which holds two sub-templates
            fullTemplate = view.template({
              height: this.height,
              width: this.width,
              uploadInstructions: this.uploadInstructions,
              imageTagName: this.imageTagName,
            }),
            // The outer template
            dropzoneTemplate = $(fullTemplate).find(".dropzone")[0].outerHTML,
            // The inner template inserted when an image is added
            previewTemplate = $(fullTemplate).find(".dz-preview")[0].outerHTML;

          // Insert the main template for this view
          view.$el.html(dropzoneTemplate);

          console.log(view.model.get("imageURL"), view.model.url());

          // Add upload & drag and drop functionality to the dropzone div.
          // For config details, see: https://www.dropzonejs.com/#configuration
          var $dropZone = view.$(".dropzone").dropzone({
            url: view.model.get("imageURL") || view.model.url(),
            acceptedFiles: "image/*",
            addRemoveLinks: false,
            maxFiles: 1,
            parallelUploads: 1,
            uploadMultiple: false,
            resizeHeight: view.maxHeight,
            resizeWidth: view.maxWidth,
            thumbnailHeight:
              view.maxHeight < view.height ? view.maxHeight : null,
            thumbnailWidth: view.maxWidth < view.width ? view.maxWidth : null,
            dictInvalidFileType:
              "This file type is not allowed. Please select an image file",
            autoProcessQueue: true,
            previewTemplate: previewTemplate,
            withCredentials: true,
            paramName: "object",
            hiddenInputContainer: this.el,

            headers: {
              "Cache-Control": null,
              "X-Requested-With": null,
              Authorization:
                MetacatUI.appUserModel.createAjaxSettings().headers
                  .Authorization,
            },

            // Override dropzone's function for showing images in the upload zone
            // so that we have the option to display them as a background images.
            // Check for minimum dimensions at this stage because dropzone has
            // calculated the file's height here.
            thumbnail: function (file, dataURL) {
              try {
                // Don't bother size check for SVG images since they're vector
                var dimCheck =
                  file.type === "image/svg+xml"
                    ? true
                    : view.checkMinDimensions(file.width, file.height);
                if (dimCheck != true) {
                  if (file.rejectDimensions) {
                    // Send reason for rejection rejectDimensions function
                    file.rejectDimensions(dimCheck);
                  }
                } else {
                  if (file.acceptDimensions) {
                    file.acceptDimensions();
                  }
                  view.showImage(file, dataURL);
                }
              } catch (e) {
                console.error(
                  "Error generating thumbnail image, error message: " + e,
                );
              }
            },

            // Dropzone will check filetype = options.acceptedFiles. Add functions
            // for when the image is too small.
            accept: function accept(file, done) {
              try {
                file.rejectDimensions = function (message) {
                  done(message);
                };
                file.acceptDimensions = function () {
                  done();
                };
              } catch (e) {
                console.error(
                  "Error during dropzone's accept function. Error code: " + e,
                );
              }
            },

            // After the file is accepted (correct filetype and min size requirements),
            // resize the image if it's too large in height or width, then
            // provide image data to a dataOne object model and calulate checksum.
            transformFile: function (file, done) {
              try {
                // Only resize images if dimensions are too large.
                // Once the image is resized (or not), save the data to the model and get a checksum.
                var resizeWidth =
                  file.width > this.options.resizeWidth
                    ? this.options.resizeWidth
                    : null;
                var resizeHeight =
                  file.height > this.options.resizeHeight
                    ? this.options.resizeHeight
                    : null;
                if (resizeHeight || resizeWidth) {
                  return this.resizeImage(
                    file,
                    resizeWidth,
                    resizeHeight,
                    this.options.resizeMethod,
                    function (blob) {
                      view.prepareD1Model(blob, file.name, file.type, done);
                    },
                  );
                } else {
                  return view.prepareD1Model(file, file.name, file.type, done);
                }
              } catch (e) {
                console.error(
                  "Error during dropzone's transformFile function. Error code: " +
                    e,
                );
              }
            },

            // Add some required formData right before the image is uploaded
            sending: function (file, xhr, formData) {
              try {
                //Create the system metadata XML & send as blob
                var sysMetaXML = view.model.serializeSysMeta();
                var xmlBlob = new Blob([sysMetaXML], {
                  type: "application/xml",
                });
                formData.append("sysmeta", xmlBlob, "sysmeta.xml");
                formData.append("pid", view.model.get("id"));
              } catch (e) {
                console.error(
                  "Error during dropzone's sending function. Error code: " + e,
                );
              }
            },

            // If there are any errors during the entire process...
            error: function error(file, message, xhr) {
              try {
                view.trigger("error");
                // Give a readable error if it's a server error
                if (xhr) {
                  console.error(message);
                  message =
                    "There was an error uploading your file. Please try again later.";
                }
                // Make sure image isn't showing (src for <img> and style for background images)
                $(file.previewElement).find(".image-container").attr({
                  src: "",
                  style: "",
                });
                // Show error using dropzone's default behaviour
                this.defaultOptions.error(file, message);
              } catch (e) {
                console.error("Problem handling error, message: " + e);
              }
            },

            init: function () {
              try {
                this.on("addedfile", function (file) {
                  // Make sure only the most recently added image is shown in the upload zone
                  view.limitFileInput();
                  // Required for parent views to use listenTo() on dropzone events
                  view.trigger("addedfile");
                });
                // Hide the remove buttons and text when an image is removed
                this.on("removedfile", function (file) {
                  view.previewImageRemove();
                  // Required for parent views to use listenTo() on dropzone events
                  view.trigger("removedfile");
                });
                this.on("success", function () {
                  view.trigger("successSaving", view.model);
                });
              } catch (e) {
                console.error(
                  "Issue initializing dropzone, error message: " + e,
                );
              }
            },
          });

          // Save the dropzone element for other functions to access later
          view.imageDropzone = $dropZone[0].dropzone;

          // Fetch the image if a URL was provided and show thumbnail
          if (view.url) {
            view.showSavedImage();
          }
        } catch (error) {
          console.error(
            "ImageUploaderView could not be rendered, error message: ",
            error,
          );
        }
      },

      /**
       * prepareD1Model - Called once an image file is resized or once it's
       * determined the the image does not need to be resized. This function adds
       * data about the image added by the user to a new DataOne model, then
       * calculates the checksum. When the checksum is finished being calculated,
       * calls the callback function (i.e. dropzone's done()).
       *
       * @param  {Blob|File} object Either the Blob or File to be saved to the server
       * @param  {string} filename the name of the file
       * @param  {string} filetype the filetype
       * @param  {function} callback a function to call once the checksum is calculated.
       */
      prepareD1Model: function (object, filename, filetype, callback) {
        try {
          var modelAttributes = {
            synced: true,
            type: "image",
            fileName: filename,
            mediaType: filetype,
            size: object.size,
            uploadFile: object,
          };

          // Each file upload must be a new DataONE object
          this.model = new DataONEObject(modelAttributes);
          this.model.updateID();
          this.model.set("obsoletes", null);
          this.model.get("accessPolicy").makePublic();

          // Start checksum, and call the callback function when it's complete
          this.model.stopListening(this.model, "checksumCalculated");
          this.model.listenToOnce(
            this.model,
            "checksumCalculated",
            function () {
              callback(object);
            },
          );
          this.model.calculateChecksum();
        } catch (exception) {
          console.log(
            "there was a problem calculating the checksum, exception: " +
              exception,
          );
        }
      },

      /**
       * limitFileInput - Ensures only the most recently added image is shown in
       * the upload zone, as we limit each zone to 1 image but dropzone is
       * designed to accept multiple files. Called whenever a file is added to a
       * dropzone element.
       */
      limitFileInput: function () {
        if (this.imageDropzone.files[1] != null) {
          this.imageDropzone.removeFile(this.imageDropzone.files[0]);
        }
      },

      /**
       * checkMinDimensions - called from dropzone's thumbnail function before the
       * image is displayed. Checks that the image meets at least the minimum
       * height and width requirements provided to view.minHeight and
       * view.minWidth.
       *
       * @param  {number} width  the image's height.
       * @param  {number} height the image's width.
       * @return {string|boolean}  returns true if the image is at least as wide as and as tall as the given height and width. Otherwise returns an error message to display to the user.
       */
      checkMinDimensions: function (width, height) {
        try {
          if (width < this.minWidth && height < this.minHeight) {
            return (
              "This image is too small. Please choose an image that's at least " +
              this.minWidth +
              "px wide and " +
              this.minHeight +
              "px tall."
            );
          } else if (width < this.minWidth) {
            return (
              "This image is too narrow. Please choose an image that's at least " +
              this.minWidth +
              "px wide."
            );
          } else if (height < this.minHeight) {
            return (
              "This image is too short. Please choose an image that's at least " +
              this.minHeight +
              "px tall."
            );
          } else {
            // minimum height and width are met. If too large, then image will be resized.
            return true;
          }
        } catch (error) {
          console.log(
            "Error checking the min dimensions of added file. Error message:" +
              error,
          );
          // Better to show an image that's too small in this case.
          return true;
        }
      },

      /**
       * showImage - General function for displaying an image file in the upload zone, whether
       * just added or already uploaded. This is the function that we use to override
       * dropzone's thumbnail() function. It displays the image as the background of
       * a div if this view's imageTagName attribute is set to "div", or as an image
       * element if imageTagName is set to "img".
       * @param  {object} file    Information about the image file
       * @param  {string} dataURL A URL for the image to be displayed
       */
      showImage: function (file, dataURL) {
        try {
          // Don't show files that are the wrong size or type
          if (!this.url && !file.accepted) {
            return;
          }

          var previewEl = $(file.previewElement).find(".image-container")[0];

          if (this.imageTagName == "img") {
            previewEl.src = dataURL;
          } else if (this.imageTagName == "div") {
            $(previewEl).css("background-image", "url(" + dataURL + ")");
          }
        } catch (error) {
          console.log(error);
          this.showError($(file.previewElement));
        }
      },

      /**
       * Display an image in the upload zone that's already saved. This gets called
       * when an image url is provided to this view.
       */
      showSavedImage: function () {
        try {
          //If there is no URL or the model hasn't been saved yet, then don't show the image
          if (!this.url || this.model.isNew()) {
            return;
          }

          // A mock image file to identify the image provided to this view
          var imageFile = {
            url: this.url,
          };

          // Add it to filelist so excess images can be removed if needed
          this.imageDropzone.files[0] = imageFile;
          // Call the default addedfile event handler
          this.imageDropzone.emit("addedfile", imageFile);
          // Show the thumbnail of the file
          this.imageDropzone.emit("thumbnail", imageFile, imageFile.url);
          // Make sure that there is no progress bar, etc...
          this.imageDropzone.emit("complete", imageFile);
        } catch (error) {
          console.log("image could not be displayed, error message: " + error);
          // When the preview image fails to render, show some explanatory text
          this.showError($(this.imageDropzone.element));
        }
      },

      /**
       * showError - Indicates to the user that the image uploader may not work
       * due to browser issues.
       * @param {jQuery} dropzoneEl - The dropzone element to show the error for.
       */
      showError: function (dropzoneEl) {
        dropzoneEl.addClass("error");
        dropzoneEl
          .find(".dz-error-message span")
          .text("Error previewing image");
        dropzoneEl.tooltip({
          placement: "bottom",
          trigger: "hover",
          title:
            "Image previews cannot be shown. Your browser may be out-of-date.",
        });
      },

      /**
       * previewImageRemove - When the user hovers over the remove button,
       * indicates to the user that the button will remove the image by 1) changing
       * the upload instruction text to a message about removing the image,
       * and 2) adding a warning class to the message div.
       */
      previewImageRemove: function (e) {
        try {
          if (e) {
            this.$el.toggleClass("remove-preview");
          } else {
            this.$el.removeClass("remove-preview");
          }
        } catch (error) {
          console.log(error);
        }
      },
    },
  );

  return ImageUploaderView;
});