Créer une interface d’envoi de fichiers sur mesure en JavaScript
Conservez le champ de fichier natif et ajoutez ce qu’il ne fournit pas : des aperçus d’images, une liste de sélection dont on peut retirer des éléments, et un retour lorsqu’une image ne peut pas être lue. Cet exemple vous permet de remplacer ou de vider une sélection pendant le chargement des aperçus, sans qu’un résultat plus ancien ne modifie la nouvelle liste. Les fichiers restent dans le navigateur ; cette page ne les soumet pas.
Décider de ce que prend en charge votre interface d’envoi de fichiers
Un champ natif prend déjà en charge la sélection de plusieurs fichiers.
Nous le garderons visible pour l’accès au clavier et construirons une liste d’aperçus autour de ses
objets File.
Choisir ou déposer des fichiers remplace la sélection précédente.
Remove supprime une ligne, et
Clear selection les supprime toutes. Annuler le sélecteur laisse
la liste inchangée.
La démo accepte jusqu’à 10 images JPEG, PNG ou GIF non vides à la fois, chacune d’au plus 2 MiB.
Elle vérifie le type MIME déclaré, une courte signature et la capacité du navigateur à décoder un
aperçu. Ces vérifications servent de retour sur la sélection, pas de preuve que le contenu est sûr.
L’attribut accept n’est qu’une indication pour le sélecteur.
Un petit fichier compressé peut tout de même se décoder en une grande image ; la limite en octets ne
borne pas la mémoire occupée par l’image décodée.
Il vous faut un navigateur, un shell POSIX tel que Bash, et Node.js 24.15 ou plus récent dans la
branche 24.x, ou la version 26.5 ou plus récente dans la branche 26.x. Aucune dépendance ni étape de
build n’est nécessaire. Conservez l’extension .mts du serveur :
Node traite ce fichier comme un module ES
même au sein d’un projet CommonJS.
Depuis le répertoire où vous rangez vos expériences, créez un nouveau dossier :
mkdir file-preview-demo
Si ce répertoire existe déjà, arrêtez-vous et choisissez un autre nom plutôt que d’écraser ses
fichiers. Enregistrez les quatre exemples suivants dans file-preview-demo sous les noms de fichiers
indiqués.
Mettre en place la structure HTML
Enregistrez sous index.html. Le champ natif doté d’un libellé constitue l’alternative au
clavier au dépôt de fichiers. La liste conserve un retour par fichier, et la zone d’état non
intrusive annonce la sélection en cours.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image selection preview</title>
<link rel="stylesheet" href="styles.css" />
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Preview selected images</h1>
<section id="drop-zone" aria-label="Drop images">
<label for="file-input">Choose images</label>
<input id="file-input" type="file" multiple accept="image/jpeg,image/png,image/gif"
aria-describedby="file-help" />
<p id="file-help">Choose or drop up to 10 JPEG, PNG, or GIF images, each up to 2 MiB.</p>
</section>
<button id="clear-selection" type="button" disabled>Clear selection</button>
<p id="status" role="status" aria-atomic="true">No files selected. Nothing has been uploaded.</p>
<ul id="file-preview" aria-label="Selected images"></ul>
<noscript>Enable JavaScript to preview and manage your selection.</noscript>
</main>
</body>
</html>
Styliser le champ de fichier avec CSS
Enregistrez sous styles.css. Le bouton du sélecteur peut être stylisé sans masquer le champ.
Les couleurs système suivent le thème clair ou sombre du navigateur, et les noms de fichiers longs
se replient à l’intérieur de chaque ligne.
:root { color-scheme: light dark; }
body {
font: 1rem/1.5 system-ui, sans-serif;
max-width: 42rem;
margin: 2rem auto;
padding: 0 1rem;
color: CanvasText;
background: Canvas;
}
#drop-zone { border: 2px dashed currentColor; padding: 1rem; }
#drop-zone.dragover { outline: 3px solid Highlight; }
label { display: block; font-weight: bold; }
input { max-width: 100%; }
button, input::file-selector-button { font: inherit; padding: 0.5rem; }
button { margin-block: 0.5rem; }
:focus-visible { outline: 3px solid Highlight; outline-offset: 3px; }
#file-preview { list-style: none; padding: 0; }
#file-preview li { border-block-start: 1px solid currentColor; padding-block: 1rem; }
#file-preview p { overflow-wrap: anywhere; }
#file-preview img { width: 8rem; height: 8rem; object-fit: contain; max-width: 100%; }
Implémenter le JavaScript pour enrichir les fonctionnalités
Enregistrez sous script.js. Chaque ligne possède son propre lecteur et sa propre URL d’objet.
Libérer une ligne la marque comme inactive avant d’interrompre une lecture en cours et de libérer
son URL. Les deux phases asynchrones vérifient cet indicateur avant de modifier l’état visible, de
sorte qu’une lecture de signature ou un décodage d’image tardif ne peut pas faire réapparaître une
ligne retirée.
FileReader.abort()
annule une lecture en cours ; la gestion de son événement d’interruption permet aussi de régler
notre promesse. Le décodage d’image n’a pas de méthode d’annulation équivalente ici ; nous ignorons
donc son résultat si la ligne a été libérée.
img.decode()
nous permet d’afficher la miniature seulement une fois le décodage réussi.
const fileInput = document.getElementById('file-input')
const preview = document.getElementById('file-preview')
const clearButton = document.getElementById('clear-selection')
const dropZone = document.getElementById('drop-zone')
const status = document.getElementById('status')
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif']
const maxSize = 2 * 1024 * 1024
let entries = []
function updateStatus() {
clearButton.disabled = entries.length === 0
const ready = entries.filter((entry) => entry.state === 'ready').length
const checking = entries.filter((entry) => entry.state === 'checking').length
const rejected = entries.filter((entry) => entry.state === 'rejected').length
status.textContent = entries.length === 0
? 'No files selected. Nothing has been uploaded.'
: `${ready} previews ready, ${checking} checking, ${rejected} rejected. Nothing has been uploaded.`
}
function dispose(entry) {
entry.active = false
if (entry.reader.readyState === FileReader.LOADING) entry.reader.abort()
if (entry.url !== null) URL.revokeObjectURL(entry.url)
entry.url = null
entry.image.removeAttribute('src')
}
function clearSelection() {
for (const entry of entries) dispose(entry)
entries = []
preview.replaceChildren()
fileInput.value = ''
updateStatus()
}
function removeEntry(entry) {
const index = entries.indexOf(entry)
const focusTarget = entries[index + 1]?.remove ?? entries[index - 1]?.remove ?? fileInput
entries.splice(index, 1)
dispose(entry)
entry.row.remove()
updateStatus()
focusTarget.focus()
}
function createEntry(file) {
const row = document.createElement('li')
const image = document.createElement('img')
image.alt = `Preview of ${file.name}`
image.hidden = true
const name = document.createElement('p')
name.textContent = file.name
const message = document.createElement('p')
message.textContent = 'Checking image…'
const remove = document.createElement('button')
remove.type = 'button'
remove.textContent = 'Remove'
remove.setAttribute('aria-label', `Remove ${file.name}`)
const entry = {
file, row, image, message, remove,
reader: new FileReader(), url: null, active: true, state: 'checking',
}
remove.addEventListener('click', () => removeEntry(entry))
row.append(image, name, message, remove)
preview.appendChild(row)
return entry
}
function finish(entry, state, message) {
entry.state = state
entry.message.textContent = message
updateStatus()
}
function checkFileSignature(file, reader = new FileReader()) {
return new Promise((resolve) => {
function complete(valid) {
reader.onload = reader.onerror = reader.onabort = null
resolve(valid)
}
reader.onerror = reader.onabort = () => complete(false)
reader.onload = () => {
const bytes = new Uint8Array(reader.result)
if (bytes.length < 4) return complete(false)
const header = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
const types = { '89504e47': 'image/png', '47494638': 'image/gif' }
const detectedType = header.startsWith('ffd8ff') ? 'image/jpeg' : types[header]
complete(detectedType === file.type)
}
reader.readAsArrayBuffer(file.slice(0, 4))
})
}
async function loadPreview(entry) {
const valid = await checkFileSignature(entry.file, entry.reader)
if (!entry.active) return
if (!valid) {
finish(entry, 'rejected', 'Cannot read this file, or its signature does not match its MIME type.')
return
}
entry.url = URL.createObjectURL(entry.file)
entry.image.src = entry.url
try {
await entry.image.decode()
if (!entry.active) return
entry.image.hidden = false
finish(entry, 'ready', 'Preview ready.')
} catch {
if (!entry.active) return
URL.revokeObjectURL(entry.url)
entry.url = null
entry.image.removeAttribute('src')
finish(entry, 'rejected', 'Image could not be decoded. Choose another file.')
}
}
function choose(files) {
if (files.length === 0) return
clearSelection()
if (files.length > 10) {
status.textContent = 'Choose no more than 10 files at once. Nothing has been uploaded.'
return
}
for (const file of files) {
const entry = createEntry(file)
entries.push(entry)
if (!allowedTypes.includes(file.type)) {
finish(entry, 'rejected', 'Choose a JPEG, PNG, or GIF with a recognized MIME type.')
} else if (file.size === 0 || file.size > maxSize) {
finish(entry, 'rejected', 'The image must be nonempty and no larger than 2 MiB.')
} else {
void loadPreview(entry)
}
}
updateStatus()
}
fileInput.addEventListener('change', () => {
choose(Array.from(fileInput.files))
// Retain the File objects ourselves so choosing the same files can fire change again.
fileInput.value = ''
})
clearButton.addEventListener('click', () => {
clearSelection()
fileInput.focus()
})
dropZone.addEventListener('dragover', (event) => {
event.preventDefault()
dropZone.classList.add('dragover')
})
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'))
dropZone.addEventListener('drop', (event) => {
event.preventDefault()
dropZone.classList.remove('dragover')
choose(Array.from(event.dataTransfer.files))
})
window.addEventListener('pagehide', clearSelection)
Les noms de fichiers passent par textContent, si bien qu’un nom contenant du HTML s’affiche comme
du texte.
Gardez les URL d’objet actives tant que leurs images sont disponibles.
Remove, Clear selection,
le remplacement, l’échec du décodage et la sortie de la page les libèrent.
Recharger ou quitter la page efface la sélection.
La vérification sur quatre octets ne reconnaît qu’un préfixe. Même un décodage réussi par le navigateur peut tolérer un fichier endommagé ; aucune des deux vérifications ne prouve que l’image est intacte ni qu’elle peut être conservée sans risque. Les lignes rejetées restent visibles avec leur explication et peuvent toujours être retirées.
Servir la page d’aperçu localement
Enregistrez sous server.mts. Ce serveur ne sert que les trois fichiers du navigateur et ne
dispose d’aucun point de terminaison d’envoi. Il choisit par défaut un port de bouclage disponible ;
vous pouvez définir PORT si vous avez besoin d’un port fixe.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
async function main(): Promise<void> {
const portText = process.env.PORT ?? '0'
const port = Number(portText)
if (!/^\d+$/.test(portText) || !Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer from 0 to 65535.')
}
const assets = new Map<string, { body: Buffer; type: string }>()
for (const [route, file, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/styles.css', 'styles.css', 'text/css'],
['/script.js', 'script.js', 'text/javascript'],
]) {
assets.set(route, { body: await readFile(new URL(file, import.meta.url)), type })
}
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/favicon.ico') {
res.writeHead(204).end()
return
}
const asset = assets.get(req.url ?? '')
if (req.method !== 'GET' || !asset) {
res.writeHead(404).end('Not found.')
return
}
res.writeHead(200, { 'Content-Type': asset.type, 'Cache-Control': 'no-store' })
res.end(asset.body)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
console.log(`Open http://127.0.0.1:${address.port}`)
}
main().catch((error: unknown) => {
const code = error instanceof Error && 'code' in error ? error.code : ''
console.error(code === 'EADDRINUSE'
? 'Port is already in use. Choose another PORT or leave it unset.'
: 'Could not start preview server. Check PORT and the three browser files.')
process.exitCode = 1
})
Depuis le répertoire parent où vous avez créé file-preview-demo, exécutez :
(cd file-preview-demo && node server.mts)
Ouvrez l’URL affichée dans votre navigateur. Laissez le terminal ouvert, puis arrêtez le serveur
avec Ctrl+C une fois terminé ; votre shell reste dans le répertoire parent. Redémarrez après avoir
modifié un fichier du navigateur, car le serveur lit ces fichiers au démarrage. Un port occupé, une
variable PORT invalide ou un fichier manquant interrompt le démarrage avec un code de sortie
non nul au lieu d’afficher une URL prête.
Compatibilité des navigateurs
Ce tutoriel a été testé sous Linux avec Node.js 24.15.0, 26.5.0 et 26.8.1, ainsi qu’avec Chromium
145 et 152. Il utilise FileReader natif, les URL d’objet, le glisser-déposer et img.decode() ; le
sélecteur reste l’alternative sur les appareils où le dépôt de fichiers n’est pas disponible.
Testez le processus de sélection complet :
- Atteignez Choose images avec la touche Tab, ouvrez le sélecteur et choisissez deux petites images. Chaque ligne doit afficher son nom de fichier, une miniature et Preview ready. L’état doit indiquer 2 previews ready, 0 checking, 0 rejected. Nothing has been uploaded.
- Atteignez avec Tab le bouton Remove d’une ligne et activez-le. Le focus passe au bouton Remove de la ligne suivante, ou à celui de la ligne précédente s’il n’y a pas de ligne suivante, ou encore au champ de fichier si la liste est vide. Clear selection ramène aussi le focus sur le champ de fichier.
- Choisissez ou déposez une autre sélection pendant le chargement des aperçus. Les lignes précédentes disparaissent et ne peuvent pas revenir lorsque leurs lectures se terminent. Choisissez à nouveau les mêmes fichiers pour vérifier la resélection ; annuler le sélecteur doit conserver la liste actuelle.
- Essayez une image vide, un type non pris en charge, un fichier de plus de 2 MiB et une image endommagée dotée d’un préfixe reconnaissable. Leurs lignes doivent afficher une explication. Choisir plus de 10 fichiers rejette toute la sélection.
Relier la sélection à un outil d’envoi
Cette liste gère ses propres objets File et réinitialise le champ natif après chaque choix.
Soumettre ce champ dans un formulaire n’enverrait donc aucun fichier sélectionné. Pour ajouter
l’envoi, transmettez les fichiers acceptés et conservés, et coordonnez le retrait ou le remplacement
avec les requêtes en cours.
Pour un exemple complet d’envoi et de réception, poursuivez avec l’outil d’envoi JavaScript sur mesure. Il traite un seul fichier JPEG, PNG ou PDF par requête, avec un reçu de somme de contrôle ; son récepteur n’est pas un point de terminaison multi-images et n’accepte pas le GIF. Considérez-le comme un tutoriel distinct, et non comme un récepteur prêt à l’emploi pour cette liste d’aperçus. Tout service d’envoi public nécessite sa propre validation et sa propre autorisation côté serveur ; cette interface locale ne fournit ni acceptation côté serveur ni protection CSRF.
