1import { Controller } from "@hotwired/stimulus" 2import _ from "lodash" 3 4const croppieScriptURL = "https://cdnjs.cloudflare.com/ajax/libs/croppie/2.6.5/croppie.min.js" 5const croppieStyleURL = "https://cdnjs.cloudflare.com/ajax/libs/croppie/2.6.5/croppie.min.css" 6const imageTypes = new Set(["image/png", "image/jpg", "image/jpeg", "image/webp"]) 7 8export default class extends Controller { 9 static targets = ["errorLabel", "imageInput", "purgedInput", "previewImage", "placeholder"] 10 11 static values = { 12 maxSize: String, 13 cropShape: String, 14 } 15 16 get isCroppieLoaded() { 17 return document.head.querySelector(`script[src="${croppieScriptURL}"]`) !== null 18 } 19 20 get maxSize() { 21 console.log("MAX SIZE", this.maxSizeValue) 22 // Default to 5 MB 23 if (!this.hasMaxSizeValue) return 5 * Math.pow(2, 20) 24 // Parse value 25 const max = this.maxSizeValue 26 const maxNumber = parseInt(max) 27 // Invalid value, use default 28 if (isNaN(maxNumber)) return 5 * Math.pow(2, 20) 29 // Check suffix 30 const suffix = max.toLowerCase()[max.length - 1] 31 switch (suffix) { 32 case "k": 33 return maxNumber * Math.pow(2, 10) // Kilo 34 case "m": 35 return maxNumber * Math.pow(2, 20) // Mega 36 case "g": 37 return maxNumber * Math.pow(2, 30) // Giga 38 default: 39 return maxNumber // exact value 40 } 41 } 42 43 get maxSizeLabel() { 44 if (!this.hasMaxSizeValue) return "5 MB" 45 return this.maxSizeValue.toUpperCase().replace(/([KMG])/, " $1B") 46 } 47 48 get cropShape() { 49 if (!this.hasCropShapeValue) return "circle" 50 if (!["circle", "square"].includes(this.cropShapeValue)) return "circle" 51 return this.cropShapeValue 52 } 53 54 async loadCroppie() { 55 if (this.isCroppieLoaded) return 56 // Create script and link elements 57 const script = document.createElement("script") 58 const link = document.createElement("link") 59 link.rel = "stylesheet" 60 link.type = "text/css" 61 // Load element asynchronously 62 const loadElement = async (elem, src) => { 63 return new Promise((resolve, reject) => { 64 elem.onload = resolve 65 elem.onerror = (error) => reject(error) 66 document.head.appendChild(elem) 67 if (elem instanceof HTMLScriptElement) elem.src = src 68 else if (elem instanceof HTMLLinkElement) elem.href = src 69 }) 70 } 71 // Await all resources 72 await Promise.all([loadElement(script, croppieScriptURL), loadElement(link, croppieStyleURL)]) 73 } 74 75 browseFiles() { 76 this.imageInputTarget.click() 77 } 78 79 onchange(event) { 80 // Update file name 81 let image = event.target.files[0] 82 this.imageName = undefined 83 // No file selected 84 if (image == undefined) return 85 // Ensure valid file type 86 let isValid = imageTypes.has(image.type) 87 this.errorLabelTarget.classList.toggle("hidden", isValid) 88 this.errorLabelTarget.querySelector("span").innerHTML = "Invalid file type" 89 if (!isValid) return 90 // Ensure valid file size 91 isValid = image.size < this.maxSize 92 this.errorLabelTarget.classList.toggle("hidden", isValid) 93 this.errorLabelTarget.querySelector( 94 "span" 95 ).innerHTML = `File size too large. Max ${this.maxSizeLabel}.` 96 if (!isValid) return 97 // Set purged value 98 if (this.hasPurgedInputTarget) this.purgedInputTarget.value = true 99 this.imageName = image.name 100 // Update preview image 101 let reader = new FileReader() 102 reader.onload = async () => { 103 this.previewImageTarget.src = reader.result 104 this.previewImageTarget.classList.toggle("hidden", false) 105 this.placeholderTarget.classList.toggle("hidden", true) 106 // Load croppie 107 await this.loadCroppie() 108 this.removeCroppie() 109 let width = this.previewImageTarget.parentElement.clientWidth - 4 110 let height = this.previewImageTarget.parentElement.clientHeight - 4 111 const imageWidth = this.previewImageTarget.naturalWidth 112 const imageHeight = this.previewImageTarget.naturalHeight 113 if (this.cropShape == "circle") { 114 width = Math.min(width, height) 115 height = Math.min(width, height) 116 } else { 117 const widthRatio = width / imageWidth 118 const heightRatio = height / imageHeight 119 const minRatio = Math.min(widthRatio, heightRatio) 120 width = imageWidth * minRatio 121 height = imageHeight * minRatio 122 } 123 this.croppie = new Croppie(this.previewImageTarget, { 124 viewport: { width, height, type: this.cropShape }, 125 showZoomer: false, 126 }) 127 window.setTimeout(() => this.croppie?.setZoom(0), 20) 128 }
129 reader.readAsDataURL(image) 130 } 131 132 remove(event) { 133 // Stop default click action 134 event.preventDefault() 135 event.stopPropagation() 136 // Remove the image 137 this.removeCroppie() 138 this.previewImageTarget.src = "" 139 this.previewImageTarget.classList.toggle("hidden", true) 140 this.placeholderTarget.classList.toggle("hidden", false) 141 this.imageInputTarget.value = "" 142 if (this.purgedInputTarget) this.purgedInputTarget.value = true 143 } 144 145 removeCroppie() { 146 if (!this.croppie) return 147 // Destroy croppie instance 148 this.croppie.destroy() 149 this.croppie = undefined 150 // Move the image preview outside of the croppie div 151 const previewImage = this.previewImageTarget 152 const target = previewImage.parentElement.parentElement 153 previewImage.parentElement.remove() 154 target.appendChild(previewImage) 155 } 156 157 async beforeSubmit(event) { 158 if (this.handledSubmit) return 159 this.handledSubmit = true 160 event.preventDefault() 161 const form = event.target.closest("form") 162 const submitButton = form.querySelector("button[type='submit']") 163 if (submitButton) submitButton.disabled = true 164 // No image 165 if (!this.croppie) { 166 window.setTimeout(() => form.requestSubmit(), 100) 167 return 168 } 169 // Get blob of cropped image, create File object 170 const blob = await this.croppie.result({ 171 type: "blob", 172 size: "original", 173 format: "jpeg", 174 quality: 0.8, 175 }) 176 const file = new File([blob], this.imageName, { 177 type: "image/jpeg", 178 lastModified: Date.now(), 179 }) 180 // Add File to DataTransfer, and set DataTransfer to file input 181 const transfer = new DataTransfer() 182 transfer.items.add(file) 183 this.imageInputTarget.files = transfer.files 184 // Submit the form 185 window.setTimeout(() => form.requestSubmit(), 100) 186 } 187};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.