1import { Controller } from "@hotwired/stimulus" 2 3// The download dialog on the QR screen (docs/QR_DESIGNER.md â "Downloads"). 4// Lives on the design form next to qr-designer and form-dirty. 5// 6// What it guards against: a download while the form has unsaved changes hands 7// over the previously saved design. So the openers go away (below lg) or are 8// disabled (from lg) as soon as anything changes, and inside the dialog an edit 9// to the printed text swaps the download button for Save. A save made from the 10// dialog posts `reopen_download` and the page comes back with the dialog open 11// on the same choice. 12// 13// The download itself is a plain link the browser handles: Safari on iOS only 14// honours a download started inside the tap that asked for it, and fetching the 15// bytes into a blob first spends that permission, so the file never arrives. 16// To still say "Preparing the fileâ¦" while a 35-cell PDF is composed, the link 17// is stamped with a one-off token and the response comes back with that token 18// in a cookie â which is the signal that the bytes have left the server. 19export default class extends Controller { 20 static targets = [ 21 "dialog", "title", "back", "menu", "tab", "workspace", "scroller", "footer", "options", "radio", "preview", 22 "textBlock", "printHeadline", "headlineField", "headline", "gridCode", "codeSide", "noRoom", 23 "download", "downloadLabel", "downloadIcon", "spinner", "share", 24 "save", "saveLabel", "saveSpinner", "unsavedNote", "error", 25 "reopenField", "desktopOpener", "mobileOpener", "openerNote" 26 ] 27 28 static values = { 29 reopen: String, 30 failureUrl: String, 31 unsaved: Boolean, 32 preparingLabel: String, 33 downloadedLabel: String, 34 savingLabel: String, 35 saveChangesLabel: String, 36 downloadFailedLabel: String, 37 saveFailedLabel: String 38 } 39 40 static STORAGE_KEY = "qr_downloads.choice" 41 static COOKIE = "qr_download" 42 static DESKTOP = "(min-width: 1024px)" 43 static DONE_MS = 2000 44 static POLL_MS = 250 45 // A PDF is composed in seconds; a request that has not answered in this long 46 // is not going to (a proxy timeout or a dropped navigation never reaches the 47 // app, so silence is all there is to read). Long enough not to cry wolf over 48 // a slow phone, short enough that the owner is not stuck watching a spinner. 49 static GIVE_UP_MS = 20_000 50 static FAILED_SUFFIX = ".failed" 51 52 connect() { 53 // A save the server rejected re-renders this page with the posted choices 54 // in the form but nothing stored: the downloads are as stale as if the 55 // form were dirty. 56 this.dirty = this.unsavedValue 57 this.group = null 58 this.kind = null 59 this.choices = this.readChoices() 60 this.desktop = window.matchMedia(this.constructor.DESKTOP) 61 this.boundLayoutChanged = () => this.layoutChanged() 62 this.desktop.addEventListener("change", this.boundLayoutChanged) 63 this.syncOpeners() 64 65 if (this.reopenValue) { 66 const radio = this.radioFor(this.reopenValue) 67 if (radio) { 68 this.choices[radio.dataset.group] = radio.value 69 this.showModal() 70 this.showGroup(radio.dataset.group) 71 } 72 this.forgetReopen() 73 } 74 } 75 76 disconnect() { 77 this.desktop.removeEventListener("change", this.boundLayoutChanged) 78 clearTimeout(this.doneTimer) 79 clearInterval(this.pollTimer) 80 } 81 82 // ââ Opening and navigating âââââââââââââââââââââââââââââââââââââââââââââââ 83 84 open() { 85 if (this.dirty) return 86 // Read again on every open: another tab may have downloaded since. 87 this.choices = this.readChoices() 88 this.showModal() 89 // A phone starts from the three groups; a desktop dialog has them as tabs 90 // and opens on the one used last. 91 if (this.desktop.matches) this.showGroup(this.choices.last || this.firstGroup()) 92 else this.showMenu() 93 } 94 95 close() { 96 if (this.dialogTarget.open) this.dialogTarget.close() 97 } 98 99 closeOnBackdrop(event) { 100 if (event.target === this.dialogTarget) this.close() 101 } 102 103 showModal() { 104 if (this.dialogTarget.open) return 105 if (typeof this.dialogTarget.showModal === "function") this.dialogTarget.showModal() 106 else this.dialogTarget.setAttribute("open", "") 107 } 108 109 showMenu() { 110 this.group = null 111 this.menuTarget.hidden = false 112 this.workspaceTarget.hidden = true 113 this.footerTarget.hidden = true 114 this.backTarget.hidden = true 115 this.titleTarget.textContent = this.titleTarget.dataset.defaultTitle 116 } 117 118 // From a menu row or a tab (params), or directly with a group name. 119 showGroup(eventOrGroup) { 120 const group = typeof eventOrGroup === "string" ? eventOrGroup : eventOrGroup.params.group 121 const fieldset = this.optionsTargets.find((el) => el.dataset.group === group) 122 if (!fieldset) return 123 124 this.group = group 125 this.choices.last = group 126 this.menuTarget.hidden = true 127 this.workspaceTarget.hidden = false 128 this.scrollerTarget.scrollTop = 0 129 this.footerTarget.hidden = false 130 this.backTarget.hidden = false
131 this.optionsTargets.forEach((el) => { el.hidden = el !== fieldset }) 132 this.tabTargets.forEach((el) => el.setAttribute("aria-selected", String(el.dataset.group === group))) 133 this.textBlockTarget.hidden = !this.radiosIn(group).some((radio) => radio.dataset.headlineFits !== undefined) 134 this.renderTitle() 135 136 const radio = this.radioFor(this.choices[group]) || this.radiosIn(group)[0] 137 radio.checked = true 138 this.select(radio) 139 } 140 141 // Widening a phone-sized window with the group list showing would leave the 142 // dialog empty: from lg the list is hidden in favour of the tabs. 143 layoutChanged() { 144 if (!this.dialogTarget.open) return 145 if (this.desktop.matches && !this.group) this.showGroup(this.choices.last || this.firstGroup()) 146 else this.renderTitle() 147 } 148 149 renderTitle() { 150 const fieldset = this.optionsTargets.find((el) => el.dataset.group === this.group) 151 this.titleTarget.textContent = !this.desktop.matches && fieldset 152 ? fieldset.dataset.title 153 : this.titleTarget.dataset.defaultTitle 154 } 155 156 // ââ Choosing a file ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 157 158 choose(event) { 159 this.select(event.target) 160 } 161 162 // The option radios are not design fields. Their input event is stopped here 163 // so neither form-dirty nor qr-designer mistakes a choice for an edit. 164 swallow() {} 165 166 select(radio) { 167 this.kind = radio 168 this.choices[radio.dataset.group] = radio.value 169 this.writeChoices() 170 this.previewTargets.forEach((el) => { el.hidden = el.dataset.download !== radio.value }) 171 this.errorTarget.classList.add("hidden") 172 this.downloadTarget.href = radio.dataset.url 173 this.downloadTarget.setAttribute("download", radio.dataset.filename) 174 this.resetDownloadButton() 175 this.renderText() 176 this.renderFooter() 177 this.prepareShare() 178 } 179 180 // ââ Printed text âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 181 182 // Any change anywhere in the form (declared on the form element). An edit 183 // inside the dialog lands here too, which is what turns Download into Save. 184 formChanged() { 185 this.dirty = true 186 this.syncOpeners() 187 if (!this.dialogTarget.open) return 188 this.renderText() 189 this.renderFooter() 190 } 191 192 renderText() { 193 if (!this.hasHeadlineFieldTarget) return 194 const wanted = this.printHeadlineTarget.checked 195 const text = this.headlineFieldTarget.value.trim() || this.headlineFieldTarget.placeholder 196 197 this.headlineTargets.forEach((el) => { 198 el.textContent = text 199 el.hidden = !wanted 200 }) 201 // The headline's line comes out of the code's height in a grid cell, so 202 // both the drawing and the stated size move with the checkbox. 203 this.gridCodeTargets.forEach((img) => { 204 img.style.width = wanted ? img.dataset.withHeadline : img.dataset.withoutHeadline 205 }) 206 this.codeSideTargets.forEach((el) => { el.hidden = (el.dataset.headline === "true") !== wanted }) 207 208 const fits = this.kind?.dataset.headlineFits 209 this.noRoomTarget.hidden = !(wanted && fits === "false") 210 } 211 212 // ââ Footer âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 213 214 renderFooter() { 215 const show = (el, visible) => el.classList.toggle("hidden", !visible) 216 show(this.saveTarget, this.dirty) 217 show(this.unsavedNoteTarget, this.dirty) 218 show(this.downloadTarget, !this.dirty) 219 show(this.shareTarget, !this.dirty && Boolean(this.shareFile) && this.kind?.value === "png") 220 } 221 222 syncOpeners() { 223 this.mobileOpenerTargets.forEach((el) => { el.hidden = this.dirty }) 224 this.desktopOpenerTargets.forEach((el) => { el.disabled = this.dirty }) 225 this.openerNoteTargets.forEach((el) => { el.hidden = !this.dirty }) 226 } 227 228 // Saving is not instant and the owner must be able to see that. The server 229 // draws the code, reads it back and stores it before answering â a couple of 230 // seconds (docs/QR_DESIGNER.md â "Rendering on the server"). Without this the 231 // button simply sat there, which reads as a screen that has stopped 232 // responding. 233 save() { 234 if (this.saving) return 235 this.saving = true 236 this.errorTarget.classList.add("hidden") 237 this.setSaveButton(true) 238 this.reopenFieldTarget.value = this.kind ? this.kind.value : "" 239 // Only the choices go; the server draws and reads back the code, which is 240 // most of what this save waits for now (docs/QR_DESIGNER.md â "Rendering 241 // on the server"). 242 this.element.requestSubmit() 243 } 244 245 // The submit left and came back as a failure â a 5xx from the proxy, or a 246 // connection that dropped mid-save. A save the server refused (a render it 247 // could not read back) re-renders the page with its own alert instead. 248 // Turbo renders its own body for a response it can render, so this is the 249 // case where the page stays put and nothing else would say a word. 250 submitEnded(event) { 251 if (event.detail?.success) return 252 this.failSave() 253 } 254 255 failSave() { 256 this.saving = false 257 this.setSaveButton(false) 258 if (!this.dialogTarget.open) return 259 this.errorTarget.textContent = this.saveFailedLabelValue 260 this.errorTarget.classList.remove("hidden") 261 } 262 263 setSaveButton(saving) { 264 this.saveTarget.setAttribute("aria-disabled", String(saving)) 265 this.saveSpinnerTarget.hidden = !saving 266 this.saveLabelTarget.textContent = saving ? this.savingLabelValue : this.saveChangesLabelValue 267 } 268 269 // ââ Downloading ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 270 271 // Runs inside the click, before the browser follows the link: everything 272 // here is synchronous, so the tap still counts as the download's gesture. 273 download(event) { 274 if (this.busy || !this.kind) { 275 event.preventDefault() 276 return 277 } 278 const token = `${Date.now()}${Math.random().toString(36).slice(2, 10)}` 279 this.downloadTarget.href = `${this.kind.dataset.url}?token=${token}` 280 this.errorTarget.classList.add("hidden") 281 this.setDownloadButton("preparing") 282 this.waitFor(token) 283 } 284 285 // The response sets a cookie carrying the token back, which is the only 286 // signal a page gets that a download it did not fetch has started. 287 waitFor(token) { 288 this.busy = true 289 const startedAt = Date.now() 290 clearInterval(this.pollTimer) 291 this.pollTimer = setInterval(() => { 292 const cookie = this.cookieToken() 293 if (cookie === token) { 294 this.forgetCookie() 295 this.finishDownload("done") 296 } else if (cookie === `${token}${this.constructor.FAILED_SUFFIX}`) { 297 // The server answered that it could not make the file. 298 this.forgetCookie() 299 this.failDownload() 300 } else if (Date.now() - startedAt > this.constructor.GIVE_UP_MS) { 301 this.reportGiveUp() 302 this.failDownload() 303 } 304 }, this.constructor.POLL_MS) 305 } 306 307 // Says so and gives the button back: `busy` is cleared, so the owner can tap 308 // Download again without reloading the page. 309 failDownload() { 310 this.errorTarget.textContent = this.downloadFailedLabelValue 311 this.errorTarget.classList.remove("hidden") 312 this.finishDownload("idle") 313 } 314 315 // A request that never reached the app leaves no server-side trace, so the 316 // page reports it. Best effort: it must never get in the owner's way. 317 reportGiveUp() { 318 const csrf = document.querySelector("meta[name='csrf-token']")?.content 319 fetch(this.failureUrlValue, { 320 method: "POST", 321 credentials: "same-origin", 322 keepalive: true, 323 headers: { "Content-Type": "application/x-www-form-urlencoded", "X-CSRF-Token": csrf || "" }, 324 body: new URLSearchParams({ kind: this.kind.value }) 325 }).catch(() => {}) 326 } 327 328 finishDownload(state) { 329 clearInterval(this.pollTimer) 330 this.busy = false 331 this.setDownloadButton(state) 332 } 333 334 cookieToken() { 335 const match = document.cookie.match(new RegExp(`(?:^|; )${this.constructor.COOKIE}=([^;]*)`)) 336 return match ? decodeURIComponent(match[1]) : null 337 } 338 339 forgetCookie() { 340 document.cookie = `${this.constructor.COOKIE}=; Max-Age=0; path=/` 341 } 342 343 setDownloadButton(state) { 344 clearTimeout(this.doneTimer) 345 const preparing = state === "preparing" 346 this.downloadTarget.setAttribute("aria-disabled", String(preparing)) 347 this.spinnerTarget.hidden = !preparing 348 this.downloadIconTarget.hidden = preparing 349 this.downloadLabelTarget.textContent = preparing 350 ? this.preparingLabelValue 351 : state === "done" ? this.downloadedLabelValue : this.kind.dataset.label 352 if (state === "done") this.doneTimer = setTimeout(() => this.resetDownloadButton(), this.constructor.DONE_MS) 353 } 354 355 resetDownloadButton() { 356 if (this.kind && !this.busy) this.setDownloadButton("idle") 357 } 358 359 // ââ Sharing the PNG ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 360 361 // navigator.share must run inside the tap, so the file is fetched ahead of 362 // it â only on a browser that can share files, and only once. 363 async prepareShare() { 364 if (this.kind?.value !== "png" || this.shareFile || this.sharing) return 365 if (typeof navigator.share !== "function" || typeof navigator.canShare !== "function") return 366 this.sharing = true 367 try { 368 const response = await fetch(this.kind.dataset.url, { credentials: "same-origin" }) 369 if (!response.ok || response.redirected) return 370 const blob = await response.blob() 371 const file = new File([blob], this.filenameFrom(response), { type: blob.type || "image/png" }) 372 if (navigator.canShare({ files: [file] })) { 373 this.shareFile = file 374 this.renderFooter() 375 } 376 } catch (error) { 377 console.warn("QR share unavailable", error) 378 } finally { 379 this.sharing = false 380 } 381 } 382 383 // No await before this call: on iOS the tap's permission is spent by the 384 // first one, and share() would then be refused. 385 share() { 386 if (!this.shareFile) return 387 navigator.share({ files: [this.shareFile] }).catch((error) => { 388 if (error.name === "AbortError") return 389 console.warn("QR share failed", error) 390 // The file is still one tap away through the download link. 391 this.shareTarget.classList.add("hidden") 392 }) 393 } 394 395 // ââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 396 397 radioFor(key) { 398 return key ? this.radioTargets.find((radio) => radio.value === key) : null 399 } 400 401 radiosIn(group) { 402 return this.radioTargets.filter((radio) => radio.dataset.group === group) 403 } 404 405 firstGroup() { 406 return this.optionsTargets[0].dataset.group 407 } 408 409 // The last choice in each group, and the group used last. Storage c
409an be 410 // missing or throw (private windows, blocked site data); the dialog works 411 // the same without it. 412 readChoices() { 413 try { 414 const stored = JSON.parse(window.localStorage.getItem(this.constructor.STORAGE_KEY) || "{}") 415 return stored && typeof stored === "object" ? stored : {} 416 } catch { 417 return {} 418 } 419 } 420 421 writeChoices() { 422 try { 423 window.localStorage.setItem(this.constructor.STORAGE_KEY, JSON.stringify(this.choices)) 424 } catch { 425 // Remembering is a convenience. 426 } 427 } 428 429 // Reloading the page must not open the dialog again. 430 forgetReopen() { 431 const url = new URL(window.location.href) 432 url.searchParams.delete("download") 433 window.history.replaceState(window.history.state, "", url) 434 } 435}
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.