1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = ["editor", "status"] 5 static values = { 6 allowedTypes: Array, 7 maxBytes: Number, 8 uploadUrl: String 9 } 10 11 connect() { 12 this.pendingUploads = 0 13 this.activeRequests = new Set() 14 this.disabledSubmitters = new Set() 15 this.form = this.element.closest("form") 16 this.handleSubmit = this.preventSubmitWhileUploading.bind(this) 17 this.form?.addEventListener("submit", this.handleSubmit) 18 this.configureToolbar() 19 } 20 21 disconnect() { 22 this.form?.removeEventListener("submit", this.handleSubmit) 23 this.activeRequests.forEach((request) => request.abort()) 24 this.enableSubmitters() 25 } 26 27 prepare(event) { 28 if (!this.hasEditorTarget || event.target !== this.editorTarget) return 29 30 this.configureToolbar(event.target) 31 } 32 33 accept(event) { 34 if (!this.hasEditorTarget || event.target !== this.editorTarget) return 35 36 event.stopPropagation() 37 38 const file = event.file 39 if (!file || !this.allowedTypesValue.includes(file.type)) { 40 event.preventDefault() 41 this.setStatus("Choose a JPG, PNG, WebP, or GIF image.", "error") 42 return 43 } 44 45 if (file.size > this.maxBytesValue) { 46 event.preventDefault() 47 this.setStatus("Choose an image smaller than 12 MB.", "error") 48 } 49 } 50 51 upload(event) { 52 if (!this.hasEditorTarget || event.target !== this.editorTarget) return 53 54 const attachment = event.attachment 55 if (!attachment?.file) return 56 57 // The app-wide Action Text handler would use the public direct-upload route. 58 // This editor uploads through the authenticated, tenant-scoped endpoint instead. 59 event.stopPropagation() 60 this.uploadAttachment(attachment) 61 } 62 63 configureToolbar(editor = this.hasEditorTarget ? this.editorTarget : null) { 64 const toolbarId = editor?.getAttribute("toolbar") 65 const toolbar = toolbarId ? document.getElementById(toolbarId) : null 66 if (!toolbar) return 67 68 toolbar.classList.add("event-description-image-toolbar") 69 const button = toolbar.querySelector("[data-trix-action='attachFiles']") 70 button?.setAttribute("title", "Add image") 71 button?.setAttribute("aria-label", "Add image") 72 } 73 74 uploadAttachment(attachment) { 75 const request = new XMLHttpRequest() 76 const formData = new FormData() 77 formData.append("file", attachment.file) 78 79 this.beginUpload(request) 80 attachment.setUploadProgress(1) 81 request.open("POST", this.uploadUrlValue) 82 request.setRequestHeader("Accept", "application/json") 83 84 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 85 if (csrfToken) request.setRequestHeader("X-CSRF-Token", csrfToken) 86 87 request.upload.addEventListener("progress", (event) => { 88 if (!event.lengthComputable) return 89 90 attachment.setUploadProgress(Math.max(1, Math.round((event.loaded / event.total) * 95))) 91 }) 92 93 request.addEventListener("load", () => { 94 const payload = this.responsePayload(request) 95 96 if (request.status >= 200 && request.status < 300 && payload.url) { 97 attachment.setAttributes({ 98 url: payload.url, 99 eventDescriptionImageToken: payload.reference_token 100 }) 101 attachment.setUploadProgress(100) 102 this.finishUpload(request, "Image added to the description.", "success") 103 } else { 104 this.failUpload(request, attachment, payload.error || "Upload failed.") 105 } 106 }) 107 request.addEventListener("error", () => this.failUpload(request, attachment, "Upload failed. Check your connection and try again.")) 108 request.addEventListener("abort", () => this.finishUpload(request)) 109 request.send(formData) 110 } 111 112 beginUpload(request) { 113 this.activeRequests.add(request) 114 this.pendingUploads += 1 115 this.disableSubmitters() 116 this.setStatus(this.uploadingMessage(), "pending") 117 } 118 119 finishUpload(request, message = null, state = "pending") { 120 if (!this.activeRequests.delete(request)) return 121 122 this.pendingUploads = Math.max(0, this.pendingUploads - 1) 123 if (this.pendingUploads === 0) { 124 this.enableSubmitters() 125 if (message) this.setStatus(message, state) 126 } else { 127 this.setStatus(this.uploadingMessage(), "pending") 128 } 129 } 130 131 failUpload(request, attachment, message) { 132 attachment.remove() 133 this.finishUpload(request, message, "error") 134 } 135 136 preventSubmitWhileUploading(event) { 137 if (this.pendingUploads === 0) return 138 139 event.preventDefault() 140 this.setStatus("Wait for the image upload to finish before saving.", "error") 141 } 142 143 disableSubmitters() { 144 this.form?.querySelectorAll("button[type='submit'], input[type='submit']").forEach((submitter) => { 145 if (submitter.disabled) return 146 147 submitter.disabled = true 148 this.disabledSubmitters.add(submitter) 149 }) 150 } 151 152 enableSubmitters() {
153 this.disabledSubmitters.forEach((submitter) => { submitter.disabled = false }) 154 this.disabledSubmitters.clear() 155 } 156 157 uploadingMessage() { 158 return this.pendingUploads === 1 ? "Uploading image..." : `Uploading ${this.pendingUploads} images...` 159 } 160 161 responsePayload(request) { 162 try { 163 return JSON.parse(request.responseText || "{}") 164 } catch (_error) { 165 return {} 166 } 167 } 168 169 setStatus(message, state) { 170 if (!this.hasStatusTarget || !this.element.isConnected) return 171 172 this.statusTarget.textContent = message 173 this.statusTarget.classList.remove("text-zinc-500", "text-emerald-700", "text-rose-700") 174 175 if (state === "success") { 176 this.statusTarget.classList.add("text-emerald-700") 177 } else if (state === "error") { 178 this.statusTarget.classList.add("text-rose-700") 179 } else { 180 this.statusTarget.classList.add("text-zinc-500") 181 } 182 } 183}
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.