assets_js_controllers_image-preview-controller.js
import { Controller } from "@hotwired/stimulus";
/**
* ImagePreview Stimulus Controller
*
* Provides real-time image preview functionality for file upload forms.
* Creates object URLs for selected images and displays them immediately
* for better user experience during file selection.
*
* Features:
* - Instant image preview on file selection
* - Object URL creation and management
* - Loading state management
* - Preview visibility control
* - Automatic cleanup of resources
*
* Targets:
* - file: File input element for image selection
* - preview: Image element for displaying preview
* - loading: Loading indicator element
*
* Usage:
* <div data-controller="image-preview">
* <input data-image-preview-target="file"
* data-action="change->image-preview#preview"
* type="file" accept="image/*">
* <div data-image-preview-target="loading">Loading...</div>
* <img data-image-preview-target="preview" hidden>
* </div>
*/
class ImagePreview extends Controller {
static targets = ['file', 'preview', 'loading']
static values = {
maxSize: Number,
maxSizeFormatted: String,
}
connect() {
// If this controller doesn't have explicit max size values, try to
// inherit them from the nearest enclosing file-size-validator scope.
if (!this.hasMaxSizeValue) {
const inherited = this.element.closest('[data-file-size-validator-max-size-value]')
if (inherited?.dataset?.fileSizeValidatorMaxSizeValue) {
const parsed = parseInt(inherited.dataset.fileSizeValidatorMaxSizeValue, 10)
if (!Number.isNaN(parsed)) {
this.maxSizeValue = parsed
}
}
}
if (!this.hasMaxSizeFormattedValue) {
const inherited = this.element.closest('[data-file-size-validator-max-size-formatted-value]')
if (inherited?.dataset?.fileSizeValidatorMaxSizeFormattedValue) {
this.maxSizeFormattedValue = inherited.dataset.fileSizeValidatorMaxSizeFormattedValue
}
}
}
buildOversizeMessage(file) {
const maxSize = this.maxSizeFormattedValue || this.formatBytes(this.maxSizeValue || 0)
return `The file "${file.name}" (${this.formatBytes(file.size)}) exceeds the maximum upload size of ${maxSize}.`
}
formatBytes(bytes, decimals = 2) {
if (!bytes || bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
/**
* Generate and display image preview
* Creates object URL for selected image and updates preview display
*
* @param {Event} event - Change event from file input
*/
preview(event) {
if (event.target.files.length > 0) {
const file = event.target.files[0]
if (this.hasMaxSizeValue && this.maxSizeValue > 0 && file.size > this.maxSizeValue) {
alert(this.buildOversizeMessage(file))
// Clear invalid selection so validators + UI stay consistent
event.target.value = ''
return
}
const reader = new FileReader();
reader.onload = () => {
this.previewTarget.src = reader.result; // This will be a data: URL
this.loadingTarget.classList.add("d-none");
this.previewTarget.hidden = false;
};
reader.readAsDataURL(file);
}
}
}
if (!window.Controllers) {
window.Controllers = {}
}
window.Controllers["image-preview"] = ImagePreview;