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.uploadError = null 14 this.activeRequests = new Set() 15 this.disabledSubmitters = new Set() 16 this.form = this.hasEditorTarget ? this.editorTarget.closest("form") : this.element.closest("form") 17 this.handleSubmit = this.preventSubmitWhileUploading.bind(this) 18 this.form?.addEventListener("submit", this.handleSubmit) 19 this.configureToolbar() 20 } 21 22 disconnect() { 23 this.form?.removeEventListener("submit", this.handleSubmit) 24 this.activeRequests.forEach((request) => request.abort()) 25 this.enableSubmitters() 26 } 27 28 prepare(event) { 29 if (!this.hasEditorTarget || event.target !== this.editorTarget) return 30 31 this.configureToolbar(event.target) 32 } 33 34 accept(event) { 35 if (!this.hasEditorTarget || event.target !== this.editorTarget) return 36 37 event.stopPropagation() 38 39 const file = event.file 40 if (!file || !this.allowedTypesValue.includes(file.type)) { 41 event.preventDefault() 42 this.setStatus("Choose a JPG, PNG, WebP, or GIF image.", "error") 43 return 44 } 45 46 if (file.size > this.maxBytesValue) { 47 event.preventDefault() 48 this.setStatus(`Choose an image smaller than ${this.maximumMegabytes()} MB.`, "error") 49 } 50 } 51 52 upload(event) { 53 if (!this.hasEditorTarget || event.target !== this.editorTarget) return 54 55 const attachment = event.attachment 56 if (!attachment?.file) return 57 58 event.stopPropagation() 59 this.uploadAttachment(attachment) 60 } 61 62 configureToolbar(editor = this.hasEditorTarget ? this.editorTarget : null) { 63 const toolbarId = editor?.getAttribute("toolbar") 64 const toolbar = toolbarId ? document.getElementById(toolbarId) : null 65 if (!toolbar) return 66 67 toolbar.classList.add("news-post-image-toolbar") 68 const button = toolbar.querySelector("[data-trix-action='attachFiles']") 69 button?.setAttribute("title", "Add image") 70 button?.setAttribute("aria-label", "Add image") 71 } 72 73 uploadAttachment(attachment) { 74 const request = new XMLHttpRequest() 75 const formData = new FormData() 76 formData.append("file", attachment.file) 77 78 this.beginUpload(request) 79 attachment.setUploadProgress(1) 80 request.open("POST", this.uploadUrlValue) 81 request.timeout = 120000 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 newsPostImageToken: payload.reference_token 100 }) 101 attachment.setUploadProgress(100) 102 this.notifyEditorChanged() 103 this.finishUpload(request, "Image added to the article.", "success") 104 } else { 105 this.failUpload(request, attachment, payload.error || "Upload failed.") 106 } 107 }) 108 request.addEventListener("error", () => { 109 this.failUpload(request, attachment, "Upload failed. Check your connection and try again.") 110 }) 111 request.addEventListener("timeout", () => { 112 this.failUpload(request, attachment, "Upload timed out. Check your connection and try again.") 113 }) 114 request.addEventListener("abort", () => this.finishUpload(request)) 115 request.send(formData) 116 } 117 118 beginUpload(request) { 119 if (this.pendingUploads === 0) this.uploadError = null 120 121 this.activeRequests.add(request) 122 this.pendingUploads += 1 123 this.disableSubmitters() 124 this.setStatus(this.uploadingMessage(), "pending") 125 } 126 127 finishUpload(request, message = null, state = "pending") { 128 if (!this.activeRequests.delete(request)) return 129 130 this.pendingUploads = Math.max(0, this.pendingUploads - 1) 131 if (this.pendingUploads === 0) { 132 this.enableSubmitters() 133 if (this.uploadError) { 134 this.setStatus(this.uploadError, "error") 135 } else if (message) { 136 this.setStatus(message, state) 137 } 138 } else { 139 this.setStatus(this.uploadingMessage(), "pending") 140 } 141 } 142 143 failUpload(request, attachment, message) { 144 this.uploadError ||= message 145 attachment.remove() 146 this.finishUpload(request) 147 } 148 149 preventSubmitWhileUploading(event) { 150 if (this.pendingUploads === 0) return 151 152 event.preventDefault() 153 this.setStatus("Wait for the image upload to finish before saving.", "error") 154 } 155 156 disableSubmitters() { 157 this.form?.querySelectorAll("button[type='submit'], input[type='submit']").forEach((submitter) => { 158 if (submitter.disabled) return 159 160 submitter.disabled = true 161 this.disabledSubmitters.add(submitter) 162 }) 163 } 164 165 enableSubmitters() {
166 this.disabledSubmitters.forEach((submitter) => { submitter.disabled = false }) 167 this.disabledSubmitters.clear() 168 } 169 170 notifyEditorChanged() { 171 if (!this.hasEditorTarget) return 172 173 this.editorTarget.dispatchEvent(new Event("input", { bubbles: true })) 174 } 175 176 uploadingMessage() { 177 return this.pendingUploads === 1 ? "Uploading image..." : `Uploading ${this.pendingUploads} images...` 178 } 179 180 maximumMegabytes() { 181 return Math.floor(this.maxBytesValue / (1024 * 1024)) 182 } 183 184 responsePayload(request) { 185 try { 186 return JSON.parse(request.responseText || "{}") 187 } catch (_error) { 188 return {} 189 } 190 } 191 192 setStatus(message, state) { 193 if (!this.hasStatusTarget || !this.element.isConnected) return 194 195 this.statusTarget.textContent = message 196 this.statusTarget.classList.remove("text-zinc-500", "text-emerald-700", "text-rose-700") 197 198 if (state === "success") { 199 this.statusTarget.classList.add("text-emerald-700") 200 } else if (state === "error") { 201 this.statusTarget.classList.add("text-rose-700") 202 } else { 203 this.statusTarget.classList.add("text-zinc-500") 204 } 205 } 206}
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.