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.