1import { Controller } from "@hotwired/stimulus" 2 3// "Point to it": open the chamber's own website in a full-screen dialog, let them 4// click the section they mean, and attach that section to the request. Sections 5// are addressed through data-site-section-index, which the composed page 6// template emits for every section (see page_canvas_editor_controller.js). 7// 8// Only the page and the section's position travel with the request. The server 9// reads the section itself, so nothing the page displays is trusted. 10const SECTION_SELECTOR = "[data-site-section-index]" 11const CHROME_ATTRIBUTE = "data-ch-pointer" 12const MAX_POINTERS = 5 13 14export default class extends Controller { 15 static targets = ["dialog", "frame", "page", "chips", "chipTemplate"] 16 static values = { previewUrl: String, fieldName: String } 17 18 connect() { 19 this.onFrameLoad = () => this.attachFrame() 20 } 21 22 disconnect() { 23 this.detachFrame() 24 } 25 26 open() { 27 if (!this.hasDialogTarget) return 28 if (this.chipsTarget.children.length >= MAX_POINTERS) { 29 this.chipsTarget.querySelector("[data-role='remove']")?.focus() 30 return 31 } 32 33 this.dialogTarget.showModal() 34 this.loadPage(this.pageTarget.value) 35 } 36 37 close() { 38 if (this.hasDialogTarget && this.dialogTarget.open) this.dialogTarget.close() 39 } 40 41 closed() { 42 this.detachFrame() 43 this.frameTarget.removeAttribute("src") 44 } 45 46 changePage() { 47 this.loadPage(this.pageTarget.value) 48 } 49 50 loadPage(slug) { 51 const url = new URL(this.previewUrlValue, window.location.origin) 52 url.searchParams.set("page", slug || "home") 53 this.detachFrame() 54 this.frameTarget.addEventListener("load", this.onFrameLoad, { once: true }) 55 this.frameTarget.src = url.toString() 56 } 57 58 remove(event) { 59 event.currentTarget.closest("li")?.remove() 60 this.element.querySelector("[data-action~='website-agent-pointer#open']")?.focus() 61 } 62 63 // Frame chrome ---------------------------------------------------------- 64 65 attachFrame() { 66 const doc = this.frameDocument() 67 if (!doc?.body) return 68 69 this.doc = doc 70 const style = doc.createElement("style") 71 style.setAttribute(CHROME_ATTRIBUTE, "") 72 style.textContent = ` 73 [${CHROME_ATTRIBUTE}-box] { position: absolute; z-index: 2147483646; pointer-events: none; 74 border: 3px solid #d97706; border-radius: 6px; background: rgba(245, 158, 11, 0.08); 75 box-shadow: 0 0 0 9999px rgba(24, 24, 27, 0.18); transition: all 80ms ease-out; } 76 [${CHROME_ATTRIBUTE}-label] { position: absolute; top: -30px; left: -3px; white-space: nowrap; 77 border-radius: 6px; background: #18181b; color: #fff; padding: 4px 8px; 78 font: 600 12px/16px system-ui, sans-serif; } 79 ${SECTION_SELECTOR} { cursor: pointer; } 80 ${SECTION_SELECTOR}:focus { outline: none; } 81 ` 82 doc.head?.appendChild(style) 83 84 this.box = doc.createElement("div") 85 this.box.setAttribute(`${CHROME_ATTRIBUTE}-box`, "") 86 this.box.hidden = true 87 this.boxLabel = doc.createElement("span") 88 this.boxLabel.setAttribute(`${CHROME_ATTRIBUTE}-label`, "") 89 this.box.appendChild(this.boxLabel) 90 doc.body.appendChild(this.box) 91 92 this.sections().forEach((section) => { 93 if (!section.hasAttribute("tabindex")) section.setAttribute("tabindex", "0") 94 }) 95 96 this.handlers = { 97 move: (event) => this.highlight(this.sectionFor(event.target)), 98 focus: (event) => this.highlight(this.sectionFor(event.target)), 99 click: (event) => this.pick(event), 100 key: (event) => this.keyPick(event), 101 submit: (event) => event.preventDefault(), 102 } 103 doc.addEventListener("mousemove", this.handlers.move, true) 104 doc.addEventListener("focusin", this.handlers.focus, true) 105 doc.addEventListener("click", this.handlers.click, true) 106 doc.addEventListener("keydown", this.handlers.key, true) 107 doc.addEventListener("submit", this.handlers.submit, true) 108 109 this.sections()[0]?.focus({ preventScroll: true }) 110 } 111 112 detachFrame() { 113 if (this.doc && this.handlers) { 114 this.doc.removeEventListener("mousemove", this.handlers.move, true) 115 this.doc.removeEventListener("focusin", this.handlers.focus, true) 116 this.doc.removeEventListener("click", this.handlers.click, true) 117 this.doc.removeEventListener("keydown", this.handlers.key, true) 118 this.doc.removeEventListener("submit", this.handlers.submit, true) 119 } 120 if (this.hasFrameTarget) this.frameTarget.removeEventListener("load", this.onFrameLoad) 121 this.doc = null 122 this.handlers = null 123 this.box = null 124 } 125 126 highlight(section) { 127 if (!this.box) return 128 if (!section) { 129 this.box.hidden = true 130 return 131 } 132 133 const view = this.doc.defaultView 134 const rect = section.getBoundingClientRect() 135 this.box.hidden = false 136 this.box.style.left = `${Math.round(rect.left + view.scrollX)}px` 137 this.box.style.top = `${Math.round(rect.top + view.scrollY)}px` 138 this.box.style.width = `${Math.round(rect.width)}px` 139 this.box.style.height = `${Math.round(rect.height)}px` 140 this.boxLabel.textContent = `Click to choose: ${this.describe(section)}` 141 this.boxLabel.style.top = rect.top < 34 ? "4px" : "-30px" 142 this.boxLabel.style.left = rect.top < 34 ? "4px" : "-3px" 143 } 144 145 pick(event) { 146 event.preventDefault() 147 event.stopPropagation() 148 this.choose(this.sectionFor(event.target)) 149 } 150 151 keyPick(event) { 152 if (event.key === "Escape") { 153 event.preventDefault() 154 this.close() 155 return 156 } 157 if (event.key !== "Enter" && event.key !== " ") return 158 159 const section = this.sectionFor(this.doc?.activeElement) 160 if (!section) return 161 162 event.preventDefault() 163 event.stopPropagation() 164 this.choose(section) 165 } 166 167 choose(section) { 168 if (!section) return 169 170 const index = Number(section.dataset.siteSectionIndex) 171 if (!Number.isInteger(index) || index < 0) return 172 173 const slug = this.pageTarget.value 174 const pageLabel = this.pageTarget.selectedOptions[0]?.textContent?.trim() || "Page" 175 const value = JSON.stringify({ page: slug, index }) 176 const duplicate = Array.from(this.chipsTarget.querySelectorAll("[data-role='value']")) 177 .some((input) => input.value === value)
178 if (!duplicate) this.addChip(`${pageLabel}: ${this.describe(section)}`, value) 179 180 this.close() 181 } 182 183 addChip(label, value) { 184 const fragment = this.chipTemplateTarget.content.cloneNode(true) 185 const chip = fragment.firstElementChild 186 chip.querySelector("[data-role='label']").textContent = label 187 chip.querySelector("[data-role='label']").title = label 188 const input = chip.querySelector("[data-role='value']") 189 input.name = this.fieldNameValue 190 input.value = value 191 chip.querySelector("[data-role='remove']").setAttribute("aria-label", `Remove ${label}`) 192 this.chipsTarget.appendChild(chip) 193 } 194 195 // A heading says which section it is better than its position does. 196 describe(section) { 197 const heading = section.querySelector("h1, h2, h3, h4")?.textContent?.replace(/\s+/g, " ").trim() 198 if (heading) return `â${heading.length > 60 ? `${heading.slice(0, 59)}â¦` : heading}â` 199 200 return `section ${Number(section.dataset.siteSectionIndex) + 1}` 201 } 202 203 sections() { 204 return this.doc ? Array.from(this.doc.querySelectorAll(SECTION_SELECTOR)) : [] 205 } 206 207 sectionFor(node) { 208 return node?.closest?.(SECTION_SELECTOR) || null 209 } 210 211 frameDocument() { 212 try { 213 return this.frameTarget.contentDocument 214 } catch (_error) { 215 return null 216 } 217 } 218}
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.