PageSourceSearch

https://www.georgetowncoc.com/assets/controllers/page_canvas_editor_controller-2ded75aa.js

js georgetowncoc.com collected 2026-09-25 04:05:29 UTC 76,772 bytes, 2,170 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Visual page editor.
4//
5// The live preview already renders the real public template through the real
6// site layout, so it is the only accurate picture of a page we have. Rather than
7// reimplement that rendering in JavaScript to get an editing canvas (a second
8// renderer would drift from the first, which is the mismatch this replaces),
9// this controller treats the preview itself as the canvas and puts the editing
10// chrome on top of it: a hover outline, the section's name, insert points on the
11// section's edges, and reorder/remove actions.
12//
13// Clicking a section opens that section's existing form fields in a panel
14// anchored beside it. The fields are never copied or moved: the form stays where
15// it is and CSS floats it, so Trix editors, upload controllers, and field names
16// all keep working exactly as they do in list mode.
17//
18// The canvas is addressed entirely through data-site-section-index, which
19// app/views/public/site_templates/composed.html.erb emits for every section.
20const CHROME_ATTRIBUTE = "data-ch-chrome"
21const CHROME_STYLE_ID = "ch-canvas-chrome-style"
22const CANVAS_CLASS = "ch-canvas"
23const SECTION_CLASS = "ch-canvas__section"
24const SECTION_SELECTOR = "[data-site-section-index]"
25const MODE_STORAGE_KEY = "chamberhive:page-editor-mode"
26// How long the chrome stays put after the pointer leaves a section, so the
27// toolbar and insert points on its edges can actually be reached.
28const HOVER_GRACE_MS = 320
29
30const STRUCTURAL_FIELDS = ["type", "section_id", "_original_index", "variant", "_text_styles"]
31const NON_TEXT_INPUT_TYPES = ["number", "checkbox", "radio", "file", "range"]
32const URL_FIELD_PATTERN = /(^|_)(url|href)$/
33const REPEATER_KEY_PATTERN = /^(.+?)_(\d+)(?:_|$)/
34// A chamber's own components hold their repeaters as real arrays, so a child of
35// one is addressed by index rather than by a number baked into the field name.
36const REPEATER_PATH_PATTERN = /^(.+)\.(\d+)(?:\.|$)/
37
38// Formatting a chamber can apply to a run of words. Sizes travel as our own
39// classes because allowing inline style would also admit font-family, and the
40// font is chosen per site rather than per word.
41// A press stores a mark against the field. It never edits the field's text:
42// markup inside a value breaks the moment a chamber's own Liquid puts that value
43// in an attribute, so the styling travels beside the text instead of inside it.
44const STYLES_FIELD = "_text_styles"
45const SIZE_MARKS = ["sm", "lg", "xl"]
46const TEXT_FORMATS = {
47  bold: { mark: "bold", className: "site-text-bold" },
48  italic: { mark: "italic", className: "site-text-italic" },
49  strike: { mark: "strike", className: "site-text-strike" },
50  small: { mark: "sm", className: "site-text-sm" },
51  large: { mark: "lg", className: "site-text-lg" },
52  xlarge: { mark: "xl", className: "site-text-xl" }
53}
54
55// Deep enough for the two ways content escapes its section - a component root
56// using width:100vw, and a full-bleed band inside that component - without
57// walking an entire page of nodes on every re-render.
58const OUTLINE_MAX_DEPTH = 3
59
60const PANEL_MARGIN = 12
61// Short of this there is no room to edit anything, so the panel keeps its height
62// and scrolls the page rather than collapsing to a sliver.
63const MIN_PANEL_HEIGHT = 260
64
65const DRAG_SCROLL_ZONE = 90
66const DRAG_SCROLL_DIVISOR = 3
67
68const ICONS = {
69  grip: "M2 4.75A.75.75 0 0 1 2.75 4h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 4.75Zm0 5A.75.75 0 0 1 2.75 9h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 9.75ZM2.75 14a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5H2.75Z",
70  remove: "M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z",
71  plus: "M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
72}
73
74export default class extends Controller {
75  static targets = [
76    "iframe", "panel", "panelTitle", "sectionList",
77    "visualButton", "listButton", "removeDialog", "removeTitle", "removeLabel", "removeScope"
78  ]
79
80  static values = {
81    mode: { type: String, default: "visual" },
82    hasErrors: { type: Boolean, default: false },
83    chromeStylesheet: String,
84    fieldMapUrl: String
85  }
86
87  connect() {
88    // Stimulus fires value-changed callbacks before connect(), so nothing may
89    // touch controller state until this flag says the state exists.
90    this.ready = false
91    this.selectedIndex = null
92    this.hoverIndex = null
93    this.pendingRemoveIndex = null
94    this.pendingRemoveItemKey = null
95    this.pendingReveal = null
96    this.hoverRelease = null
97    this.panelSubject = null
98    this.canvasStale = false
99    this.fieldNodes = new Map()
100    this.nodesByField = new Map()
101    this.markableFields = null
102    this.repeaterItems = new Map()
103    this.fieldMapIndex = null
104    this.fieldRequest = null
105    this.hoverField = null
106    this.drag = null
107    this.dropIndicator = null
108    this.frameDocument = null
109    this.frameCleanups = []
110    this.chrome = null
111    this.trackFrame = null
112
113    this.onPanelFocus = (event) => this.handlePanelFocus(event)
114    this.onFrameLoad = () => this.installChrome()
115    this.onViewportChange = () => this.reposition()
116    // Turbo snapshots the page before Stimulus disconnects. Without this the
117    // cached copy keeps an open panel frozen at the last section's coordinates,
118    // which is what a restoration visit would paint before reconnecting.
119    this.onBeforeCache = () => this.resetForCache()
120
121    if (this.hasIframeTarget) this.iframeTarget.addEventListener("load", this.onFrameLoad)
122    // The panel is in this document, not the canvas frame, so the styling row
123    // follows focus here.
124    this.element.addEventListener("focusin", this.onPanelFocus)
125    window.addEventListener("resize", this.onViewportChange)
126    window.addEventListener("scroll", this.onViewportChange, true)
127    document.addEventListener("turbo:before-cache", this.onBeforeCache)
128
129    this.ready = true
130
131    // A failed save re-renders with its messages inside the section form: the
132    // field errors, the per-section summary, and the "Needs attention" badge. In
133    // visual mode that form is a panel that is closed until a section is picked,
134    // so the editor would press Save and see nothing happen. Show the list until
135    // the errors are dealt with, and leave their saved preference alone.
136    if (this.hasErrorsValue) {
137      this.forcedListMode = true
138      this.modeValue = "list"
139    } else {
140      this.modeValue = this.storedMode() || this.modeValue
141    }
142
143    this.applyMode()
144
145    // The mode buttons do nothing until this controller is listening, so say so
146    // rather than leaving callers to guess when the editor is live.
147    this.element.dataset.canvasEditorReady = "true"
148
149    // Stimulus can connect after the preview has already loaded (a Turbo
150    // restoration visit, or a slow controller import).
151    this.installChrome()
152  }
153
154  disconnect() {
155    this.fieldRequest?.abort()
156    this.resetForCache()
157    this.cancelHoverRelease()
158    this.releaseFrame()
159    if (this.hasIframeTarget) this.iframeTarget.removeEventListener("load", this.onFrameLoad)
160    this.element.removeEventListener("focusin", this.onPanelFocus)
161    window.removeEventListener("resize", this.onViewportChange)
162    window.removeEventListener("scroll", this.onViewportChange, true)
163    document.removeEventListener("turbo:before-cache", this.onBeforeCache)
164    if (this.trackFrame) cancelAnimationFrame(this.trackFrame)
165  }
166
167  // Leave no transient state in the markup that Turbo is about to cache.
168  resetForCache() {
169    delete this.element.dataset.canvasEditorReady
170
171    if (this.hasPanelTarget) {
172      this.panelTarget.dataset.panelOpen = "false"
173      this.panelTarget.style.removeProperty("top")
174      this.panelTarget.style.removeProperty("left")
175    }
176
177    if (this.hasRemoveDialogTarget && this.removeDialogTarget.open) {
178      this.removeDialogTarget.close()
179    }
180  }
181
182  // Mode ----------------------------------------------------------------
183
184  useVisualMode(event) {
185    event?.preventDefault()
186    this.forcedListMode = false
187    this.modeValue = "visual"
188  }
189
190  useListMode(event) {
191    event?.preventDefault()
192    this.forcedListMode = false
193    this.modeValue = "list"
194  }
195
196  modeValueChanged() {
197    if (!this.ready) return
198    this.applyMode()
199  }
200
201  applyMode() {
202    const visual = this.modeValue === "visual"
203    this.element.dataset.canvasMode = visual ? "visual" : "list"
204
205    this.markToggle(this.hasVisualButtonTarget && this.visualButtonTarget, visual)
206    this.markToggle(this.hasListButtonTarget && this.listButtonTarget, !visual)
207
208    if (visual) {
209      this.installChrome()
210    } else {
211      // List mode is the original editor, untouched: no chrome, no forced
212      // selection, every section expandable on its own.
213      this.closePanel()
214      this.selectedIndex = null
215      this.hoverIndex = null
216      this.releaseFrame()
217    }
218
219    this.rememberMode()
220  }
221
222  markToggle(button, active) {
223    if (!button) return
224    button.dataset.active = String(active)
225    button.setAttribute("aria-pressed", String(active))
226  }
227
228  storedMode() {
229    try {
230      const stored = window.localStorage.getItem(MODE_STORAGE_KEY)
231      return stored === "visual" || stored === "list" ? stored : null
232    } catch (_error) {
233      return null
234    }
235  }
236
237  rememberMode() {
238    // Being dropped into the list to read an error is not a preference. The flag
239    // stays set until the editor picks a mode themselves, because connect()
240    // applies the mode more than once.
241    if (this.forcedListMode) return
242
243    try {
244      window.localStorage.setItem(MODE_STORAGE_KEY, this.modeValue)
245    } catch (_error) {
246      // A blocked storage quota should never stop the editor working.
247    }
248  }
249
250  // Canvas chrome -------------------------------------------------------
251
252  installChrome() {
253    if (this.modeValue !== "visual") return
254
255    const doc = this.frameDoc()
256    if (!doc?.body) return
257
258    // The preview swaps its whole document on every refresh, so anything bound
259    // to the previous one has to go before we bind the new one.
260    this.releaseFrame()
261    this.frameDocument = doc
262
263    this.linkChromeStylesheet(doc)
264    doc.documentElement.classList.add(CANVAS_CLASS)
265
266    // Focusable and named, so a keyboard or screen-reader user can reach a
267    // section and knows Enter opens its settings. Reordering, remov
267ing and
268    // inserting are pointer affordances on the toolbar below; their keyboard
269    // equivalents live in List view, which the mode toggle reaches.
270    this.canvasSections(doc).forEach((section, index) => {
271      section.classList.add(SECTION_CLASS)
272      section.setAttribute("tabindex", "0")
273      section.setAttribute("aria-label", `Edit the ${this.sectionLabel(index)} section`)
274    })
275
276    this.chrome = this.buildChrome(doc)
277    doc.body.appendChild(this.chrome)
278
279    this.bindFrame(doc)
280    this.canvasStale = false
281    this.syncSelectionFromForm()
282
283    // Every resolved node belonged to the document that was just replaced.
284    const restoreIndex = this.selectedIndex
285    this.clearFieldMap()
286    if (restoreIndex !== null && restoreIndex !== undefined) this.loadFieldMap(restoreIndex)
287
288    if (this.pendingReveal !== null && this.pendingReveal !== undefined) {
289      const reveal = this.pendingReveal
290      this.pendingReveal = null
291
292      // page_live_preview restores the previous scroll position from its own
293      // load listener, which is registered per render and so always runs after
294      // this one. Scrolling now would be undone a moment later, so wait until
295      // that restore has happened.
296      requestAnimationFrame(() => this.scrollCanvasTo(reveal))
297    }
298
299    this.reposition()
300  }
301
302  linkChromeStylesheet(doc) {
303    if (!this.hasChromeStylesheetValue) return
304    if (doc.getElementById(CHROME_STYLE_ID)) return
305
306    const link = doc.createElement("link")
307    link.id = CHROME_STYLE_ID
308    link.rel = "stylesheet"
309    link.href = this.chromeStylesheetValue
310    doc.head.appendChild(link)
311  }
312
313  buildChrome(doc) {
314    const chrome = doc.createElement("div")
315    chrome.className = "ch-chrome"
316    chrome.setAttribute(CHROME_ATTRIBUTE, "")
317    chrome.setAttribute("aria-hidden", "true")
318    chrome.hidden = true
319
320    const bar = doc.createElement("div")
321    bar.className = "ch-chrome__bar"
322
323    // The section's name doubles as its drag handle, so the thing you grab is
324    // the thing that says what you are moving.
325    const grip = doc.createElement("button")
326    grip.type = "button"
327    grip.className = "ch-chrome__grip"
328    grip.tabIndex = -1
329    grip.title = "Drag to move this section"
330    grip.setAttribute("data-ch-drag", "")
331    grip.innerHTML = this.icon(ICONS.grip)
332
333    const label = doc.createElement("span")
334    label.className = "ch-chrome__label"
335    grip.appendChild(label)
336    bar.appendChild(grip)
337
338    bar.appendChild(this.buildAction(doc, "edit", "Edit", null))
339    bar.appendChild(this.buildAction(doc, "remove", "Remove", ICONS.remove))
340
341    chrome.appendChild(bar)
342    chrome.appendChild(this.buildInsert(doc, "before", "Add before", "Add a section above this one"))
343    chrome.appendChild(this.buildInsert(doc, "after", "Add after", "Add a section below this one"))
344
345    this.chromeLabel = label
346    return chrome
347  }
348
349  buildAction(doc, action, label, iconPath) {
350    const button = doc.createElement("button")
351    button.type = "button"
352    button.className = "ch-chrome__action"
353    button.dataset.chAction = action
354    button.tabIndex = -1
355    button.title = label
356
357    if (iconPath) {
358      button.innerHTML = this.icon(iconPath)
359      const sr = doc.createElement("span")
360      sr.textContent = label
361      sr.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);"
362      button.appendChild(sr)
363    } else {
364      button.textContent = label
365    }
366
367    return button
368  }
369
370  buildInsert(doc, position, label, description) {
371    const button = doc.createElement("button")
372    button.type = "button"
373    button.className = `ch-chrome__insert ch-chrome__insert--${position}`
374    button.dataset.chAction = `insert-${position}`
375    button.tabIndex = -1
376    button.title = description
377    button.innerHTML = `${this.icon(ICONS.plus)}<span>${label}</span>`
378    button.setAttribute("aria-label", description)
379    return button
380  }
381
382  icon(path) {
383    return `<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" style="width:14px;height:14px;display:block;"><path d="${path}"></path></svg>`
384  }
385
386  bindFrame(doc) {
387    const listen = (target, type, handler, options) => {
388      target.addEventListener(type, handler, options)
389      this.frameCleanups.push(() => target.removeEventListener(type, handler, options))
390    }
391
392    listen(doc, "mouseover", (event) => this.handleFrameHover(event))
393    listen(doc, "focusin", (event) => this.handleFrameHover(event))
394    // Only when the pointer actually leaves the canvas. A capture-phase
395    // mouseleave on the document fires for every element the pointer leaves,
396    // including the section itself, which took the chrome away mid-reach.
397    listen(doc.documentElement, "mouseleave", () => this.scheduleHoverRelease())
398    listen(doc, "click", (event) => this.handleFrameClick(event), true)
399    listen(doc, "pointerdown", (event) => this.startDrag(event))
400    listen(doc, "pointermove", (event) => this.dragMove(event))
401    listen(doc, "pointerup", (event) => this.endDrag(event))
402    listen(doc, "pointercancel", () => this.cancelDrag())
403    listen(doc, "keydown", (event) => this.handleFrameKeydown(event))
404    // Pressing a format button must not collapse what is being formatted: the
405    // browser clears the selection on mousedown, and the resulting
406    // selectionchange took the bar down before the click could ever land on it.
407    listen(doc, "mousedown", (event) => {
408      this.textWasSelected = false
409    })
410    listen(doc, "mouseup", (event) => {
411      const selection = doc.getSelection?.()
412      this.textWasSelected = Boolean(selection && !selection.isCollapsed && selection.toString().trim())
413    })
414    listen(doc, "submit", (event) => event.preventDefault(), true)
415    listen(doc.defaultView, "scroll", () => this.reposition(), true)
416    listen(doc.defaultView, "resize", () => {
417      this.insetCache = new WeakMap()
418      this.reposition()
419    })
420  }
421
422  releaseFrame() {
423    this.cancelHoverRelease()
424    if (this.drag?.mode === "item") this.finishItemDrag(false)
425    this.finishDrag()
426    this.dropIndicator = null
427    this.unmarkCanvas()
428    ;(this.frameCleanups || []).forEach((cleanup) => {
429      try {
430        cleanup()
431      } catch (_error) {
432        // The document may already be gone; nothing left to unbind.
433      }
434    })
435    this.frameCleanups = []
436
437    try {
438      this.chrome?.remove()
439    } catch (_error) {
440      // Same: the chrome went away with its document.
441    }
442
443    this.chrome = null
444    this.chromeLabel = null
445    this.fieldOutline = null
446    this.fieldOutlineLabel = null
447    this.frameDocument = null
448  }
449
450  unmarkCanvas() {
451    const doc = this.frameDocument
452    if (!doc?.body) return
453
454    try {
455      doc.documentElement.classList.remove(CANVAS_CLASS)
456      this.canvasSections(doc).forEach((section) => {
457        section.classList.remove(SECTION_CLASS)
458        section.removeAttribute("tabindex")
459        section.removeAttribute("aria-label")
460      })
461    } catch (_error) {
462      // The document went away with the preview; nothing to clean up.
463    }
464  }
465
466  // Canvas interaction --------------------------------------------------
467
468  handleFrameHover(event) {
469    if (this.modeValue !== "visual") return
470    if (this.canvasStale) return
471    if (this.drag) return
472
473    // Moving onto the chrome itself must not count as leaving the section it
474    // belongs to, or the buttons would vanish the moment you reached for them.
475    if (event.target?.closest?.(`[${CHROME_ATTRIBUTE}]`)) {
476      this.cancelHoverRelease()
477      return
478    }
479
480    // Neutral space between sections is not a reason to take the chrome away.
481    // The toolbar and the insert points sit on a section's outside edges, so
482    // reaching for one means crossing that gap; releasing there would delete the
483    // button the pointer is travelling towards, and once it is gone there is
484    // nothing left to hover to bring it back. The chrome stays on the last
485    // section pointed at until another section claims it, or the pointer leaves
486    // the canvas entirely.
487    const section = this.canvasSectionFor(event.target)
488    if (!section) return
489
490    this.cancelHoverRelease()
491
492    // The inner outline tracks the pointer within the selected section, so it
493    // updates even while the section under the pointer has not changed.
494    if (this.canvasIndexOf(section) === this.selectedIndex) this.updateFieldOutline(event)
495    else this.hideFieldOutline()
496
497    if (this.hoverIndex === this.canvasIndexOf(section)) return
498
499    this.hoverIndex = this.canvasIndexOf(section)
500    this.reposition()
501  }
502
503  scheduleHoverRelease() {
504    if (this.hoverRelease) return
505
506    this.hoverRelease = window.setTimeout(() => {
507      this.hoverRelease = null
508      this.hoverIndex = null
509      this.reposition()
510    }, HOVER_GRACE_MS)
511  }
512
513  cancelHoverRelease() {
514    if (!this.hoverRelease) return
515
516    window.clearTimeout(this.hoverRelease)
517    this.hoverRelease = null
518  }
519
520  handleFrameClick(event) {
521    if (this.modeValue !== "visual") return
522
523    // Still an editor, so still no navigation - but a stale canvas must not be
524    // allowed to act, because its indices point at the previous structure.
525    if (this.canvasStale) {
526      event.preventDefault()
527      event.stopPropagation()
528      return
529    }
530
531    const itemAction = event.target?.closest?.("[data-ch-item-action]")
532    if (itemAction) {
533      event.preventDefault()
534      event.stopPropagation()
535      const itemKey = this.hoverField?.itemKey
536      if (!itemKey) return
537      if (itemAction.dataset.chItemAction === "add") this.addRepeaterItem(itemKey)
538      else this.requestItemRemove(itemKey)
539      return
540    }
541
542    const action = event.target?.closest?.("[data-ch-action]")
543    if (action) {
544      event.preventDefault()
545      event.stopPropagation()
546      this.runAction(action.dataset.chAction)
547      return
548    }
549
550    // A drag that selected words ends in a click, and treating that click as
551    // "open this section" moved focus into the panel and wiped the selection.
552    // This reads the gesture that just happened rather than the live selection:
553    // asking whether anything is selected right now also blocked the next click
554    // on a section whenever a selection happened to be left lying around.
555    if (this.textWasSelected) {
556      this.textWasSelected = false
557      event.preventDefault()
558      event.stopPropagation()
559      return
560    }
561
562    // The canvas is an editor, not a browser. Nothing inside it navigates,
563    // submits, or triggers a section's own click behavior.
564    const section = this.canvasSectionFor(event.target)
565    if (!section) return
566
567    event.preventDefault()
568    event.stopPropagation()
569
570    // Inside the section already being edited, a click resolves to the smallest
571    // thing under the pointer rather than re-selecting the whole section.
572    if (this.canvasIndexOf(section) === this.selectedIndex) {
573      const target = this.targetUnder(event.target)
574      if (target?.kind === "field") return this.focusField(target.entries[0])
575      if (target?.kind === "item") return this.focusItem(target.itemKey)
576    }
577
578    this.selectIndex(this.canvasIndexOf(section))
579  }
580
581  handleFrameKeydown(event) {
582    if (this.modeValue !== "visual") return
583
584    if (event.key === "Escape" && this.drag) {
585      event.preventDefault()
586      this.cancelDrag()
587      return
588    }
589
590    if (this.canvasStale) return
591    if (event.key !== "Enter" && event.key !== " ") return
592
593    const section = this.canvasSectionFor(event.target)
594    if (!section || section !== event.target) return
595
596    event.preventDefault()
597    this.selectIndex(this.canvasIndexOf(section))
598  }
599
600  runAction(action) {
601    if (this.canvasStale) return
602
603    const index = this.activeIndex()
604    if (index === null) return
605
606    switch (action) {
607      case "edit":
608        this.selectIndex(index)
609        break
610      case "remove":
611        this.requestRemove(index)
612        break
613      case "insert-before":
614        this.requestInsert(index)
615        break
616      case "insert-after":
617        this.requestInsert(index + 1)
618        break
619    }
620  }
621
622  // Section operations --------------------------------------------------
623
624  selectIndex(index) {
625    const sections = this.formSections()
626    if (index === null || index < 0 || index >= sections.length) return
627
628    sections.forEach((section, position) => {
629      section.open = position === index
630    })
631
632    this.selectedIndex = index
633    this.openPanel("section")
634    this.scrollCanvasTo(index)
635    this.reposition()
636    this.focusFirstField(sections[index])
637    this.loadFieldMap(index)
638  }
639
640  // The canvas insert points live on a section's edges, so a page with no
641  // sections left has nowhere to point at. This is the way back in.
642  addSectionAtEnd(event) {
643    event?.preventDefault()
644    this.requestInsert(this.formSections().length)
645  }
646
647  requestInsert(index) {
648    const root = this.sectionsRoot()
649    if (!root) return
650
651    // Keep the library dialog's ancestors displayed: a dialog whose ancestor is
652    // display:none never reaches the top layer, so it would open invisibly.
653    this.openPanel("section")
654    root.dispatchEvent(new CustomEvent("page-canvas:add-section", {
655      bubbles: false,
656      detail: { index }
657    }))
658  }
659
660  requestRemove(index) {
661    const section = this.formSections()[index]
662    if (!section) return
663
664    this.pendingRemoveIndex = index
665    this.pendingRemoveItemKey = null
666
667    this.describeRemoval(
668      "Remove this section?",
669      section.dataset.sectionLabelValue || "This section",
670      "will be removed from the page."
671    )
672  }
673
674  cancelRemove(event) {
675    event?.preventDefault()
676    this.pendingRemoveIndex = null
677    this.pendingRemoveItemKey = null
678    if (this.hasRemoveDialogTarget) this.removeDialogTarget.close()
679  }
680
681  confirmRemove(event) {
682    event?.preventDefault()
683
684    if (this.pendingRemoveItemKey) {
685      const itemKey = this.pendingRemoveItemKey
686      this.pendingRemoveItemKey = null
687      if (this.hasRemoveDialogTarget) this.removeDialogTarget.close()
688      this.removeRepeaterItem(itemKey)
689      return
690    }
691
692    const index = this.pendingRemoveIndex
693    this.pendingRemoveIndex = null
694    if (this.hasRemoveDialogTarget) this.removeDialogTarget.close()
695    if (index === null) return
696
697    const section = this.formSections()[index]
698    if (!section) return
699
700    section.remove()
701    this.selectedIndex = null
702    this.hoverIndex = null
703    this.closePanel()
704    this.notifySectionsChanged()
705  }
706
707  notifySectionsChanged() {
708    this.sectionsRoot()?.dispatchEvent(new CustomEvent("page-sections:changed", { bubbles: true }))
709  }
710
711  markCanvasStale() {
712    this.canvasStale = true
713    this.hoverIndex = null
714    this.clearFieldMap()
715    this.cancelHoverRelease()
716    if (this.chrome) this.chrome.hidden = true
717
718    // Marked on the document being superseded. The replacement arrives without
719    // it, which is what says the canvas is addressable again.
720    try {
721      this.frameDocument?.documentElement?.setAttribute("data-ch-stale", "true")
722    } catch (_error) {
723      // The document may already have been replaced.
724    }
725
726    // The canvas is unusable until it re-renders, so re-render it now instead of
727    // sitting out the debounce that exists for typing.
728    this.application
729      .getControllerForElementAndIdentifier(this.element, "page-live-preview")
730      ?.refreshImmediately()
731  }
732
733  // Reacts to add/remove/reorder from either surface, including the section
734  // library, so the canvas and the panel never disagree about what is selected.
735  sectionsChanged() {
736    if (this.modeValue !== "visual") return
737
738    // Adding, removing or reordering changes the form immediately, but the
739    // canvas only catches up after a debounced request round trip. Until it
740    // does, its data-site-section-index values describe the previous structure,
741    // so acting on them would edit or delete a different section than the one
742    // being pointed at. Refuse to act rather than act on the wrong thing.
743    this.markCanvasStale()
744
745    const sections = this.formSections()
746
747    // A freshly added row is opened alongside whatever was already open, so the
748    // first open row is not necessarily the one the editor just asked for.
749    const addedIndex = sections.findIndex((section) => section.dataset.sectionJustAdded === "true")
750    const openIndex = addedIndex >= 0 ? addedIndex : sections.findIndex((section) => section.open)
751
752    // The canvas still shows the previous render; reveal the new section once
753    // the preview has caught up.
754    if (addedIndex >= 0) this.pendingReveal = addedIndex
755
756    if (openIndex < 0) {
757      this.selectedIndex = null
758      this.closePanel()
759    } else {
760      sections.forEach((section, position) => {
761        section.open = position === openIndex
762      })
763      this.selectedIndex = openIndex
764      this.openPanel("section")
765    }
766
767    this.reposition()
768  }
769
770  syncSelectionFromForm() {
771    const sections = this.formSections()
772    if (this.selectedIndex !== null && this.selectedIndex < sections.length) return
773
774    const openIndex = sections.findIndex((section) => section.open)
775    this.selectedIndex = openIndex >= 0 ? openIndex : null
776  }
777
778  // Drag to reorder ------------------------------------------------------
779
780  startDrag(event) {
781    if (this.modeValue !== "visual" || this.canvasStale || this.drag) return
782    if (event.button !== undefined && event.button !== 0) return
783
784    // A card's grip and a section's grip both mean "move this", so they take the
785    // same route in, just with different things to move.
786    if (event.target?.closest?.("[data-ch-item-drag]")) return this.startItemDrag(event)
787
788    const handle = event.target?.closest?.("[data-ch-drag]")
789    if (!handle) return
790
791    const from = this.activeIndex()
792    if (from === null) return
793
794    const doc = this.frameDocument
795    const view = doc?.defaultView
796    if (!view) return
797
798    event.preventDefault()
799    event.stopPropagation()
800
801    this.drag = { from, to: from, pointerId: event.pointerId, clientY: event.clientY, handle, raf: null }
802
803    try {
804      handle.setPointerCapture(event.pointerId)
805    } catch (_error) {
806      // Without capture the document listeners still track the pointer.
807    }
808
809    doc.documentElement.setAttribute("data-ch-dragging", "true")
810    this.previousUserSelect = doc.body.style.userSelect
811    doc.body.style.userSelect = "none"
812    this.canvasSectionAt(from)?.classList.add("ch-canvas__section--dragging")
813
814    this.showDropIndicator(doc)
815    this.updateDrop(event.clientY)
816    this.drag.raf = view.requestAnimationFrame(() => this.autoScrollTick())
817  }
818
819  dragMove(event) {
820    if (!this.drag || event.pointerId !== this.drag.pointerId) return
821
822    event.preventDefault()
823    this.drag.clientX = event.clientX
824    this.drag.clientY = event.clientY
825
826    if (this.drag.mode === "item") this.updateItemDrop(event.clientX, event.clientY)
827    else this.updateDrop(event.clientY)
828  }
829
830  endDrag(event) {
831    if (!this.drag) return
832    if (event && event.pointerId !== this.drag.pointerId) return
833
834    event?.preventDefault()
835
836    if (this.drag.mode === "item") return this.finishItemDrag(true)
837
838    const { from, to } = this.drag
839    this.finishDrag()
840    this.moveSectionTo(from, to)
841  }
842
843  cancelDrag() {
844    if (!this.drag) return
845
846    if (this.drag.mode === "item") this.finishItemDrag(false)
847    else this.finishDrag()
848
849    this.reposition()
850  }
851
852  finishDrag() {
853    const drag = this.drag
854    this.drag = null
855    if (!drag) return
856
857    const doc = this.frameDocument
858    const view = doc?.defaultView
859
860    if (drag.raf && view) view.cancelAnimationFrame(drag.raf)
861
862    try {
863      if (drag.handle.hasPointerCapture?.(drag.pointerId)) {
864        drag.handle.releasePointerCapture(drag.pointerId)
865      }
866    } catch (_error) {
867      // Capture is already gone if the document was replaced.
868    }
869
870    try {
871      doc?.documentElement?.removeAttribute("data-ch-dragging")
872      if (doc?.body) doc.body.style.userSelect = this.previousUserSelect || ""
873      doc?.querySelectorAll(".ch-canvas__section--dragging")
874        .forEach((el) => el.classList.remove("ch-canvas__section--dragging"))
875    } catch (_error) {
876      // Nothing to restore on a document that has gone away.
877    }
878
879    this.hideDropIndicator()
880  }
881
882  // Which gap the section would land in, as an insertion index in 0..count.
883  updateDrop(clientY) {
884    if (!this.drag) return
885
886    const sections = this.canvasSections()
887    let to = sections.length
888
889    for (let index = 0; index < sections.length; index += 1) {
890      const rect = sections[index].getBoundingClientRect()
891      if (clientY < rect.top + rect.height / 2) {
892        to = index
893        break
894      }
895    }
896
897    this.drag.to = to
898    this.positionDropIndicator(to, sections)
899  }
900
901  // Dragging towards the top or bottom edge scrolls the canvas, so a section can
902  // be moved further than one screenful without letting go.
903  autoScrollTick() {
904    if (!this.drag) return
905
906    const view = this.frameDocument?.defaultView
907    if (!view) return
908
909    const height = view.innerHeight
910    const y = this.drag.clientY
911    let delta = 0
912
913    if (y < DRAG_SCROLL_ZONE) {
914      delta = -Math.ceil((DRAG_SCROLL_ZONE - y) / DRAG_SCROLL_DIVISOR)
915    } else if (y > height - DRAG_SCROLL_ZONE) {
916      delta = Math.ceil((y - (height - DRAG_SCROLL_ZONE)) / DRAG_SCROLL_DIVISOR)
917    }
918
919    if (delta !== 0) {
920      view.scrollBy(0, delta)
921      if (this.drag.mode === "item") this.updateItemDrop(this.drag.clientX, y)
922      else this.updateDrop(y)
923      this.reposition()
924    }
925
926    this.drag.raf = view.requestAnimationFrame(() => this.autoScrollTick())
927  }
928
929  showDropIndicator(doc) {
930    if (!this.dropIndicator || !doc.contains(this.dropIndicator)) {
931      this.dropIndicator = doc.createElement("div")
932      this.dropIndicator.className = "ch-drop"
933      this.dropIndicator.setAttribute("aria-hidden", "true")
934      doc.body.appendChild(this.dropIndicator)
935    }
936
937    this.dropIndicator.hidden = false
938  }
939
940  positionDropIndicator(to, sections) {
941    if (!this.dropIndicator) return
942
943    const reference = sections[to] || sections[sections.length - 1]
944    if (!reference) return
945
946    const rect = reference.getBoundingClientRect()
947    const y = sections[to] ? rect.top : rect.bottom
948
949    this.dropIndicator.style.top = `${Math.round(y)}px`
950    this.dropIndicator.style.left = `${Math.round(rect.left)}px`
951    this.dropIndicator.style.width = `${Math.round(rect.width)}px`
952  }
953
954  hideDropIndicator() {
955    if (!this.dropIndicator) return
956    this.dropIndicator.hidden = true
957  }
958
959  moveSectionTo(from, to) {
960    // Dropped back in its own gap.
961    if (to === from || to === from + 1) {
962      this.reposition()
963      return
964    }
965
966    const sections = this.formSections()
967    const moving = sections[from]
968    if (!moving) return
969
970    const reference = sections[to]
971    if (reference) reference.before(moving)
972    else this.sectionListTarget.appendChild(moving)
973
974    // Removing the row first shifts everything after it up by one.
975    const landed = to > from ? to - 1 : to
976    if (this.selectedIndex === from) this.selectedIndex = landed
977
978    this.notifySectionsChanged()
979  }
980
981  // Fields inside a section -----------------------------------------------
982  //
983  // The section outline says "this is the hero". These say "this is the hero's
984  // headline", so a single line or photo can be picked out and edited without
985  // reading the panel to work out which control drives what.
986
987  // The form is the authority on what a field is: its own controls already know
988  // a headline from a dropdown from an image picker. Sending that classification
989  // means the server never has to guess from key names.
990  classifyPanelFields(details) {
991    const fields = []
992    const urlFields = []
993    if (!details) return { fields, urlFields }
994
995    details.querySelectorAll("[data-section-field]").forEach((node) => {
996      const key = node.dataset.sectionField
997      if (!key || STRUCTURAL_FIELDS.includes(key)) return
998
999      const tag = node.tagName
1000      if (tag === "SELECT") return
1001      if (tag === "INPUT" && NON_TEXT_INPUT_TYPES.includes(node.type)) return
1002
1003      // A repeater's whole array lives in this one input. It is not a value that
1004      // appears anywhere on the page, so there is nothing to locate for it; its
1005      // items are collected below instead.
1006      if (node.dataset.customRepeaterTarget === "value") return
1007
1008      fields.push(key)
1009      if (node.type === "url" || URL_FIELD_PATTERN.test(key)) urlFields.push(key)
1010    })
1011
1012    // Item fields are not section fields, so they have to be addressed by where
1013    // they sit: shortcuts.0.title. That is what lets each item be found on the
1014    // page rather than only in the panel.
1015    details.querySelectorAll("[data-controller~='custom-repeater']").forEach((root) => {
1016      const repeater = root.querySelector("[data-custom-repeater-target='value']")?.dataset.sectionField
1017      if (!repeater) return
1018
1019      root.querySelectorAll("[data-custom-repeater-target='item']").forEach((item, index) => {
1020        item.querySelectorAll("[data-custom-repeater-item-field]").forEach((child) => {
1021          const tag = child.tagName
1022          if (tag === "SELECT") return
1023          if (tag === "INPUT" && NON_TEXT_INPUT_TYPES.includes(child.type)) return
1024
1025          const path = `${repeater}.${index}.${child.dataset.customRepeaterItemField}`
1026          fields.push(path)
1027          if (child.type === "url" || URL_FIELD_PATTERN.test(child.dataset.customRepeaterItemField)) urlFields.push(path)
1028        })
1029      })
1030    })
1031
1032    return { fields, urlFields }
1033  }
1034
1035  async loadFieldMap(index) {
1036    this.clearFieldMap()
1037    if (!this.hasFieldMapUrlValue || this.modeValue !== "visual") return
1038    this.fieldMapIndex = index
1039
1040    const details = this.formSections()[index]
1041    const { fields, urlFields } = this.classifyPanelFields(details)
1042    if (!fields.length) return
1043
1044    this.fieldRequest?.abort()
1045    this.fieldRequest = new AbortController()
1046
1047    const body = new FormData(this.element)
1048    body.delete("_method")
1049    body.append("section_index", String(index))
1050    fields.forEach((field) => body.append("fields[]", field))
1051    urlFields.forEach((field) => body.append("url_fields[]", field))
1052
1053    try {
1054      const response = await fetch(this.fieldMapUrlValue, {
1055        method: "POST",
1056        body,
1057        headers: { Accept: "application/json", "X-CSRF-Token": this.csrfToken() },
1058        credentials: "same-origin",
1059        signal: this.fieldRequest.signal
1060      })
1061      if (!response.ok) return
1062
1063      const payload = await response.json()
1064      // The canvas may have moved on while this was in flight.
1065      if (this.fieldMapIndex !== index || this.canvasStale) return
1066
1067      this.markableFields = new Set(payload.markable || [])
1068      this.applyFieldMap(index, payload.fields || {})
1069      this.revealStyledField()
1070
1071    } catch (_error) {
1072      // Field-level editing is an accelerator. Losing it leaves the section
1073      // outline and the panel working exactly as before.
1074    }
1075  }
1076
1077  applyFieldMap(index, map) {
1078    const section = this.canvasSectionAt(index)
1079    if (!section) return
1080
1081    Object.entries(map).forEach(([field, location]) => {
1082      const node = this.resolvePath(section, location.path)
1083      if (!node) return
1084      if (!this.fieldNodes.has(node)) this.fieldNodes.set(node, [])
1085      this.fieldNodes.get(node).push({ field, ...location })
1086      // fieldNodes is keyed by node and has no reverse lookup, which is what
1087      // re-anchoring the format bar after a re-render needs.
1088      if (location.kind === "text" && !this.nodesByField.has(field)) this.nodesByField.set(field, node)
1089    })
1090
1091    // Several fields can land on one element: an <img> carries both its source
1092    // and its alt text, a link carries both its label and its href. Pointing at
1093    // it should offer the one that element mostly is.
1094    this.fieldNodes.forEach((entries) => entries.sort((a, b) => this.entryRank(a) - this.entryRank(b)))
1095
1096    this.markRepeaterItems(index, map, section)
1097
1098    // Says the section's fields are addressable now. Nothing renders from this;
1099    // it exists so callers do not have to guess when the map has landed.
1100    try {
1101      section.ownerDocument.documentElement.dataset.chFields = String(this.fieldNodes.size)
1102    } catch (_error) {
1103      // The document may already have been replaced.
1104    }
1105  }
1106
1107  entryRank(entry) {
1108    if (entry.kind === "text") return 0
1109    if (entry.attribute === "src" || entry.attribute === "href") return 1
1110    return 2
1111  }
1112
1113  // Paths are child indices from the section wrapper, and DOM `children` is
1114  // element-only, which is the same thing the server counted.
1115  resolvePath(root, path) {
1116    let node = root
1117    for (const step of Array.isArray(path) ? path : []) {
1118      node = node.children?.[step]
1119      if (!node) return null
1120    }
1121    return node === root ? null : node
1122  }
1123
1124  clearFieldMap() {
1125    this.fieldNodes = new Map()
1126    this.nodesByField = new Map()
1127    this.repeaterItems = new Map()
1128    this.fieldMapIndex = null
1129    this.hoverField = null
1130    this.hideFieldOutline()
1131
1132    try {
1133      delete this.frameDocument?.documentElement?.dataset?.chFields
1134    } catch (_error) {
1135      // Same: nothing to clear on a document that has gone away.
1136    }
1137  }
1138
1139  csrfToken() {
1140    const meta = document.querySelector("meta[name='csrf-token']")
1141    return meta ? meta.content : ""
1142  }
1143
1144  // Repeater items ---------------------------------------------------------
1145  //
1146  // Numbered fields (member_3_name, image_5_url) all land inside the one card
1147  // they belong to, so the shallowest path they share IS that card. That is
1148  // derived from where the values actually rendered, which is why it survives
1149  // the loops that compact away empty slots and renumber what is left.
1150  markRepeaterItems(index, map, section) {
1151    const groups = new Map()
1152
1153    Object.entries(map).forEach(([field, location]) => {
1154      const itemKey = this.itemKeyFor(field)
1155      if (!itemKey) return
1156      if (!groups.has(itemKey)) groups.set(itemKey, [])
1157      groups.get(itemKey).push(location.path)
1158    })
1159
1160    groups.forEach((paths, itemKey) => {
1161      const common = this.commonPath(paths)
1162      // An empty common path means the fields share only the section itself,
1163      // which is not a card and must not be offered as one.
1164      if (!common.length) return
1165
1166      const node = this.resolvePath(section, common)
1167      if (node) this.repeaterItems.set(node, itemKey)
1168    })
1169  }
1170
1171  itemKeyFor(field) {
1172    const path = String(field).match(REPEATER_PATH_PATTERN)
1173    if (path) return `${path[1]}.${path[2]}`
1174
1175    const numbered = String(field).match(REPEATER_KEY_PATTERN)
1176    return numbered ? `${numbered[1]}_${numbered[2]}` : null
1177  }
1178
1179  commonPath(paths) {
1180    if (!paths.length) return []
1181
1182    return paths.reduce((common, path) => {
1183      let length = 0
1184      while (length < common.length && length < path.length && common[length] === path[length]) length += 1
1185      return common.slice(0, length)
1186    })
1187  }
1188
1189  // Hit testing --------------------------------------------------------------
1190
1191  // Innermost wins: pointing at a headline inside a card inside a section should
1192  // offer the headline.
1193  targetUnder(node) {
1194    let current = node
1195    while (current && current !== this.frameDocument?.body) {
1196      if (this.fieldNodes.has(current)) {
1197        const entries = this.fieldNodes.get(current)
1198        return {
1199          kind: "field",
1200          node: current,
1201          entries,
1202          itemKey: this.itemKeyFor(entries[0].field)
1203        }
1204      }
1205      if (this.repeaterItems.has(current)) {
1206        return { kind: "item", node: current, itemKey: this.repeaterItems.get(current) }
1207      }
1208      current = current.parentElement
1209    }
1210    return null
1211  }
1212
1213  updateFieldOutline(event) {
1214    if (this.drag) return
1215    if (this.canvasStale) return this.hideFieldOutline()
1216    if (this.selectedIndex === null) return this.hideFieldOutline()
1217    if (event.target?.closest?.(".ch-field")) return
1218
1219    const target = this.targetUnder(event.target)
1220    if (!target) return this.hideFieldOutline()
1221
1222    this.hoverField = target
1223    this.drawFieldOutline(target)
1224  }
1225
1226  drawFieldOutline(target) {
1227    const doc = this.frameDocument
1228    if (!doc?.body) return
1229
1230    if (!this.fieldOutline || !doc.contains(this.fieldOutline)) {
1231      this.fieldOutline = doc.createElement("div")
1232      this.fieldOutline.className = "ch-field"
1233      this.fieldOutline.setAttribute("aria-hidden", "true")
1234
1235      // Same shape as the section toolbar: grab the name to move the thing the
1236      // name refers to.
1237      const bar = doc.createElement("span")
1238      bar.className = "ch-field__bar"
1239
1240      const grip = doc.createElement("button")
1241      grip.type = "button"
1242      grip.className = "ch-field__grip"
1243      grip.tabIndex = -1
1244      grip.title = "Drag to move this one"
1245      grip.setAttribute("data-ch-item-drag", "")
1246      grip.innerHTML = this.icon(ICONS.grip)
1247      bar.appendChild(grip)
1248
1249      const label = doc.createElement("span")
1250      label.className = "ch-field__label"
1251      bar.appendChild(label)
1252      this.fieldOutlineLabel = label
1253
1254      // Alongside the name rather than at the far edge: a field that runs the full
1255      // width of the page put them underneath the settings panel.
1256      const actions = doc.createElement("span")
1257      actions.className = "ch-field__actions"
1258      actions.appendChild(this.buildItemAction(doc, "add", "Add another", ICONS.plus))
1259      actions.appendChild(this.buildItemAction(doc, "remove", "Remove this one", ICONS.remove))
1260      bar.appendChild(actions)
1261
1262      this.fieldOutline.appendChild(bar)
1263
1264      doc.body.appendChild(this.fieldOutline)
1265    }
1266
1267    const rect = this.paintedRect(target.node)
1268    if (!rect.width && !rect.height) return this.hideFieldOutline()
1269
1270    this.fieldOutline.hidden = false
1271    this.fieldOutline.dataset.kind = target.kind
1272    this.fieldOutline.style.left = `${Math.round(rect.left)}px`
1273    this.fieldOutline.style.top = `${Math.round(rect.top)}px`
1274    this.fieldOutline.style.width = `${Math.round(rect.width)}px`
1275    this.fieldOutline.style.height = `${Math.round(rect.height)}px`
1276    this.fieldOutline.dataset.above = String(rect.top < 24)
1277    this.fieldOutline.dataset.hasItem = String(Boolean(target.itemKey))
1278
1279    if (this.fieldOutlineLabel) {
1280      this.fieldOutlineLabel.textContent = target.kind === "item"
1281        ? this.itemLabelFor(target.itemKey)
1282        : this.fieldLabelFor(target.entries[0])
1283    }
1284  }
1285
1286  buildItemAction(doc, action, label, iconPath) {
1287    const button = doc.createElement("button")
1288    button.type = "button"
1289    button.className = "ch-field__action"
1290    button.dataset.chItemAction = action
1291    button.tabIndex = -1
1292    button.title = label
1293    button.setAttribute("aria-label", label)
1294    button.innerHTML = this.icon(iconPath)
1295    return button
1296  }
1297
1298  hideFieldOutline() {
1299    this.hoverField = null
1300    if (this.fieldOutline) this.fieldOutline.hidden = true
1301  }
1302
1303  fieldLabelFor(entry) {
1304    const input = this.panelFieldNode(entry.field) || this.itemFieldNode(entry.field)
1305    const label = input?.closest("div")?.querySelector("label")?.textContent?.trim()
1306    if (label) return label
1307
1308    // Image pickers have no <label> of their own, so read the key instead. A
1309    // field inside a repeater item is named by where it sits, so only its last
1310    // part is the field: routes.0.label is a label, not a "Routes.0.label".
1311    const key = entry.field.split(".").pop()
1312    const words = key.replace(/_/g, " ").replace(/\s+(url|href)$/, "").trim()
1313    return words.charAt(0).toUpperCase() + words.slice(1)
1314  }
1315
1316  // The panel control for a repeater item's field, which is addressed by its
1317  // position rather than by a section field name.
1318  itemFieldNode(field) {
1319    const match = String(field).match(/^(.+)\.(\d+)\.(.+)$/)
1320    if (!match) return null
1321
1322    const details = this.formSections()[this.fieldMapIndex ?? this.selectedIndex]
1323    if (!details) return null
1324
1325    const value = details.querySelector(`[data-custom-repeater-target="value"][data-section-field="${CSS.escape(match[1])}"]`)
1326    const root = value?.closest("[data-controller~='custom-repeater']")
1327    const item = root?.querySelectorAll("[data-custom-repeater-target='item']")[Number(match[2])]
1328    return item?.querySelector(`[data-custom-repeater-item-field="${CSS.escape(match[3])}"]`) || null
1329  }
1330
1331  itemLabelFor(itemKey) {
1332    const dotted = String(itemKey).match(/^(.+)\.(\d+)$/)
1333    if (dotted) {
1334      const words = dotted[1].split(".").pop().replace(/_/g, " ").replace(/s$/, "")
1335      return `${words.charAt(0).toUpperCase()}${words.slice(1)} ${Number(dotted[2]) + 1}`
1336    }
1337
1338    const [prefix, number] = itemKey.split(/_(\d+)$/)
1339    const words = prefix.replace(/_/g, " ")
1340    return `${words.charAt(0).toUpperCase()}${words.slice(1)} ${number}`
1341  }
1342
1343  // Acting on a field --------------------------------------------------------
1344
1345  // Editing still happens in the panel: it already has the right control for
1346  // every type, including Trix and the image uploader. This only removes the
1347  // hunt for which control that is.
1348  focusField(entry) {
1349    const input = this.panelFieldNode(entry.field) || this.itemFieldNode(entry.field)
1350    if (!input) return
1351
1352    input.closest("details:not([open])")?.setAttribute("open", "")
1353    this.revealPanelItem(input)
1354
1355    // A repeater item is a collapsed disclosure, so the field being pointed at is
1356    // not reachable until the item holding it is opened.
1357    const item = input.closest("[data-custom-repeater-item]")
1358    if (item) {
1359      const root = item.closest("[data-controller~='custom-repeater']")
1360      this.application
1361        .getControllerForElementAndIdentifier(root, "custom-repeater")
1362        ?.setExpanded(item, true)
1363    }
1364
1365    const target = this.panelControlFor(entry.field, input)
1366    target.scrollIntoView({ block: "center", behavior: "smooth" })
1367
1368    if (this.isFocusable(target)) {
1369      target.focus({ preventScroll: true })
1370      if (target.tagName === "INPUT" && target.type === "text") target.select?.()
1371    }
1372
1373    this.flashPanelControl(target)
1374  }
1375
1376  // An image field's value lives in a hidden input, so focusing it does nothing
1377  // anyone can see. The control that field is actually edited through is the
1378  // upload picker keyed to the same field.
1379  panelControlFor(field, input) {
1380    if (this.isFocusable(input)) return input
1381
1382    const details = this.formSections()[this.selectedIndex]
1383    const picker = details?.querySelector(`[data-upload-field-key="${CSS.escape(field)}"]`)
1384    return picker || input.closest("div") || input
1385  }
1386
1387  isFocusable(node) {
1388    if (!(node instanceof HTMLElement)) return false
1389    if (node.tagName === "INPUT" && node.type === "hidden") return false
1390    return node.offsetParent !== null || node.tagName === "TRIX-EDITOR"
1391  }
1392
1393  // Focus alone is easy to miss on a file input, and invisible on a picker whose
1394  // value is hidden. Say where you landed.
1395  flashPanelControl(target) {
1396    const block = target.closest("div") || target
1397    block.classList.add("page-editor-flash")
1398    window.setTimeout(() => block.classList.remove("page-editor-flash"), 1200)
1399  }
1400
1401  // progressive-items hides spare slots with a class, not the hidden property.
1402  revealPanelItem(node) {
1403    node.closest("[data-progressive-items-target~='item']")?.classList.remove("hidden")
1404  }
1405
1406  panelFieldNode(field, index = null) {
1407    const at = index === null ? (this.fieldMapIndex ?? this.selectedIndex) : index
1408    const details = this.formSections()[at]
1409    if (!details) return null
1410    return details.querySelector(`[data-section-field="${CSS.escape(field)}"]`)
1411  }
1412
1413  requestItemRemove(itemKey) {
1414    this.pendingRemoveItemKey = itemKey
1415    this.pendingRemoveIndex = null
1416
1417    this.describeRemoval(
1418      "Remove this item?",
1419      this.itemLabelFor(itemKey),
1420      "will be removed from this section."
1421    )
1422  }
1423
1424  describeRemoval(title, subject, scope) {
1425    if (this.hasRemoveTitleTarget) this.removeTitleTarget.textContent = title
1426    if (this.hasRemoveLabelTarget) this.removeLabelTarget.textContent = subject
1427    if (this.hasRemoveScopeTarget) this.removeScopeTarget.textContent = scope
1428    if (this.hasRemoveDialogTarget) this.removeDialogTarget.showModal()
1429  }
1430
1431  removeRepeaterItem(itemKey) {
1432    const found = this.repeaterControllerFor(itemKey)
1433    if (!found.controller || !found.item) return
1434
1435    // The panel's own controls are the API: custom-repeater reads the pressed
1436    // button to find which item it is acting on.
1437    if (found.kind === "custom") {
1438      found.item.querySelector("[data-custom-repeater-remove]")?.click()
1439      this.notifySectionsChanged()
1440      return
1441    }
1442
1443    found.controller.removeItemElement(found.item)
1444    this.notifySectionsChanged()
1445  }
1446
1447  addRepeaterItem(itemKey) {
1448    const found = this.repeaterControllerFor(itemKey)
1449    if (found.kind === "custom") {
1450      found.root?.querySelector("[data-custom-repeater-target='addButton']")?.click()
1451      this.notifySectionsChanged()
1452      return
1453    }
1454
1455    found.controller?.addButtonTarget?.click()
1456  }
1457
1458  repeaterControllerFor(itemKey) {
1459    const details = this.formSections()[this.selectedIndex] || this.formSections()[this.fieldMapIndex]
1460    if (!details) return {}
1461
1462    const dotted = String(itemKey).match(/^(.+)\.(\d+)$/)
1463    if (dotted) return this.customRepeaterFor(details, dotted[1], Number(dotted[2]))
1464
1465    // Any field of the item is enough to find the item element it sits in.
1466    const input = details.querySelector(`[data-section-field^="${CSS.escape(itemKey)}_"]`)
1467    const root = input?.closest("[data-controller~='progressive-items']")
1468    if (!root) return {}
1469
1470    return {
1471      controller: this.application.getControllerForElementAndIdentifier(root, "progressive-items"),
1472      item: input.closest("[data-progressive-items-target~='item']"),
1473      kind: "progressive"
1474    }
1475  }
1476
1477  customRepeaterFor(details, repeater, index) {
1478    const value = details.querySelector(`[data-custom-repeater-target="value"][data-section-field="${CSS.escape(repeater)}"]`)
1479    const root = value?.closest("[data-controller~='custom-repeater']")
1480    if (!root) return {}
1481
1482    return {
1483      controller: this.application.getControllerForElementAndIdentifier(root, "custom-repeater"),
1484      item: root.querySelectorAll("[data-custom-repeater-target='item']")[index],
1485      root,
1486      kind: "custom"
1487    }
1488  }
1489
1490  // Hold to reorder a card ---------------------------------------------------
1491  //
1492  // A card is already a click target, so picking one up has to be a different
1493  // gesture. Press and hold commits to moving it; moving the pointer first means
1494  // you were doing something else.
1495
1496  startItemDrag(event) {
1497    // The grip belongs to whatever the outline is currently on.
1498    const target = this.hoverField
1499    if (!target?.itemKey) return
1500
1501    const node = this.itemNodeFor(target)
1502    const doc = this.frameDocument
1503    const view = doc?.defaultView
1504    if (!node?.isConnected || !view) return
1505
1506    event.preventDefault()
1507    event.stopPropagation()
1508
1509    this.drag = {
1510      mode: "item",
1511      itemKey: target.itemKey,
1512      node,
1513      pointerId: event.pointerId,
1514      clientX: event.clientX,
1515      clientY: event.clientY,
1516      drop: null,
1517      raf: null
1518    }
1519
1520    try {
1521      event.target.closest("[data-ch-item-drag]").setPointerCapture(event.pointerId)
1522    } catch (_error) {
1523      // Without capture the document listeners still track the pointer.
1524    }
1525
1526    doc.documentElement.setAttribute("data-ch-dragging", "item")
1527    // The panel sits over part of the canvas, and a card may need to land under
1528    // it. Fade it for the duration rather than making the drop unreachable.
1529    this.element.dataset.canvasDragging = "true"
1530    this.previousUserSelect = doc.body.style.userSelect
1531    doc.body.style.userSelect = "none"
1532    node.classList.add("ch-canvas__section--dragging")
1533
1534    this.showDropIndicator(doc)
1535    this.updateItemDrop(event.clientX, event.clientY)
1536    this.drag.raf = view.requestAnimationFrame(() => this.autoScrollTick())
1537  }
1538
1539  // Cards can sit in a row, a column, or a grid, so the nearest one wins and the
1540  // side is decided on whichever axis the pointer is further from its centre.
1541  updateItemDrop(clientX, clientY) {
1542    if (!this.drag || this.drag.mode !== "item") return
1543
1544    let best = null
1545    let bestDistance = Infinity
1546
1547    this.itemDragSiblings().forEach((entry) => {
1548      const rect = entry.node.getBoundingClientRect()
1549      const centreX = rect.left + rect.width / 2
1550      const centreY = rect.top + rect.height / 2
1551      const distance = ((clientX - centreX) ** 2) + ((clientY - centreY) ** 2)
1552
1553      if (distance < bestDistance) {
1554        bestDistance = distance
1555        best = { entry, rect, centreX, centreY }
1556      }
1557    })
1558
1559    if (!best) return
1560
1561    const dx = clientX - best.centreX
1562    const dy = clientY - best.centreY
1563    const horizontal = Math.abs(dx) > Math.abs(dy)
1564    const after = horizontal ? dx > 0 : dy > 0
1565
1566    this.drag.drop = { itemKey: best.entry.itemKey, after }
1567    this.positionItemDropIndicator(best.rect, { horizontal, after })
1568  }
1569
1570  positionItemDropIndicator(rect, { horizontal, after }) {
1571    const indicator = this.dropIndicator
1572    if (!indicator) return
1573
1574    indicator.dataset.orientation = horizontal ? "vertical" : "horizontal"
1575
1576    if (horizontal) {
1577      indicator.style.left = `${Math.round(after ? rect.right : rect.left)}px`
1578      indicator.style.top = `${Math.round(rect.top)}px`
1579      indicator.style.width = "0px"
1580      indicator.style.height = `${Math.round(rect.height)}px`
1581    } else {
1582      indicator.style.left = `${Math.round(rect.left)}px`
1583      indicator.style.top = `${Math.round(after ? rect.bottom : rect.top)}px`
1584      indicator.style.width = `${Math.round(rect.width)}px`
1585      indicator.style.height = "0px"
1586    }
1587  }
1588
1589  // Only cards from the same repeater, in the order they render.
1590  itemDragSiblings() {
1591    const prefix = this.repeaterPrefix(this.drag?.itemKey)
1592    if (!prefix) return []
1593
1594    const entries = []
1595    this.repeaterItems.forEach((itemKey, node) => {
1596      if (this.repeaterPrefix(itemKey) === prefix && node.isConnected) entries.push({ node, itemKey })
1597    })
1598
1599    return entries.sort((a, b) =>
1600      a.node.compareDocumentPosition(b.node) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
1601    )
1602  }
1603
1604  repeaterPrefix(itemKey) {
1605    return itemKey ? String(itemKey).replace(/_\d+$/, "") : null
1606  }
1607
1608  // The card element for whatever is being pointed at, which for a one-field
1609  // card is the field's own element.
1610  itemNodeFor(target) {
1611    if (target.kind === "item") return target.node
1612
1613    let current = target.node
1614    while (current && current !== this.frameDocument?.body) {
1615      if (this.repeaterItems.has(current)) return current
1616      current = current.parentElement
1617    }
1618    return target.node
1619  }
1620
1621  finishItemDrag(apply) {
1622    const drag = this.drag
1623    this.drag = null
1624    if (!drag) return
1625
1626    const view = this.frameDocument?.defaultView
1627    if (drag.raf && view) view.cancelAnimationFrame(drag.raf)
1628    delete this.element.dataset.canvasDragging
1629
1630    try {
1631      this.frameDocument?.documentElement?.removeAttribute("data-ch-dragging")
1632      if (this.frameDocument?.body) this.frameDocument.body.style.userSelect = this.previousUserSelect || ""
1633      drag.node?.classList?.remove("ch-canvas__section--dragging")
1634    } catch (_error) {
1635      // The document went away with the preview.
1636    }
1637
1638    this.hideDropIndicator()
1639
1640    if (apply && drag.drop) this.moveRepeaterItem(drag.itemKey, drag.drop)
1641  }
1642
1643  moveRepeaterItem(itemKey, { itemKey: targetKey, after }) {
1644    if (itemKey === targetKey) return
1645
1646    const source = this.repeaterControllerFor(itemKey)
1647    const target = this.repeaterControllerFor(targetKey)
1648    if (!source.controller || !source.item || !target.item) return
1649    if (source.controller !== target.controller) return
1650
1651    if (after) target.item.after(source.item)
1652    else target.item.before(source.item)
1653
1654    if (source.kind === "custom") {
1655      source.controller.refresh()
1656      this.notifySectionsChanged()
1657      return
1658    }
1659
1660    source.controller.reindexItems()
1661    source.controller.applyVisibility()
1662    source.controller.emitChanged()
1663  }
1664
1665  // Formatting where the words are ------------------------------------------
1666  //
1667  // Styling lives with the field it changes, in the panel, directly under the
1668  // input holding the words. Everything for one thing is therefore in one place.
1669  //
1670  // It used to float over the canvas at the words themselves. That put the words
1671  // and their styling in one place but their content somewhere else, which is the
1672  // half of the split that mattered: the thing staff reach for most is the text,
1673  // and the text is edited in the panel.
1674  //
1675  // One row exists and it moves to whichever field has focus, so the panel never
1676  // carries a row per field and there is never more than one to read.
1677  panelFormatRow() {
1678    if (this.formatRow && this.element.contains(this.formatRow)) return this.formatRow
1679
1680    const row = document.createElement("div")
1681    row.className = "page-editor__format"
1682    row.setAttribute("role", "toolbar")
1683    row.hidden = true
1684
1685    Object.entries(TEXT_FORMATS).forEach(([name, spec]) => {
1686      row.appendChild(this.buildFormatButton(document, name, spec))
1687    })
1688    row.appendChild(this.buildFormatButton(document, "clear", null))
1689
1690    row.addEventListener("mousedown", (event) => event.preventDefault())
1691    row.addEventListener("click", (event) => {
1692      const button = event.target.closest("[data-ch-format]")
1693      if (!button) return
1694
1695      event.preventDefault()
1696      this.applyFormat(button.dataset.chFormat)
1697    })
1698
1699    this.formatRow = row
1700    return row
1701  }
1702
1703  // Only what the server will actually store a style for. The canvas already
1704  // fetched that list for this section, so the panel needs no request of its own.
1705  panelFieldIsStylable(input) {
1706    if (!input || this.modeValue !== "visual") return false
1707    if (!(input.tagName === "TEXTAREA" || (input.tagName === "INPUT" && input.type === "text"))) return false
1708
1709    const field = input.dataset.sectionField
1710    if (!field || STRUCTURAL_FIELDS.includes(field)) return false
1711
1712    return Boolean(this.markableFields?.has(field))
1713  }
1714
1715  // Also called when a section opens, so a field that already carries a style
1716  // shows it without being clicked first: otherwise a bold headline looks exactly
1717  // like a plain one until you happen to put the cursor in it.
1718  revealStyledField() {
1719    const index = this.fieldMapIndex ?? this.selectedIndex
1720    const details = this.formSections()[index]
1721    if (!details) return
1722
1723    const styles = this.parseStyles(this.panelFieldNode(STYLES_FIELD, index)?.value)
1724    const styled = Object.keys(styles).find((field) => styles[field]?.length)
1725    if (!styled) return
1726
1727    const input = details.querySelector(`[data-section-field="${CSS.escape(styled)}"]`)
1728    if (input && !document.activeElement?.closest?.(".page-editor__panel")) this.showFormatRowFor(input)
1729  }
1730
1731  handlePanelFocus(event) {
1732    const input = event.target?.closest?.("[data-section-field]")
1733    if (!this.panelFieldIsStylable(input)) return this.hideFormatRow()
1734
1735    this.showFormatRowFor(input)
1736  }
1737
1738  showFormatRowFor(input) {
1739    if (!this.panelFieldIsStylable(input)) return this.hideFormatRow()
1740
1741    const index = this.fieldMapIndex ?? this.selectedIndex
1742    if (index === null || index === undefined) return this.hideFormatRow()
1743
1744    this.formatTarget = { field: input.dataset.sectionField, index }
1745    const row = this.panelFormatRow()
1746    row.hidden = false
1747    row.setAttribute("aria-label", `Style ${this.fieldLabelForInput(input)}`)
1748    input.insertAdjacentElement("afterend", row)
1749    this.syncFormatButtons()
1750  }
1751
1752  fieldLabelForInput(input) {
1753    const label = input.closest("div")?.querySelector("label")?.textContent?.trim()
1754    if (label) return label.replace(/\s*\(optional\)\s*$/i, "")
1755
1756    return String(input.dataset.sectionField || "text").split(".").pop().replace(/_/g, " ")
1757  }
1758
1759  hideFormatRow() {
1760    this.formatTarget = null
1761    if (this.formatRow) this.formatRow.hidden = true
1762  }
1763
1764  parseStyles(raw) {
1765    try {
1766      const parsed = JSON.parse(raw || "{}")
1767      return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}
1768    } catch (_error) {
1769      return {}
1770    }
1771  }
1772
1773  applyFormat(name) {
1774    const target = this.formatTarget
1775    if (!target) return
1776
1777    const input = this.panelFieldNode(STYLES_FIELD, target.index)
1778    if (!input) return
1779
1780    const all = this.parseStyles(input.value)
1781    const marks = new Set(all[target.field] || [])
1782
1783    if (name === "clear") {
1784      marks.clear()
1785    } else {
1786      const mark = TEXT_FORMATS[name].mark
1787      const had = marks.has(mark)
1788      // One size at a time, so pressing Largest twice cannot compound.
1789      if (SIZE_MARKS.includes(mark)) SIZE_MARKS.forEach((size) => marks.delete(size))
1790      else marks.delete(mark)
1791      if (!had) marks.add(mark)
1792    }
1793
1794    if (marks.size) all[target.field] = [...marks]
1795    else delete all[target.field]
1796
1797    input.value = Object.keys(all).length ? JSON.stringify(all) : ""
1798    input.dispatchEvent(new Event("input", { bubbles: true }))
1799    input.dispatchEvent(new Event("change", { bubbles: true }))
1800
1801    this.syncFormatButtons()
1802    this.previewController()?.queueRefreshSoon()
1803  }
1804
1805  // Which styles this field is carrying, so the row reads as a toolbar rather
1806  // than six buttons with no memory.
1807  syncFormatButtons() {
1808    const target = this.formatTarget
1809    if (!this.formatRow || !target) return
1810
1811    const stored = new Set(this.parseStyles(this.panelFieldNode(STYLES_FIELD, target.index)?.value)[target.field] || [])
1812
1813    this.formatRow.querySelectorAll("[data-ch-format]").forEach((button) => {
1814      const spec = TEXT_FORMATS[button.dataset.chFormat]
1815      if (!spec) return
1816
1817      const on = stored.has(spec.mark)
1818      button.dataset.active = String(on)
1819      button.setAttribute("aria-pressed", String(on))
1820    })
1821  }
1822
1823  buildFormatButton(doc, name, spec) {
1824    const button = doc.createElement("button")
1825    button.type = "button"
1826    button.className = "page-editor__format-button"
1827    button.dataset.chFormat = name
1828
1829    const labels = {
1830      bold: "Bold", italic: "Italic", strike: "Strikethrough",
1831      small: "Smaller", large: "Larger", xlarge: "Largest", clear: "Remove formatting"
1832    }
1833    button.title = labels[name]
1834    button.setAttribute("aria-label", labels[name])
1835
1836    const glyphs = { bold: "B", italic: "I", strike: "S", small: "A", large: "A", xlarge: "A", clear: "Clear" }
1837    button.textContent = glyphs[name]
1838    if (spec?.className) button.dataset.chFormatSize = spec.className.replace("site-text-", "")
1839    if (name === "clear") button.dataset.chFormatWide = "true"
1840
1841    return button
1842  }
1843
1844  previewController() {
1845    return this.application.getControllerForElementAndIdentifier(this.element, "page-live-preview")
1846  }
1847
1848  // Panel ----------------------------------------------------------------
1849
1850  editPageSettings(event) {
1851    event?.preventDefault()
1852    if (this.modeValue !== "visual") return
1853
1854    this.formSections().forEach((section) => { section.open = false })
1855    this.selectedIndex = null
1856    this.openPanel("page")
1857    this.reposition()
1858  }
1859
1860  openPanel(mode) {
1861    if (!this.hasPanelTarget) return
1862
1863    // Only a change of subject justifies moving the editor's place in the form.
1864    // Adding or removing a repeater item inside the open section also lands here
1865    // (those controllers emit page-sections:changed too); scrolling to the top
1866    // then would throw the editor out of the row they are filling in.
1867    const subject = mode === "section" ? `section:${this.selectedIndex}` : mode
1868    const changed = this.panelSubject !== subject ||
1869      this.panelTarget.dataset.panelOpen !== "true"
1870
1871    this.panelSubject = subject
1872    this.panelTarget.dataset.panel = mode
1873    this.panelTarget.dataset.panelOpen = "true"
1874    this.updatePanelTitle(mode)
1875
1876    if (changed) this.panelTarget.scrollTop = 0
1877
1878    this.positionPanel()
1879  }
1880
1881  closePanel(event) {
1882    event?.preventDefault()
1883    if (!this.hasPanelTarget) return
1884
1885    this.panelTarget.dataset.panelOpen = "false"
1886    this.panelSubject = null
1887
1888    if (event) {
1889      // An explicit close should also drop the selection, or the outline would
1890      // keep claiming something is being edited.
1891      const closed = this.canvasSectionAt(this.selectedIndex)
1892
1893      this.formSections().forEach((section) => { section.open = false })
1894      this.selectedIndex = null
1895      this.reposition()
1896
1897      // The close button is inside the panel it hides, so without this the
1898      // browser drops focus to <body> and the next Tab restarts at the top of
1899      // the page. Hand focus back to the section that was being edited.
1900      try {
1901        closed?.focus({ preventScroll: true })
1902      } catch (_error) {
1903        // The preview may have reloaded out from under us; losing focus here is
1904        // no worse than the default.
1905      }
1906    }
1907  }
1908
1909  updatePanelTitle(mode) {
1910    if (!this.hasPanelTitleTarget) return
1911
1912    if (mode === "page") {
1913      this.panelTitleTarget.textContent = "Page settings"
1914      return
1915    }
1916
1917    const section = this.formSections()[this.selectedIndex]
1918    this.panelTitleTarget.textContent = section?.dataset?.sectionLabelValue || "Section"
1919  }
1920
1921  positionPanel() {
1922    if (this.modeValue !== "visual") return
1923    if (!this.hasPanelTarget || !this.hasIframeTarget) return
1924    if (this.panelTarget.dataset.panelOpen !== "true") return
1925
1926    // Pinned to the top of the preview that is actually on screen, rather than
1927    // following the selected section down the page. The controls for a thing are
1928    // therefore always in the same place, and which section they belong to is
1929    // said by the outline on the canvas.
1930    //
1931    // Anchored to the preview and not to the viewport, because a viewport offset
1932    // cannot know where the toolbar is: a taller browser header, a wrapped
1933    // toolbar or a validation banner all push it down, and a panel pinned above
1934    // it ends up half behind Save with its lower half off screen.
1935    //
1936    // Right rather than left because a section's toolbar and a field's toolbar
1937    // are both anchored to the left edge of what they belong to; a panel there
1938    // would cover the buttons the editor is reaching for.
1939    const frame = this.iframeTarget.getBoundingClientRect()
1940    const top = Math.max(frame.top, this.toolbarBottom(), 0) + PANEL_MARGIN
1941    const visibleBottom = Math.min(frame.bottom, window.innerHeight)
1942
1943    this.panelTarget.style.top = `${Math.round(top)}
1943px`
1944    this.panelTarget.style.maxHeight = `${Math.round(Math.max(visibleBottom - top - PANEL_MARGIN, MIN_PANEL_HEIGHT))}px`
1945  }
1946
1947  // The editor's own toolbar is sticky, so where it sits depends on how far the
1948  // page is scrolled as well as on how tall everything above it is.
1949  toolbarBottom() {
1950    const bar = this.element.querySelector("[data-page-dirty-state-target='saveBar']")
1951    return bar ? bar.getBoundingClientRect().bottom : 0
1952  }
1953
1954  focusFirstField(section) {
1955    if (!section) return
1956
1957    const field = section.querySelector(
1958      "trix-editor, input:not([type='hidden']):not([disabled]), textarea, select"
1959    )
1960    if (field) field.focus({ preventScroll: true })
1961  }
1962
1963  // Positioning ----------------------------------------------------------
1964
1965  reposition() {
1966    if (this.trackFrame) cancelAnimationFrame(this.trackFrame)
1967    this.trackFrame = requestAnimationFrame(() => {
1968      this.trackFrame = null
1969      this.drawChrome()
1970      this.redrawFieldOutline()
1971      this.positionPanel()
1972    })
1973  }
1974
1975  // A section's own box is not always the whole of what it paints: a full-bleed
1976  // band reaches past its container to the window edge, so an outline drawn on
1977  // the box alone sits inside the thing it is meant to be around.
1978  //
1979  // How far the content escapes does not change while the page scrolls, only when
1980  // it re-renders or the window resizes, so it is measured once per section and
1981  // reused. Keyed on the node, so a re-render brings new nodes and new measures.
1982  paintedRect(element) {
1983    const rect = element.getBoundingClientRect()
1984    const insets = this.paintedInsets(element, rect)
1985
1986    return {
1987      left: rect.left - insets.left,
1988      top: rect.top - insets.top,
1989      right: rect.right + insets.right,
1990      bottom: rect.bottom + insets.bottom,
1991      width: rect.width + insets.left + insets.right,
1992      height: rect.height + insets.top + insets.bottom
1993    }
1994  }
1995
1996  paintedInsets(element, rect) {
1997    this.insetCache ||= new WeakMap()
1998    const cached = this.insetCache.get(element)
1999    if (cached) return cached
2000
2001    let left = 0
2002    let right = 0
2003    let top = 0
2004    let bottom = 0
2005
2006    const view = this.frameDocument?.defaultView
2007    const extend = (box) => {
2008      if (!box.width && !box.height) return
2009
2010      if (rect.left - box.left > left) left = rect.left - box.left
2011      if (box.right - rect.right > right) right = box.right - rect.right
2012      if (rect.top - box.top > top) top = rect.top - box.top
2013      if (box.bottom - rect.bottom > bottom) bottom = box.bottom - rect.bottom
2014    }
2015
2016    // Stops at anything that clips, because what a clipping box hides is not
2017    // painted outside it and must not widen the outline. A logo marquee is the
2018    // case that makes this matter: its track is width:max-content and animates
2019    // translateX(-50%), so measuring through the strip that clips it would give
2020    // an outline several times too wide, and a different one on every frame.
2021    const walk = (node, depth) => {
2022      for (const child of node.children) {
2023        extend(child.getBoundingClientRect())
2024        if (depth >= OUTLINE_MAX_DEPTH) continue
2025
2026        const style = view?.getComputedStyle(child)
2027        if (style && (style.overflowX !== "visible" || style.overflowY !== "visible")) continue
2028
2029        walk(child, depth + 1)
2030      }
2031    }
2032    walk(element, 0)
2033
2034    const insets = {
2035      left: Math.max(0, Math.round(left)),
2036      right: Math.max(0, Math.round(right)),
2037      top: Math.max(0, Math.round(top)),
2038      bottom: Math.max(0, Math.round(bottom))
2039    }
2040    this.insetCache.set(element, insets)
2041    return insets
2042  }
2043
2044  redrawFieldOutline() {
2045    if (this.hoverField?.node?.isConnected) this.drawFieldOutline(this.hoverField)
2046    else this.hideFieldOutline()
2047  }
2048
2049  // Opening an item's own row is the useful thing a click on a card can do: it
2050  // is where its fields, and its own remove control, already live.
2051  focusItem(itemKey) {
2052    const found = this.repeaterControllerFor(itemKey)
2053    const item = found.item
2054    if (!item) return
2055
2056    // A custom repeater's items are collapsed disclosures, so the one being
2057    // pointed at has to be opened before anything in it can be reached.
2058    if (found.kind === "custom") found.controller?.setExpanded(item, true)
2059
2060    item.classList.remove("hidden")
2061    item.scrollIntoView({ block: "center", behavior: "smooth" })
2062    this.flashPanelControl(item)
2063
2064    const field = item.querySelector("input:not([type='hidden']):not([disabled]), textarea, select")
2065    field?.focus({ preventScroll: true })
2066  }
2067
2068  drawChrome() {
2069    if (!this.chrome) return
2070
2071    if (this.canvasStale) {
2072      this.chrome.hidden = true
2073      return
2074    }
2075
2076    const index = this.activeIndex()
2077    const section = this.canvasSectionAt(index)
2078
2079    if (!section) {
2080      this.chrome.hidden = true
2081      return
2082    }
2083
2084    const rect = this.paintedRect(section)
2085    const view = this.frameDocument?.defaultView
2086    if (!view) return
2087
2088    // Fully scrolled past: nothing to outline. During a drag the chrome stays
2089    // put regardless, because hiding it would drop the handle's pointer capture
2090    // and strand the drag halfway through.
2091    if (!this.drag && (rect.bottom < 0 || rect.top > view.innerHeight)) {
2092      this.chrome.hidden = true
2093      return
2094    }
2095
2096    this.chrome.hidden = false
2097    this.chrome.dataset.selected = String(index === this.selectedIndex)
2098    this.chrome.dataset.barBelow = String(rect.top < 32)
2099    this.chrome.style.left = `${Math.round(rect.left)}px`
2100    this.chrome.style.top = `${Math.round(rect.top)}px`
2101    this.chrome.style.width = `${Math.round(rect.width)}px`
2102    this.chrome.style.height = `${Math.round(rect.height)}px`
2103
2104    if (this.chromeLabel) {
2105      this.chromeLabel.textContent = this.sectionLabel(index)
2106    }
2107  }
2108
2109  scrollCanvasTo(index) {
2110    const section = this.canvasSectionAt(index)
2111    if (!section) return
2112
2113    const view = this.frameDocument?.defaultView
2114    if (!view) return
2115
2116    const rect = section.getBoundingClientRect()
2117    if (rect.top >= 0 && rect.bottom <= view.innerHeight) return
2118
2119    section.scrollIntoView({ block: "start", behavior: "smooth" })
2120  }
2121
2122  // Lookups --------------------------------------------------------------
2123
2124  frameDoc() {
2125    try {
2126      return this.hasIframeTarget ? this.iframeTarget.contentDocument : null
2127    } catch (_error) {
2128      return null
2129    }
2130  }
2131
2132  canvasSections(doc = this.frameDocument) {
2133    if (!doc) return []
2134    return Array.from(doc.querySelectorAll(SECTION_SELECTOR))
2135  }
2136
2137  canvasSectionAt(index) {
2138    if (index === null || index === undefined) return null
2139    return this.canvasSections()[index] || null
2140  }
2141
2142  canvasSectionFor(node) {
2143    return node?.closest?.(SECTION_SELECTOR) || null
2144  }
2145
2146  canvasIndexOf(section) {
2147    const index = Number(section?.dataset?.siteSectionIndex)
2148    return Number.isInteger(index) ? index : null
2149  }
2150
2151  activeIndex() {
2152    return this.hoverIndex !== null && this.hoverIndex !== undefined
2153      ? this.hoverIndex
2154      : this.selectedIndex
2155  }
2156
2157  formSections() {
2158    if (!this.hasSectionListTarget) return []
2159    return Array.from(this.sectionListTarget.querySelectorAll("[data-section]"))
2160  }
2161
2162  sectionLabel(index) {
2163    const section = this.formSections()[index]
2164    return section?.dataset?.sectionLabelValue || "Section"
2165  }
2166
2167  sectionsRoot() {
2168    return this.element.querySelector("[data-controller~='page-sections']")
2169  }
2170}

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.