Aller au contenu

Upload d’images et JavaScript⚓︎

Un tutoriel ?

Cette page est davantage la description pas à pas d’une page qui n’a jamais été mise en production. Je souhaitais explorer le téléversement d’une image avec son affichage préalable et la possibilité de l’ajouter par glisser-déposer. Il y aurait beaucoup de choses à peaufiner avant d’avoir une version utilisable (ne serait-ce qu’au niveau de l’accessibilité) mais ça peut vous inspirer.

✨ L’exemple complet⚓︎

🐣 2022-08

On commence avec l’exemple complet car c’est toujours chouette de jouer avec en direct :

Ouvrir dans un nouvel onglet

Vous pouvez essayer d’ajouter des images, on de les déposer dans la zone dédiée. Rien n’est envoyé sur le serveur. On a une prévisualisation des images sélectionnées et la capacité de le faire au clavier (ce qui est toujours un bon exercice d’accessibilité).

On commence par le HTML et la CSS⚓︎

🐣 2022-08

On commence par une base qui permet de soumettre des images sans aucun JavaScript pour faire de l’amélioration progressive :

images-form.html
<form action="/" method="post"
  enctype="multipart/form-data"><!-- (1)! -->
  <fieldset>
    <input
      type="file"
      name="files"
      id="files"
      accept="image/*"
      multiple
      data-max-size-kb="500"
    /><!-- (2)! -->
    <label for="files" class="btn-like"><!-- (3)! -->
      Click to upload image(s) or drag & drop below
    </label>
    <div role="alert" aria-live="polite" hidden></div><!-- (4)! -->
    <output></output><!-- (5)! -->
    <canvas>Drag & drop image here</canvas><!-- (6)! -->
  </fieldset>
  <input type="submit" name="submit" />
</form>
  1. On n’oublie pas d’indiquer que l’on souhaite envoyer des fichiers.
  2. On accepte toutes formes d’images et on ajoute un attribut pour spécifier la taille maximale (qui sera appliquée en JS).
  3. On style le label comme un bouton car on va utiliser une astuce.
  4. On prépare un endroit où afficher nos erreurs.
  5. On utilise l’élément output pour la prévisualisation de l’image, qui permet d’afficher le résultat d’une action utilisateur·ice.
  6. On utilise un élément canvas pour délimiter la zone où l’on va pouvoir glisser-déposer les images.

Au sujet du label stylé comme un bouton, il s’agit d’une astuce issue de cet article qui consiste à masquer l’input en lui-même car le label a les mêmes propriétés au clic et que le bouton natif n’est pas si évident à styler. Surtout lorsqu’on veut garder la cohérence avec la zone de dépôt.

Je n’ajoute pas la CSS complète ici car c’est vite verbeux (elle est dans l’exemple), je veux juste montrer ce qui est nécessaire pour que le label nous serve à sélectionner des images :

images-form.css
input[type='file'] {
  width: 0.1px;
  height: 0.1px;
  opacity: 0;
  overflow: hidden;
  position: absolute;
  z-index: -1;
}
input[type='file'] + label {
  font-size: 1.5rem;
  display: inline-block;
}

input[type='file']:focus + label,
input[type='file'] + label:hover {
  background-color: darkblue;
}
input[type='file'] + label {
  cursor: pointer; /* "hand" cursor */
}
input[type='file']:focus + label {
  outline: thin dotted;
  outline: -webkit-focus-ring-color auto 5px;
}
input[type='file']:focus-within + label {
  outline: thin dotted;
  outline: -webkit-focus-ring-color auto 5px;
}
input[type='file'] + label * {
  pointer-events: none;
}

Pouvoir cliquer sur la zone de dépôt⚓︎

🐣 2022-08

images-selector-dropzone.js
1
2
3
4
5
6
7
;(function openFileSelectorOnDropzoneClick(targetSelector, inputSelector) {
  'use strict'

  const target = document.querySelector(targetSelector)
  const input = document.querySelector(inputSelector)
  target.addEventListener('click', (event) => input.click(), false)
})('canvas', 'input[type="file"]')

C’est assez direct, on simule le clic sur l’input lorsqu’on clic sur le canvas défini plus haut.

Avoir un retour au glisser⚓︎

🐣 2022-08

Une autre chose qui est pertinente, c’est de pouvoir avoir un retour visuel lorsqu’on passe l’image sur la zone où l’on peut la déposer.

Ici on ajoute une classe active à notre élément lorsqu’on passe dessus, la principale difficulté étant de devoir écouter plusieurs évènements pour la même action.

images-highlight-dropzone.js
;(function highlightOnDragOrFocus(targetSelector, className) {
  'use strict'

  function addEventListenerMulti(element, events, fn) { // (3)!
    /* https://stackoverflow.com/a/45096932 */
    events
      .split(' ')
      .forEach((event) => element.addEventListener(event, fn, false))
  }

  const target = document.querySelector(targetSelector)
  if (target) {
    addEventListenerMulti(target, 'dragenter dragover focus', (event) => {
      event.stopPropagation()
      event.preventDefault()
      target.classList.add(className) // (1)!
    })
    addEventListenerMulti(target, 'dragleave blur', (event) => {
      event.stopPropagation()
      event.preventDefault()
      target.classList.remove(className) // (2)!
    })
  }
})('canvas', 'active')
  1. Ajout de la classe active au canvas.
  2. Retrait de la classe active au canvas.
  3. Fragment qui permet d’écouter une même fonction sur plusieurs évènements.

Prévisualiser les images⚓︎

🐣 2022-08

Ici c’est un gros morceau. On écoute un change sur l’input ou un drop sur notre dropzone (élément canvas). À partir de là, on va vérifier si la taille limite est dépassée et afficher un message d’erreur si c’est le cas ou afficher l’image téléversée si elle est inférieure à la taille autorisée.

images-preview-upload.js
;(function handleImageSelection(
  inputSelector,
  outputSelector,
  dropzoneSelector,
  errorsSelector
) {
  'use strict'

  const input = document.querySelector(inputSelector)
  const output = document.querySelector(outputSelector)
  const dropzone = document.querySelector(dropzoneSelector)
  const errors = document.querySelector(errorsSelector)

  input.addEventListener(
    'change',
    (event) => {
      event.stopPropagation()
      event.preventDefault()
      handleImages(event.target.files)
    },
    false
  )
  dropzone.addEventListener(
    'drop',
    (event) => {
      event.stopPropagation()
      event.preventDefault()
      // TODO: filter out files not matching the
      // `accept="image/*"` pattern.
      handleImages(event.dataTransfer.files)
      input.files = event.dataTransfer.files // (1)!
    },
    false
  )

  function handleImages(images) {
    Array.from(images).forEach((image) => {
      const isOversized = checkOversize(image.size)
      if (isOversized) return
      displayImage(image)
    })
  }

  function checkOversize(imageSize) {
    const maxSizeKb = Number(input.dataset.maxSizeKb)
    const isOversized = Math.round(imageSize / 1024) > maxSizeKb

    if (isOversized) {
      errors.removeAttribute('hidden')
      errors.innerHTML = `<p>Oversized! ${maxSizeKb}Kb is the limit</p>`
      input.value = ''
      return true
    } else {
      errors.innerHTML = ''
      errors.setAttribute('hidden', 'hidden')
      dropzone.setAttribute('hidden', 'hidden')
      return false
    }
  }

  function displayImage(image) {
    // No need for a FileReader in that case:
    // https://developer.mozilla.org/en-US/docs/Web/API/↩
    // File_API/Using_files_from_web_applications↩
    // #example_using_object_urls_to_display_images
    const figure = document.createElement('figure')

    const img = document.createElement('img')
    img.file = image // Required for future upload, fragile?
    img.src = window.URL.createObjectURL(image)
    img.onload = (event) => window.URL.revokeObjectURL(event.target.src)
    img.alt = 'Image preview'
    figure.appendChild(img)

    const figcaption = document.createElement('figcaption')
    figcaption.textContent = image.name
    figure.appendChild(figcaption)

    output.appendChild(figure)
  }
})('input[type="file"]', 'output', 'canvas', '[role="alert"]')
  1. Requis pour que les fichiers déposés soient ajoutés à l’input !

Visualiser la progression⚓︎

🐣 2022-08

Pour finir, on voudrait pouvoir donner un retour à l’utilisateur·ice au moment du téléversement avec un pourcentage d’avancement.

images-progress-upload.js
;(function uploadImageWithProgress(formSelector, imagesSelector) {
  'use strict'

  const form = document.querySelector(formSelector)
  form.addEventListener(
    'submit',
    (event) => {
      event.stopPropagation()
      event.preventDefault()
      const images = document.querySelectorAll(imagesSelector)
      Array.from(images).forEach((image) => {
        const options = {
          method: event.target.method,
          body: image.file,
        }
        console.log(`Uploading "${image.file.name}"`)
        request(event.target.action, options, displayPercentage)
          .then(console.log.bind(console))
          .catch(console.log.bind(console))
      })
    },
    false
  )

  function request(url, options = {}, onProgress) {
    // See https://github.com/github/fetch/issues/89#issuecomment-256610849
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest()
      xhr.open(options.method || 'get', url)
      for (let header in options.headers || {})
        xhr.setRequestHeader(header, options.headers[header])
      xhr.onload = (event) => resolve(event.target.responseText)
      xhr.onerror = reject
      if (xhr.upload && onProgress) xhr.upload.onprogress = onProgress
      xhr.send(options.body)
    })
  }

  function displayPercentage(event) {
    // TODO: use a `progress` HTML element.
    if (event.lengthComputable) {
      const percentage = Math.round((event.loaded * 100) / event.total)
      console.log(percentage)
    }
  }
})('form', 'output img')

Visualiser la progression

Ça serait pas mal d’avoir un endroit où faire le téléversement pour aller jusqu’au bout de la démo. Peut-être via httpbin ?

Aller plus loin/différemment⚓︎


Dernière mise à jour: 2023-09-05