1

I've been building an image uploader that is now theoretically complete, but it is not working in Safari.

I initially thought the problem was because DataTransfer, or more specifically dataTransfer.files was not supported, but this is now supported in Desktop Safari (Version 14.1 +). Although there is no support at all in iOS I will be having the file-picker only (no drag and drop) on iOS so this isn't an issue.

The Problem / Scenario on Desktop Safari

  1. When a user clicks the 'select files' link and submits the images, it all works OK.

  2. When a user drags and drops images the file preview images are duplicated (e.g. 2 images shows 4 previews). When submitted, only the 2 images are uploaded though (which is correct obviously).

  3. When a user clicks the 'select files' link instead of using the drag and drop functionality and then deletes an image from the image previews, the image previews are then duplicated (similar to the way the are in point 2. above).

Logically this would make me think the problem is with the change event listener, but I can't seem to fix it.

Any help would be hugely appreciated.

const dropZone = document.getElementById("drop-zone"),
  showSelectedImages = document.getElementById("show-selected-images"),
  fileUploader = document.getElementById("standard-upload-files");

dropZone.addEventListener("click", (evt) => {
  // assigns the dropzone to the hidden input element so when you click 'select files' it brings up a file picker window
  fileUploader.click();
});

// Prevent browser default when draging over
dropZone.addEventListener("dragover", (evt) => {
  evt.preventDefault();
});

fileUploader.addEventListener("change", (evt) => {
  // this function is further down but declared here and shows a thumbnail of the image
  [...fileUploader.files].forEach(updateThumbnail);
});

function getFileListItems(files) {
  var transferObject = new ClipboardEvent("").clipboardData || new DataTransfer()
  for (var i = 0; i < files.length; i++) transferObject.items.add(files[i])
  return transferObject.files;
}

dropZone.addEventListener("drop", (evt) => {
  evt.preventDefault();
  // assign dropped files to the hidden input element
  if (evt.dataTransfer.files.length) {
    fileUploader.files = getFileListItems([...fileUploader.files, ...evt.dataTransfer.files]);
  }
  // function is declared here but written further down
  [...evt.dataTransfer.files].forEach(updateThumbnail);
});

// updateThumbnail function that needs to be able to handle multiple files
function updateThumbnail(file) {
  if (file.type.startsWith("image/")) {
    let uploadImageWrapper = document.createElement("article"),
      removeImage = document.createElement("div"),
      thumbnailElement = new Image();

    // 'x' that deletes the image
    removeImage.classList.add("remove-image");
    removeImage.innerHTML =
      '<svg id="remove-x" viewBox="0 0 150 150"><path fill="#000" d="M147.23,133.89a9.43,9.43,0,1,1-13.33,13.34L75,88.34,16.1,147.23A9.43,9.43,0,1,1,2.76,133.89L61.66,75,2.76,16.09A9.43,9.43,0,0,1,16.1,2.77L75,61.66,133.9,2.77a9.42,9.42,0,1,1,13.33,13.32L88.33,75Z"/></svg>';

    // image thumbnail
    thumbnailElement.classList.add("drop-zone__thumb");
    thumbnailElement.src = URL.createObjectURL(file);

    // appending elements
    showSelectedImages.append(uploadImageWrapper); // <article> element
    uploadImageWrapper.append(removeImage); // 'x' to delete
    uploadImageWrapper.append(thumbnailElement); // image thumbnail

    // Delete images
    removeImage.addEventListener("click", (evt) => {
      if (evt.target) {
        var deleteImage = removeImage.parentElement;
        deleteImage.remove();
        fileUploader.files = getFileListItems([...fileUploader.files].filter(f => file !== f));
      }
    });
  }
} // end of 'updateThumbnail' function
body {
  margin: 0;
  display: flex;
  justify-content: center;
  width: 100%;
}

form {
  width: 30%;
}

#drop-zone {
  border: 1px dashed;
  width: 100%;
  padding: 1rem;
  margin-bottom: 1rem;
}

.select-files {
  text-decoration: underline;
  cursor: pointer;
}

/* images that are previewed prior to form submission*/

.drop-zone__thumb {
  width: 200px;
  height: auto;
  display: block;
}

#remove-x {
  width: 1rem;
  height: 1rem;
}

#submit-images {
  margin: 1rem 0;
}

#show-selected-images {
  display: flex;
}
<form id="upload-images-form" enctype="multipart/form-data" method="post">
  <h1>Upload Your Images</h1>
  <div id="drop-zone" class="drop-zone flex">
    <p class="td text-center">DRAG AND DROP IMAGES HERE</p>
    <p class="td text-center" style="margin: 0">Or</p>
    <p class="tl text-center select-files text-bold pointer">Select Files</p>
  </div>
  <input id="standard-upload-files" style="display:none" style="min-width: 100%" type="file" name="standard-upload-files[]" multiple>
  <input type="submit" name="submit-images" id="submit-images" value="SUBMIT IMAGES">
  <div id="show-selected-images"></div>
</form>
Kaiido
  • 123,334
  • 13
  • 219
  • 285
pjk_ok
  • 618
  • 7
  • 35
  • 90

1 Answers1

2

The problem is that Safari fires a new (trusted o.O) event when you set the .files property of your <input>. (BUG 180465)

Given they do fire that event synchronously, you could workaround that by using a simple flag:

let ignoreEvent = false; // our flag

const inp = document.querySelector("input");
inp.onchange = (evt) => {
  if (ignoreEvent) {
    console.log("this event should be ignored");
  }
  else {
    console.log("A true change event");
  }
};
const dT = new DataTransfer();
dT.items.add(new File(['foo'], 'programmatically_created.txt'));

// right before setting the .files
ignoreEvent = true;
inp.files = dT.files;
// the change event fired synchronously,
// lower the flag down
ignoreEvent = false;
<input type="file" id="inp">

Which in Safari will output "This event should be ignored".

Now, I can't help but remind that setting the FileList of an <input> like that is still a hack. You really shouldn't use this in production. (I did amend my answer to make it clearer.)

So, please go with a simple Array and a FormData for the upload. Moreover since you don't even show the original file picker <input>.


You apparently already did copy-paste a previous answer that you did not understand, so to avoid you falling in this (comprehensible) trap again, I'll just highlight the key points you will need for your implementation, and I won't give you a working example, on purpose.

So first you need to define an Array ([]) which will store the files your user did select and which is accessible to all of the following functions.
In both the drop and input's change events, you will update this Array with the Files newly selected (you can either append the new Files, or replace them, it's your call).
Then you will add a new event listener to the <form> element's submit event. This event will fire when the submit button is clicked.
Its default action would be to POST the <form>'s content to your server, but since we want to include our own Array and not the <input>'s file list (since it could actually be empty), we don't want this default action to occur, so we will call event.preventDefault() to avoid that.
Now, we still want to send something to the server, so we will build a new form, from scratch.
That's where we'll create a new FormData object, and use its append() method to store our files. The first argument of this method should be the field name, which is also the <input>'s name attribute value. The second one should be a single file, so we'll need to call this method as many times as we have items in our Array. Since we deal with File objects, we don't need the third argument.

Once our FormData is complete, we just have to upload it to the server.
To do so you, in modern browsers you can simply use the fetch() method, passing in the URL to the server, and an option bag looking roughly like

{
  method: "POST",
  body: formData // the FormData object we just created
}

If you need to support older browsers, you can also do the same with an XMLHttpRequest object, passing the FormData in its .send(formData) method.

Since OP apparently can't get out of this despite the step by step explanations, here is a jsfiddle.

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • @TheChewy I see... I did add the steps for you to follow to implement this. I prefer to teach how to fish rather than just giving you the fish. – Kaiido Aug 09 '21 at 03:48
  • @paul_cambs what? You just have to translate every sentence to a line of code, 1:1. I link to the docs of every non basic steps. OP has already most of what they need for the remaining. Why should I reference soneone else's tutorial? My answer is self sufficient. I identified the issue, I proposed a workaround and explained a true solution to what they are doing. What are you not understanding in there? – Kaiido Aug 12 '21 at 23:07
  • And @paul_cambs, I'm not sure I read your comment correctly, but if all the tutorials about d&d you saw were using the dataTransfer interface it's because d&d exposes the dragged data through it. OP still need to use this interface to get their dropped files, but they already have that part of the code. The hack, that I now regret [I did expose to the world,](https://stackoverflow.com/a/47172409/3702797) is to use the constructor of this interface to set a the FileList of an . This should not be done (yet). – Kaiido Aug 12 '21 at 23:20
  • 1
    It is not "hard". The problem is that you've been told to use some "smarty" hack while you don't need it at all. In my last comment I am only talking about the `getFileListItems` function as "a hack". In the drop event, the `evt.dataTransfer` is actually a DataTransfer object too, but this one is fine, it has been created by the browser during the event. You need to use that one. But you don't have to create your own as is being done in `getFileListItems`. Anyway, I did add a link to a jsfiddle in my answer, with working code. Please read it and try to understand every steps before using it. – Kaiido Aug 16 '21 at 01:42
  • @paulo_cam could you describe precisely what you did and what you saw? Did the console shown that it would send a file that shouldn't be there anymore or did you check the actual request sent? Did you temper in any way with the fiddle? It definitely works here (and I'm quite confident it does everywhere). – Kaiido Sep 18 '21 at 02:06