1import { Controller } from "@hotwired/stimulus"; 2import { pickPrimarySecondary, rgbToHex } from "utils/color_extraction_utils"; 3import { setInnerHTML } from "utils/safe_html"; 4 5// Connects to data-controller="logo-upload" 6// Handles logo upload with cropper.js, including color extraction for forms 7export default class extends Controller { 8 static targets = [ 9 "modal", 10 "input", 11 "cropperContainer", 12 "cropperInstructions", 13 "cropAndUploadBtn", 14 ]; 15 16 static values = { 17 modelPrefix: String, 18 placeholderUrl: String, 19 previewId: String, 20 previewImageId: String, 21 removeButtonId: String, 22 logoDataFieldName: String, 23 }; 24 25 connect() { 26 this.cropper = null; 27 this.setupEventListeners(); 28 this.cacheExternalElements(); 29 } 30 31 cacheExternalElements() { 32 // These elements are outside the controller scope (in the parent form) 33 // so we need to look them up by ID/name 34 this.previewElement = document.getElementById(this.previewIdValue); 35 this.previewImageElement = document.getElementById(this.previewImageIdValue); 36 this.removeButtonElement = document.getElementById(this.removeButtonIdValue); 37 this.logoDataFieldElement = document.querySelector( 38 `input[name="${this.logoDataFieldNameValue}"]` 39 ); 40 } 41 42 disconnect() { 43 if (this.cropper) { 44 this.cropper.destroy(); 45 this.cropper = null; 46 } 47 } 48 49 setupEventListeners() { 50 // Setup modal hidden event to cleanup cropper 51 if (this.hasModalTarget) { 52 this.modalTarget.addEventListener( 53 "hidden.bs.modal", 54 this.handleModalHidden.bind(this) 55 ); 56 } 57 } 58 59 handleFileSelect(event) { 60 const file = event.target.files[0]; 61 if (!file) return; 62 63 if (file.size > 5 * 1024 * 1024) { 64 alert("File is too large. Maximum size is 5MB."); 65 this.inputTarget.value = ""; 66 return; 67 } 68 69 const reader = new FileReader(); 70 reader.onload = (e) => { 71 this.initializeCropper(e.target.result); 72 }; 73 reader.readAsDataURL(file); 74 } 75 76 initializeCropper(imageSrc) { 77 // Clear previous cropper 78 if (this.cropper) { 79 this.cropper.destroy(); 80 setInnerHTML(this.cropperContainerTarget, ""); 81 } 82 83 // Show the cropper container 84 this.cropperContainerTarget.parentElement.classList.remove("d-none"); 85 86 // Create new image element 87 const img = document.createElement("img"); 88 img.src = imageSrc; 89 this.cropperContainerTarget.appendChild(img); 90 91 // Wait for Cropper to be available 92 if (typeof Cropper === "undefined") { 93 setTimeout(() => this.initializeCropper(imageSrc), 100); 94 return; 95 } 96 97 // Initialize Cropper 98 this.cropper = new Cropper(img, { 99 aspectRatio: 1, 100 viewMode: 1, 101 dragMode: "move", 102 autoCropArea: 1, 103 restore: false, 104 guides: true, 105 center: true, 106 highlight: false, 107 cropBoxMovable: false, 108 cropBoxResizable: false, 109 toggleDragModeOnDblclick: false, 110 ready: () => { 111 this.cropperInstructionsTarget.classList.remove("d-none"); 112 this.cropAndUploadBtnTarget.disabled = false; 113 114 // Extract colors after the image is fully loaded and cropper is ready 115 const colors = this.extractColorsFromImage(img); 116 this.updateColors(colors); 117 }, 118 }); 119 } 120 121 handleModalHidden() { 122 this.cropperInstructionsTarget.classList.add("d-none"); 123 this.cropperContainerTarget.parentElement.classList.add("d-none"); 124 this.inputTarget.value = ""; 125 this.cropAndUploadBtnTarget.disabled = true; 126 if (this.cropper) { 127 this.cropper.destroy(); 128 setInnerHTML(this.cropperContainerTarget, ""); 129 this.cropper = null; 130 } 131 } 132 133 cropAndUpload() { 134 if (!this.cropper) return; 135 136 try { 137 const canvas = this.cropper.getCroppedCanvas({ 138 width: 256, 139 height: 256, 140 fillColor: "#fff", 141 }); 142 143 if (!canvas) { 144 throw new Error("Failed to create cropped canvas"); 145 } 146 147 canvas.toBlob( 148 (blob) => { 149 if (!blob) { 150 throw new Error("Failed to create blob from canvas"); 151 } 152 153 const reader = new FileReader(); 154 reader.onload = (e) => { 155 // Store the base64 data in the hidden field 156 if (this.logoDataFieldElement) { 157 this.logoDataFieldElement.value = e.target.result; 158 } 159 160 // Update preview 161 if (this.previewImageElement) { 162 this.previewImageElement.src = e.target.result; 163 } 164 if (this.previewElement) { 165 this.previewElement.classList.remove("d-none"); 166 } 167 if (this.removeButtonElement) { 168 this.removeButtonElement.classList.remove("d-none"); 169 } 170 171 // Close modal 172 this.closeModal(); 173 };
174 reader.readAsDataURL(blob); 175 }, 176 "image/jpeg", 177 0.8 178 ); 179 } catch (error) { 180 console.error("Error processing image:", error); 181 alert("Error processing image. Please try again."); 182 } 183 } 184 185 closeModal() { 186 if (this.hasModalTarget) { 187 // Try direct click on close button first as it's most reliable 188 const closeButton = this.modalTarget.querySelector(".btn-close"); 189 if (closeButton) { 190 closeButton.click(); 191 } else if (window.bootstrap) { 192 // Fallbacks if close button not found 193 try { 194 const bsModal = window.bootstrap.Modal.getOrCreateInstance( 195 this.modalTarget 196 ); 197 if (bsModal) { 198 bsModal.hide(); 199 } 200 } catch (e) { 201 console.warn("Could not close modal with Bootstrap API:", e); 202 } 203 } 204 } 205 } 206 207 // Color extraction from logo 208 extractColorsFromImage(imageElement) { 209 try { 210 const canvas = document.createElement("canvas"); 211 const ctx = canvas.getContext("2d"); 212 213 const width = imageElement.naturalWidth || imageElement.width; 214 const height = imageElement.naturalHeight || imageElement.height; 215 216 if (!width || !height) { 217 console.error("Image has no dimensions, cannot extract colors"); 218 return { primary: "#000000", secondary: "#FFFFFF" }; 219 } 220 221 canvas.width = width; 222 canvas.height = height; 223 ctx.drawImage(imageElement, 0, 0, width, height); 224 225 const imageData = ctx.getImageData(0, 0, width, height); 226 const data = imageData.data; 227 228 // Build frequency map by sampling every 10th pixel 229 const colorMap = new Map(); 230 for (let i = 0; i < data.length; i += 40) { 231 const r = data[i]; 232 const g = data[i + 1]; 233 const b = data[i + 2]; 234 const a = data[i + 3]; 235 if (a === 0) continue; 236 237 const color = rgbToHex(r, g, b); 238 colorMap.set(color, (colorMap.get(color) || 0) + 1); 239 } 240 241 const sortedColors = Array.from(colorMap.entries()) 242 .sort((a, b) => b[1] - a[1]) 243 .map(([color]) => color); 244 245 return pickPrimarySecondary(sortedColors); 246 } catch (error) { 247 console.error("Error extracting colors:", error); 248 return { primary: "#000000", secondary: "#FFFFFF" }; 249 } 250 } 251 252 // Update color fields based on the model type 253 updateColors(colors) { 254 // Determine the model from the logo data field name 255 const modelPrefix = this.modelPrefixValue; 256 const isPrimaryColorField = 257 modelPrefix === "game" ? "opponent_primary_color" : "primary_color"; 258 const isSecondaryColorField = 259 modelPrefix === "game" ? "opponent_secondary_color" : "secondary_color"; 260 261 const primaryColorInput = document.querySelector( 262 `input[name="${modelPrefix}[${isPrimaryColorField}]"]` 263 ); 264 const secondaryColorInput = document.querySelector( 265 `input[name="${modelPrefix}[${isSecondaryColorField}]"]` 266 ); 267 268 if (primaryColorInput) { 269 primaryColorInput.value = colors.primary; 270 const primaryColorText = primaryColorInput 271 .closest(".input-group") 272 ?.querySelector(".input-group-text"); 273 if (primaryColorText) { 274 primaryColorText.textContent = colors.primary; 275 } 276 277 // Update abbreviation preview if exists 278 const abbreviationPreview = document.getElementById("abbreviationPreview"); 279 if (abbreviationPreview) { 280 abbreviationPreview.style.backgroundColor = colors.primary; 281 } 282 } 283 284 if (secondaryColorInput) { 285 secondaryColorInput.value = colors.secondary; 286 const secondaryColorText = secondaryColorInput 287 .closest(".input-group") 288 ?.querySelector(".input-group-text"); 289 if (secondaryColorText) { 290 secondaryColorText.textContent = colors.secondary; 291 } 292 293 // Update abbreviation preview text color if exists 294 const abbreviationPreview = document.getElementById("abbreviationPreview"); 295 if (abbreviationPreview) { 296 abbreviationPreview.style.color = colors.secondary; 297 } 298 } 299 } 300}
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.