1import { Controller } from "@hotwired/stimulus" 2 3// Renders a live, accurate email preview of the composer form into an iframe (srcdoc), 4// and can send a test of the in-progress draft without saving it first. 5export default class extends Controller { 6 static targets = ["form", "iframe", "status", "testStatus"] 7 static values = { url: String, testUrl: String } 8 9 connect() { 10 this.timer = null 11 this.abortController = null 12 this.requestSeq = 0 13 // Defer the first render so Trix has time to populate its hidden input. 14 this.timer = window.setTimeout(() => this.refresh(), 350) 15 } 16 17 disconnect() { 18 if (this.timer) window.clearTimeout(this.timer) 19 this.abortController?.abort() 20 } 21 22 schedule() { 23 if (this.timer) window.clearTimeout(this.timer) 24 this.setStatus("Updatingâ¦") 25 this.timer = window.setTimeout(() => this.refresh(), 450) 26 } 27 28 async refresh() { 29 if (!this.hasFormTarget || !this.hasUrlValue || !this.hasIframeTarget) return 30 31 this.abortController?.abort() 32 this.abortController = new AbortController() 33 const requestId = ++this.requestSeq 34 35 const body = new FormData(this.formTarget) 36 body.delete("_method") 37 body.delete("authenticity_token") 38 39 try { 40 const response = await fetch(this.urlValue, { 41 method: "POST", 42 headers: { Accept: "text/html", "X-CSRF-Token": this.csrfToken() }, 43 body, 44 credentials: "same-origin", 45 signal: this.abortController.signal 46 }) 47 48 if (!response.ok) throw new Error(`Preview failed (${response.status})`) 49 50 const html = await response.text() 51 if (requestId !== this.requestSeq) return 52 53 this.iframeTarget.srcdoc = html 54 this.setStatus("Updated") 55 } catch (error) { 56 if (error.name === "AbortError") return 57 this.setStatus("Preview unavailable") 58 } 59 } 60 61 // Opens the current draft full-size in a new tab. The live-preview iframe already holds 62 // the rendered email, so we reuse that HTML via a Blob URL â no server round-trip (and 63 // therefore no CSRF concerns), and the window.open happens synchronously on the click so 64 // it is not treated as a blocked popup. 65 openFull(event) { 66 event?.preventDefault() 67 68 const html = (this.hasIframeTarget && this.iframeTarget.srcdoc) || 69 "<!doctype html><meta charset='utf-8'><body style='font:14px system-ui,sans-serif;padding:24px;color:#52525b'>Start writing to preview your email.</body>" 70 71 const url = URL.createObjectURL(new Blob([html], { type: "text/html" })) 72 window.open(url, "_blank", "noopener") 73 window.setTimeout(() => URL.revokeObjectURL(url), 60000) 74 } 75 76 async sendTest(event) { 77 const button = event?.currentTarget 78 if (!this.hasFormTarget || !this.hasTestUrlValue) return 79 80 if (button) button.disabled = true 81 this.setTestStatus("Sending testâ¦") 82 83 const body = new FormData(this.formTarget) 84 body.delete("_method") 85 body.delete("authenticity_token") 86 87 try { 88 const response = await fetch(this.testUrlValue, { 89 method: "POST", 90 headers: { Accept: "application/json", "X-CSRF-Token": this.csrfToken() }, 91 body, 92 credentials: "same-origin" 93 }) 94 const payload = await response.json() 95 96 if (!response.ok || payload.ok === false) { 97 throw new Error(payload.error || "Test email could not be sent.") 98 } 99 100 this.setTestStatus(`Test sent to ${payload.to_email}.`) 101 } catch (error) { 102 this.setTestStatus(error.message || "Test email could not be sent.") 103 } finally { 104 if (button) button.disabled = false 105 } 106 } 107 108 setStatus(text) { 109 if (this.hasStatusTarget) this.statusTarget.textContent = text 110 } 111 112 setTestStatus(text) { 113 if (this.hasTestStatusTarget) this.testStatusTarget.textContent = text 114 } 115 116 csrfToken() { 117 return document.querySelector("meta[name='csrf-token']")?.content || "" 118 } 119}
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.