1import { Controller } from "@hotwired/stimulus" 2 3// Manages multi-file input: shows a live filename list and supports removing 4// individual files before submit without a page reload. 5export default class extends Controller { 6 static targets = ["input", "list", "zone"] 7 static values = { maxSize: { type: Number, default: 26214400 } } 8 9 connect() { 10 this.accumulatedFiles = [] 11 } 12 13 update() { 14 this._merge(Array.from(this.inputTarget.files)) 15 this._sync() 16 this.renderList() 17 } 18 19 dragover(event) { 20 event.preventDefault() 21 if (this.hasZoneTarget) this.zoneTarget.classList.add("is-dragover") 22 } 23 24 dragleave() { 25 if (this.hasZoneTarget) this.zoneTarget.classList.remove("is-dragover") 26 } 27 28 drop(event) { 29 event.preventDefault() 30 if (this.hasZoneTarget) this.zoneTarget.classList.remove("is-dragover") 31 this._merge(Array.from(event.dataTransfer.files)) 32 this._sync() 33 this.renderList() 34 } 35 36 remove(event) { 37 const index = parseInt(event.currentTarget.dataset.fileIndex, 10) 38 this.accumulatedFiles = this.accumulatedFiles.filter((_, i) => i !== index) 39 this._sync() 40 this.renderList() 41 } 42 43 _merge(newFiles) { 44 for (const file of newFiles) { 45 if (file.size > this.maxSizeValue) { 46 window.Toast?.error(`${file.name} is too large (maximum is 25 MB per file)`) 47 continue 48 } 49 const key = `${file.name}|${file.size}|${file.lastModified}` 50 const isDupe = this.accumulatedFiles.some( 51 f => `${f.name}|${f.size}|${f.lastModified}` === key 52 ) 53 if (!isDupe) this.accumulatedFiles.push(file) 54 } 55 } 56 57 _sync() { 58 const dt = new DataTransfer() 59 this.accumulatedFiles.forEach(f => dt.items.add(f)) 60 // value="" reliably clears the native input (including CDP-set files) so 61 // any late change event reads an empty FileList and cannot re-add removed files. 62 this.inputTarget.value = "" 63 this.inputTarget.files = dt.files 64 } 65 66 renderList() { 67 this.listTarget.innerHTML = "" 68 // Read from accumulatedFiles, not inputTarget.files: CDP-set File objects 69 // (from Selenium's DOM.setFileInputFiles) can silently fail when added to 70 // a DataTransfer, leaving inputTarget.files empty after _sync(). 71 this.accumulatedFiles.forEach((file, index) => { 72 const item = document.createElement("span") 73 item.className = "ticket-attachment-file-item" 74 75 const icon = document.createElement("span") 76 icon.className = "icon is-small" 77 icon.innerHTML = '<i class="fas fa-file"></i>' 78 79 const name = document.createElement("span") 80 name.className = "ticket-attachment-file-name" 81 name.textContent = file.name 82 83 const btn = document.createElement("button") 84 btn.type = "button" 85 btn.className = "ticket-attachment-file-remove" 86 btn.setAttribute("data-action", "click->file-input#remove") 87 btn.setAttribute("data-file-index", index) 88 btn.setAttribute("aria-label", `Remove ${file.name}`) 89 btn.innerHTML = '<i class="fas fa-xmark"></i>' 90 91 item.appendChild(icon) 92 item.appendChild(name) 93 item.appendChild(btn) 94 this.listTarget.appendChild(item) 95 }) 96 } 97}
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.