1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" />
4<script data-name="termly-code"> 5(function () { 6 var button = null; 7 8 function createPreferencesButton() { 9 var btn = document.createElement("button"); 10 btn.type = "button"; 11 btn.id = "consent-banner-prefs-button"; 12 btn.className = "termly-display-preferences"; 13 btn.setAttribute("aria-label", "Cookie preferences"); 14 btn.title = "Cookie preferences"; 15 if (document.body) { 16 document.body.appendChild(btn); 17 } else { 18 document.addEventListener("DOMContentLoaded", function () { 19 document.body.appendChild(btn); 20 }); 21 } 22 return btn; 23 } 24 25 function showPreferencesButton() { 26 if (!button) { 27 button = createPreferencesButton(); 28 } 29 if (button.style.display !== "block") { 30 button.style.display = "block"; 31 } 32 } 33 34 window.onTermlyLoaded = function () { 35 window.Termly.on("consent", function (data) { 36 if (data && data.uuid) { 37 showPreferencesButton(); 38 } 39 }); 40 }; 41}()); 42</script>
42<script src="https://app.termly.io/resource-blocker/84a3dc1d-21b9-4a65-9a8a-064af3ef5844?autoBlock=on" data-name="termly-code" onload="window.onTermlyLoaded && window.onTermlyLoaded();"></script>
42 43 <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 44 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 45 <link rel="preconnect" href="https://fonts.googleapis.com" /> 46 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 47 <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&display=swap" rel="stylesheet" /> 48 <title>SurfAce Country Club</title> 49
49<script type="module" crossorigin src="/assets/index-CAGVz00w.js"></script>
49 50 <link rel="stylesheet" crossorigin href="/assets/index-Bs03FsZT.css"> 51 <style data-name="termly-code"> 52button#consent-banner-prefs-button { 53 display: none; 54 background-color: white; 55 background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 40 44' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='20' cy='22' r='11' stroke='%233C3C3C' stroke-width='2'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M27.5042 16.7507C27.5042 17.7164 26.7197 18.5 25.7514 18.5C24.7845 18.5 24 17.7164 24 16.7507C24 15.7836 24.7845 15 25.7514 15C26.7197 15 27.5042 15.7836 27.5042 16.7507Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M28.503 23.25C28.503 23.9405 27.9428 24.5 27.2515 24.5C26.5602 24.5 26 23.9405 26 23.25C26 22.5611 26.5602 22 27.2515 22C27.9428 22 28.503 22.5611 28.503 23.25Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M22 21.5C22 22.3286 21.3286 23 20.5 23C19.6714 23 19 22.3286 19 21.5C19 20.6733 19.6714 20 20.5 20C21.3286 20 22 20.6733 22 21.5Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M26 28C26 28.5524 25.5524 29 25 29C24.4476 29 24 28.5524 24 28C24 27.4489 24.4476 27 25 27C25.5524 27 26 27.4489 26 28Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M20 14C20 14.5524 19.5524 15 19 15C18.4476 15 18 14.5524 18 14C18 13.4489 18.4476 13 19 13C19.5524 13 20 13.4489 20 14Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M21 28.5C21 29.8809 19.8809 31 18.5 31C17.1191 31 16 29.8809 16 28.5C16 27.1222 17.1191 26 18.5 26C19.8809 26 21 27.1222 21 28.5Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 21C16 22.1059 15.1041 23 14 23C12.8941 23 12 22.1059 12 21C12 19.8959 12.8941 19 14 19C15.1041 19 16 19.8959 16 21Z' fill='%233C3C3C'/%3E%3Cpath d='M21 1L23.6464 3.64645C23.8417 3.84171 23.8417 4.1583 23.6464 4.35356L21 7' stroke='%233C3C3C' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M1 22.75C1 12.3947 9.39466 4 19.75 4C20.9718 4 22.1664 4.11687 23.3231 4.34005' stroke='%233C3C3C' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M19 42.75L16.3536 40.1035C16.1583 39.9083 16.1583 39.5917 16.3536 39.3964L19 36.75' stroke='%233C3C3C' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M39 21C39 31.3553 30.6053 39.75 20.25 39.75C19.0282 39.75 17.8336 39.6331 16.6769 39.41' stroke='%233C3C3C' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); 56 background-size: 44px; 57 background-repeat: no-repeat; 58 background-position: center; 59 border: none; 60 box-sizing: border-box; 61 cursor: pointer; 62 padding: 9px 9px 7px 7px; 63 outline: none; 64 width: 64px; 65 height: 64px; 66 border-top-right-radius: 50%; 67 box-shadow: 0px 0px 6.86px 0px #0000001A; 68 position: fixed; 69 bottom: 0; 70 left: 0; 71 z-index: 9999999; 72 transform: scale(0.7); 73 transform-origin: bottom left; 74 transition: all .3s ease; 75} 76button#consent-banner-prefs-button:hover { 77 transform: scale(1); 78} 79button#consent-banner-prefs-button.termly-display-preferences--disabled { 80 opacity: 0.4; 81 cursor: not-allowed; 82} 83</style></head> 84 <body> 85 <div id="root"></div> 86
86<script>(function () { 87 if (window.parent === window) return; 88 89 // Broadcasts the iframe's path to the parent so the editor's top-bar reflects 90 // in-iframe navigation. Assumes BrowserRouter â adding `hashchange` would 91 // only fire dedupe-skipped events for in-page anchors. 92 (function () { 93 var lastBroadcastPath = null; 94 function currentPath() { 95 return window.location.pathname || '/'; 96 } 97 function scrollToTopIfPathChanged(previousPath) { 98 if (currentPath() === previousPath) return; 99 try { 100 window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); 101 } catch (_err) { 102 try { 103 window.scrollTo(0, 0); 104 } catch (_fallbackErr) { 105 // Ignore scroll failures so customer routing cannot break. 106 } 107 } 108 } 109 function broadcastPath() { 110 var path = currentPath(); 111 if (path === lastBroadcastPath) return; 112 // Guard: this runs from inside patched pushState/replaceState â a throw 113 // here would break the customer's SPA router. 114 try { 115 window.parent.postMessage( 116 { type: 'wsb-page-navigate', path: path }, 117 '*', 118 ); 119 lastBroadcastPath = path; 120 } catch (_err) { 121 // Leave lastBroadcastPath untouched so the next nav retries. 122 } 123 } 124 // Patch history methods so SPA push/replaceState are observable. 125 var originalPush = window.history.pushState; 126 var originalReplace = window.history.replaceState; 127 window.history.pushState = function () { 128 var previousPath = currentPath(); 129 var result = originalPush.apply(this, arguments); 130 scrollToTopIfPathChanged(previousPath); 131 broadcastPath(); 132 return result; 133 }; 134 window.history.replaceState = function () { 135 var previousPath = currentPath(); 136 var result = originalReplace.apply(this, arguments); 137 scrollToTopIfPathChanged(previousPath); 138 broadcastPath(); 139 return result; 140 }; 141 window.addEventListener('popstate', function () { 142 scrollToTopIfPathChanged(lastBroadcastPath); 143 broadcastPath(); 144 }); 145 // Initial broadcast â deferred so the parent's listener is attached. 146 setTimeout(broadcastPath, 0); 147 })(); 148 149 var EDIT_EVENTS = { 150 MOVE_COMPONENT: 'MOVE_COMPONENT', 151 DELETE_COMPONENT: 'DELETE_COMPONENT', 152 STYLE_OBJECT_OVERRIDES: 'STYLE_OBJECT_OVERRIDES', 153 SET_IMAGE_UPLOAD_TARGET: 'SET_IMAGE_UPLOAD_TARGET', 154 UPDATE_IMAGE_UPLOAD_TARGET: 'UPDATE_IMAGE_UPLOAD_TARGET', 155 CLEAR_IMAGE_UPLOAD_TARGET: 'CLEAR_IMAGE_UPLOAD_TARGET', 156 APPLY_TEXT_CONTENT: 'APPLY_TEXT_CONTENT', 157 PROP_OBJECT_OVERRIDES: 'PROP_OBJECT_OVERRIDES', 158 159 // Routing event back to the UI 160 TEXT_CONTENT_CHANGED: 'TEXT_CONTENT_CHANGED', 161 }; 162 var overlay = document.createElement('div'); 163 overlay.id = 'wsb-hover-overlay'; 164 Object.assign(overlay.style, { 165 position: 'fixed', 166 pointerEvents: 'none', 167 zIndex: '2147483647', 168 border: '1px solid #0066AA', 169 transition: 'all 0.1s ease-out', 170 display: 'none', 171 }); 172 document.body.appendChild(overlay); 173 174 var selectedOverlay = document.createElement('div'); 175 selectedOverlay.id = 'wsb-selected-overlay'; 176 Object.assign(selectedOverlay.style, { 177 position: 'fixed', 178 pointerEvents: 'none', 179 zIndex: '2147483646', 180 border: '2px solid #0066AA', 181 backgroundColor: '#0066AA1A', 182 display: 'none', 183 }); 184 document.body.appendChild(selectedOverlay); 185 186 var state = { 187 enabled: false, 188 hoveredEl: null, 189 selectedEl: null, 190 uploadTarget: null, 191 quotaExceeded: false, 192 isPostCard: false, 193 isServiceCard: false, 194 // True when the current selection is a duplicated-id ("dynamic-static") 195 // element â items rendered from a .map() over a hardcoded array (e.g. FAQ 196 // entries). Selection is allowed for visual feedback, but manual text / 197 // style edits are blocked; the parent must hide manual controls. 198 isManualEditDisabled: false, 199 editingEl: null, 200 editingOriginalContent: null, 201 pickerAfterEl: null, 202 pickerBeforeEl: null, 203 pickerOpen: false, 204 blogPostMode: false, 205 }; 206 var savedRange = null; 207 var savedEditingEl = null; 208 var storedServices = []; 209 210 var ui = { 211 overlay: overlay, 212 selectedOverlay: selectedOverlay, 213 overlayNodes: [overlay], 214 selectedOverlayNodes: [selectedOverlay], 215 }; 216 217 function positionOverlay(el, ov) { 218 var r = el.getBoundingClientRect();
219 Object.assign(ov.style, { 220 top: r.top + 'px', 221 left: r.left + 'px', 222 width: r.width + 'px', 223 height: r.height + 'px', 224 display: 'block', 225 }); 226 return r; 227 } 228 229 function hideOverlayGroup(overlays) { 230 for (var i = 0; i < overlays.length; i++) { 231 overlays[i].style.display = 'none'; 232 } 233 } 234 235 function ensureOverlayNode(overlays, template, index) { 236 if (overlays[index]) return overlays[index]; 237 var clone = template.cloneNode(false); 238 if (clone.id) clone.id = clone.id + '-clone-' + index; 239 clone.style.display = 'none'; 240 document.body.appendChild(clone); 241 overlays[index] = clone; 242 return clone; 243 } 244 245 function positionOverlayGroupById(wsbId, overlays, template, primaryEl) { 246 if (!wsbId) { 247 hideOverlayGroup(overlays); 248 return null; 249 } 250 var matches = findElementsByWsbId(wsbId); 251 if (!matches.length) { 252 hideOverlayGroup(overlays); 253 return null; 254 } 255 var firstRect = null; 256 var primaryRect = null; 257 for (var i = 0; i < matches.length; i++) { 258 var ov = ensureOverlayNode(overlays, template, i); 259 var r = positionOverlay(matches[i], ov); 260 if (!firstRect) firstRect = r; 261 if (primaryEl && matches[i] === primaryEl) primaryRect = r; 262 } 263 for (var j = matches.length; j < overlays.length; j++) { 264 overlays[j].style.display = 'none'; 265 } 266 return primaryRect || firstRect; 267 } 268 269 function positionSingleOverlay(el, overlays, template) { 270 if (!el || !el.isConnected) { 271 hideOverlayGroup(overlays); 272 return null; 273 } 274 var ov = ensureOverlayNode(overlays, template, 0); 275 var r = positionOverlay(el, ov); 276 for (var i = 1; i < overlays.length; i++) { 277 overlays[i].style.display = 'none'; 278 } 279 return r || el.getBoundingClientRect(); 280 } 281 282 function positionOverlayGroupForElement(el, overlays, template) { 283 if (!el || !el.isConnected) { 284 hideOverlayGroup(overlays); 285 return null; 286 } 287 var callId = el.getAttribute('data-wsb-call-id'); 288 if (callId) { 289 return positionSingleOverlay(el, overlays, template); 290 } 291 var wsbId = el.getAttribute('data-wsb-id'); 292 if (!wsbId) { 293 return positionSingleOverlay(el, overlays, template); 294 } 295 return ( 296 positionOverlayGroupById(wsbId, overlays, template, el) || 297 el.getBoundingClientRect() 298 ); 299 } 300 301 function wsbLine(id) { 302 if (!id) return -1; 303 var parts = id.split(':'); 304 return parseInt(parts[parts.length - 2], 10); 305 } 306 function wsbFile(id) { 307 if (!id) return ''; 308 var parts = id.split(':'); 309 parts.pop(); 310 parts.pop(); 311 return parts.join(':'); 312 } 313 function findElementsByWsbId(id) { 314 if (!id) return []; 315 var allEls = document.querySelectorAll('[data-wsb-id]'); 316 var results = []; 317 for (var i = 0; i < allEls.length; i++) { 318 if (allEls[i].getAttribute('data-wsb-id') === id) results.push(allEls[i]); 319 } 320 return results; 321 } 322 323 function getNearestAttributeValue(startNode, attributeName, disallowedValues = []) { 324 const disallowed = new Set(disallowedValues); 325 326 let cursor = 327 startNode && startNode.nodeType === Node.ELEMENT_NODE 328 ? startNode 329 : startNode?.parentElement || null; 330 331 while (cursor) { 332 const value = cursor.getAttribute(attributeName); 333 334 if (value && !disallowed.has(value)) { 335 return value; 336 } 337 338 cursor = cursor.parentElement; 339 } 340 341 return null; 342 } 343 344 function extractUrlFromCssImage(value) { 345 if (!value || value === 'none') return ''; 346 var match = String(value).match(/^url\((['"]?)(.*?)\1\)$/); 347 return match ? match[2] : value; 348 } 349 350 function findElementByCallId(id) { 351 var elements = document.querySelectorAll('[data-wsb-call-id]'); 352 var target = null; 353 elements.forEach((e) => { 354 if (e.getAttribute('data-wsb-call-id') === id) target = e; 355 }); 356 return target; 357 } 358 function findElementsByAttribute(attr, id) { 359 if (!attr || !id) return []; 360 var allEls = document.querySelectorAll('[' + attr + ']'); 361 var results = []; 362 for (var i = 0; i < allEls.length; i++) { 363 if (allEls[i].getAttribute(attr) === id) results.push(allEls[i]); 364 } 365 return results; 366 } 367 368 // Find the source-code id used by existing editor messages and edit handlers. 369 function findElementId(el) { 370 if (!el || el === document || el.nodeType !== 1) return null; 371 return el.getAttribute("data-wsb-id") || el.getAttribute("data-wsb-call-id"); 372 } 373 374 function findClosestUniquelyIdentifiableElement(startElement) { 375 // Identity priority is local to this helper because "selectability" is a 376 // DOM concern, not a source-edit concern. Product cards are repeated from 377 // the same JSX line, so their data-wsb-id is often duplicated; the rendered 378 // product id is the identity that makes the card selectable as a product. 379 // Component call ids and DOM wsb ids remain the source-code identities used 380 // for normal element editing. 381 var uniqueIdentifiers = [ 382 'data-wc-product-id', 383 'data-ce-product-id', 384 'data-wsb-call-id', 385 'data-wsb-id', 386 ]; 387 388 var parent = startElement; 389 390 // Keep the walk shallow so a click inside an unidentifiable subtree does 391 // not accidentally select a distant page wrapper. Five levels covers the 392 // common card/text/icon nesting in generated pages. 393 var searchDepth = 5; 394 395 while (parent && searchDepth > 0) { 396 if (parent === document) return null; 397 if (parent.nodeType !== 1) { 398 searchDepth = searchDepth - 1; 399 parent = parent.parentNode; 400 continue; 401 } 402 403 var ids = {}; 404 var uniqueBy = null; 405 406 for (var i = 0; i < uniqueIdentifiers.length; i++) { 407 var attr = uniqueIdentifiers[i]; 408 var value = parent.getAttribute(attr); 409 if (!value) continue; 410 411 ids[attr] = value; 412 if (!uniqueBy && findElementsByAttribute(attr, value).length === 1) { 413 uniqueBy = { attr: attr, value: value }; 414 } 415 } 416 417 if (uniqueBy) { 418 return { element: parent, ids: ids, uniqueBy: uniqueBy }; 419 } 420 searchDepth = searchDepth - 1; 421 parent = parent.parentNode; 422 } 423 return null; 424 } 425 426 // Avoids a full document scan for ids that may appear multiple times â 427 // narrows to siblings first, then descendants, then falls back to the 428 // structurally nearest match across the whole document. 429 function findClosestElementByWsbId(id, anchorEl) { 430 if (!id || !anchorEl) return null; 431 432 var parent = anchorEl.parentNode; 433 if (parent) { 434 var children = parent.children; 435 for (var i = 0; i < children.length; i++) { 436 if (children[i].getAttribute('data-wsb-id') === id) return children[i]; 437 } 438 } 439 440 var descendant = anchorEl.querySelector("[data-wsb-id='" + id + "']"); 441 if (descendant) return descendant; 442 443 // Multiple matches exist â pick the one sharing the deepest branch with anchorEl 444 var matches = findElementsByWsbId(id); 445 if (!matches.length) return null; 446 if (matches.length === 1) return matches[0]; 447 448 var best = null, 449 bestDepth = -1; 450 for (var m = 0; m < matches.length; m++) { 451 var node = matches[m]; 452 var depth = 0; 453 var cursor = node; 454 while (cursor) { 455 if (cursor === anchorEl || cursor.contains(anchorEl)) { 456 depth += 1000; 457 break; 458 }
459 cursor = cursor.parentNode; 460 depth++; 461 } 462 if (depth > bestDepth) { 463 bestDepth = depth; 464 best = node; 465 } 466 } 467 return best; 468 } 469 470 function createEditEventHandlers(ctx) { 471 function getSelectedElements() { 472 if (!ctx.state.selectedEl) return []; 473 var selectedId = ctx.state.selectedEl.getAttribute('data-wsb-id'); 474 if (!selectedId) return [ctx.state.selectedEl]; 475 var matches = ctx.findElementsByWsbId(selectedId); 476 return matches.length ? matches : [ctx.state.selectedEl]; 477 } 478 479 return { 480 // Swaps the DOM positions of two same-parent components by wsb-id. 481 // Intentionally independent of the current selection so the editor 482 // can replay it for undo/redo/discard after the toolbar has closed. 483 // A swap is its own inverse, which is what makes that replay possible. 484 MOVE_COMPONENT: function ({ idA, idB }) { 485 var matchesA = ctx.findElementsByWsbId(idA); 486 var matchesB = ctx.findElementsByWsbId(idB); 487 if (matchesA.length !== 1 || matchesB.length !== 1) return; 488 var elA = matchesA[0]; 489 var elB = matchesB[0]; 490 if (!elA.parentNode || elA.parentNode !== elB.parentNode) return; 491 492 var marker = document.createComment('wsb-swap'); 493 elA.parentNode.insertBefore(marker, elA); 494 elB.parentNode.insertBefore(elA, elB); 495 marker.parentNode.insertBefore(elB, marker); 496 marker.parentNode.removeChild(marker); 497 498 var selected = ctx.state.selectedEl; 499 if (selected === elA || selected === elB) { 500 ctx.positionOverlayGroupForElement( 501 selected, 502 ctx.ui.selectedOverlayNodes, 503 ctx.ui.selectedOverlay, 504 ); 505 } 506 }, 507 DELETE_COMPONENT: function () { 508 var target = ctx.state.selectedEl; 509 if (!target || !target.parentNode) return; 510 511 if ( 512 ctx.state.editingEl && 513 (ctx.state.editingEl === target || 514 target.contains(ctx.state.editingEl)) 515 ) { 516 ctx.state.editingEl = null; 517 } 518 if ( 519 ctx.state.selectedEl && 520 (ctx.state.selectedEl === target || 521 target.contains(ctx.state.selectedEl)) 522 ) { 523 ctx.state.selectedEl = null; 524 ctx.state.isManualEditDisabled = false; 525 ctx.hideOverlayGroup(ctx.ui.selectedOverlayNodes); 526 } 527 if ( 528 ctx.state.hoveredEl && 529 (ctx.state.hoveredEl === target || 530 target.contains(ctx.state.hoveredEl)) 531 ) { 532 ctx.state.hoveredEl = null; 533 ctx.hideOverlayGroup(ctx.ui.overlayNodes); 534 } 535 536 target.parentNode.removeChild(target); 537 }, 538 STYLE_OBJECT_OVERRIDES: function ({ selectedId, loopId, update }) { 539 var targets = selectedId 540 ? ctx.findElementsByWsbId(selectedId) 541 : getSelectedElements(); 542 if (!targets.length || !update || typeof update !== 'object') return; 543 if (loopId) { 544 targets = targets.filter( 545 (target) => 546 getLoopId(target) === loopId, 547 ); 548 } 549 if (!targets.length) return; 550 551 // Text styling props â when set at block level, strip from child spans so parent wins 552 var TEXT_STYLE_PROPS = { 553 textDecoration: 1, 554 fontWeight: 1, 555 fontStyle: 1, 556 color: 1, 557 fontSize: 1, 558 textTransform: 1, 559 }; 560 561 for (var ti = 0; ti < targets.length; ti++) { 562 var target = targets[ti]; 563 var keys = Object.keys(update); 564 for (var i = 0; i < keys.length; i++) { 565 var styleProp = keys[i]; 566 var value = update[styleProp]; 567 if (!styleProp) continue; 568 569 if ( 570 styleProp.indexOf('--') === 0 || 571 styleProp.indexOf('-') !== -1 572 ) { 573 if (value == null || value === '') { 574 target.style.removeProperty(styleProp); 575 } else { 576 target.style.setProperty(styleProp, value); 577 } 578 } else if (value == null || value === '') { 579 target.style[styleProp] = ''; 580 } else { 581 target.style[styleProp] = value; 582 } 583 584 // For text style props, strip matching inline style from child spans so block style wins 585 if (TEXT_STYLE_PROPS[styleProp]) { 586 var childSpans = target.querySelectorAll('span'); 587 for (var si = 0; si < childSpans.length; si++) { 588 childSpans[si].style[styleProp] = ''; 589 } 590 } 591 } 592 } 593 ctx.updatePositions(); 594 }, 595 596 PROP_OBJECT_OVERRIDES: function ({ selectedId, loopId, update }) { 597 var targets = selectedId 598 ? ctx.findElementsByWsbId(selectedId) 599 : getSelectedElements(); 600 601 if (!targets.length || !update || typeof update !== 'object') return; 602 603 const target = loopId ? targets.find((t) => getLoopId(t) === loopId) : targets[0]; 604 605 if (!target) return; 606 607 var keys = Object.keys(update); 608 for (var i = 0; i < keys.length; i++) { 609 var prop = keys[i]; 610 var value = update[prop]; 611 612 if (!prop || prop === "children") continue; 613 614 target.setAttribute(prop, value); 615 } 616 617 ctx.updatePositions(); 618 }, 619 620 SET_IMAGE_UPLOAD_TARGET: function ({ 621 selectedId, 622 hasBgImage, 623 loopId, 624 imgChildId, 625 }) { 626 let target = null; 627 var lookupId = hasBgImage ? selectedId : imgChildId || selectedId; 628 var targets = lookupId 629 ? ctx.findElementsByWsbId(lookupId) 630 : getSelectedElements(); 631 632 if (!targets.length) return; 633 634 if (loopId) { 635 target = targets.find((t) => getNearestAttributeValue(t, "data-wsb-loop-id", ["-1"]) === loopId); 636 } else { 637 target = targets[0]; 638 } 639 640 state.uploadTarget = { element: target, hasBgImage }; 641 ctx.updatePositions(); 642 },
643 UPDATE_IMAGE_UPLOAD_TARGET: function ({ url }) { 644 if (!state.uploadTarget) return; 645 const { element, hasBgImage } = state.uploadTarget; 646 647 if (hasBgImage) { 648 element.style.backgroundImage = 'url(' + url + ')'; 649 } else { 650 element.setAttribute('src', url); 651 element.src = url; 652 } 653 ctx.updatePositions(); 654 }, 655 CLEAR_IMAGE_UPLOAD_TARGET: function () { 656 state.uploadTarget = null; 657 ctx.updatePositions(); 658 }, 659 660 APPLY_TEXT_CONTENT: function ({ wsbId, callId, loopId, content }) { 661 if (typeof content !== "string") return; 662 663 var targets = []; 664 665 if (callId) { 666 var element = ctx.findElementByCallId(callId); 667 if (element) targets.push(element); 668 } else if (wsbId) { 669 targets = ctx.findElementsByWsbId(wsbId); 670 } 671 672 if (loopId) { 673 targets = targets.filter(function (target) { 674 return ( 675 getNearestAttributeValue( 676 target, 677 "data-wsb-loop-id", 678 ["-1"] 679 ) === loopId 680 ); 681 }); 682 } 683 684 var contentProperty = hasSupportedInlineMarkup(content) 685 ? "innerHTML" 686 : "textContent"; 687 688 for (var i = 0; i < targets.length; i++) { 689 targets[i][contentProperty] = content; 690 } 691 692 ctx.updatePositions(); 693 }, 694 695 TEXT_CONTENT_CHANGED: function ({ 696 selectedId, 697 content, 698 previousContent, 699 }) { 700 if (!selectedId || typeof content !== 'string') return; 701 window.parent.postMessage( 702 { 703 type: EDIT_EVENTS.TEXT_CONTENT_CHANGED, 704 data: { 705 selectedId: selectedId, 706 content: content, 707 previousContent: 708 typeof previousContent === 'string' ? previousContent : null, 709 }, 710 }, 711 '*', 712 ); 713 }, 714 }; 715 } 716 717 var editEventHandlers = createEditEventHandlers({ 718 state: state, 719 ui: ui, 720 findElementsByWsbId: findElementsByWsbId, 721 findElementByCallId: findElementByCallId, 722 findClosestElementByWsbId: findClosestElementByWsbId, 723 positionOverlayGroupById: positionOverlayGroupById, 724 positionOverlayGroupForElement: positionOverlayGroupForElement, 725 hideOverlayGroup: hideOverlayGroup, 726 updatePositions: updatePositions, 727 }); 728 729 function getTagName(el) { 730 if (!el) return ''; 731 if (typeof el.localName === 'string' && el.localName) 732 return el.localName.toLowerCase(); 733 if (typeof el.tagName === 'string' && el.tagName) 734 return el.tagName.toLowerCase(); 735 return ''; 736 } 737 738 var EDITOR_ATTR_PREFIX = 'data-wsb-'; 739 740 // Attribute-tolerant so the stamped `<br data-wsb-id="â¦">` still counts as 741 // markup. Mirrors INLINE_MARKUP_RE on the server (project.service.ts). 742 var SUPPORTED_INLINE_MARKUP_RE = 743 /<(?:span|strong|em|b|i|u|s|mark|code|a|br)(?:\s[^>]*)?\/?>/i; 744 function hasSupportedInlineMarkup(content) { 745 return SUPPORTED_INLINE_MARKUP_RE.test(content); 746 } 747 748 // Serializes an editable element's content for persistence: strips the 749 // editor's `data-wsb-*`/contentEditable scaffolding and unwraps any tag the 750 // editor can't round-trip (e.g. a browser-injected <div>) to its text, so 751 // saved content only ever holds supported inline markup. 752 function serializeInlineContent(el) { 753 var clone = el.cloneNode(true); 754 var nodes = clone.querySelectorAll('*'); 755 for (var i = 0; i < nodes.length; i++) { 756 var node = nodes[i]; 757 var attrNames = node.getAttributeNames ? node.getAttributeNames() : []; 758 for (var a = 0; a < attrNames.length; a++) { 759 if (attrNames[a].indexOf(EDITOR_ATTR_PREFIX) === 0) { 760 node.removeAttribute(attrNames[a]); 761 } 762 } 763 node.removeAttribute('contenteditable'); 764 if (getTagName(node) !== 'br' && !INLINE_TAGS[getTagName(node)]) { 765 var parent = node.parentNode; 766 if (parent) { 767 while (node.firstChild) parent.insertBefore(node.firstChild, node); 768 parent.removeChild(node); 769 } 770 } 771 } 772 return hasSupportedInlineMarkup(clone.innerHTML) 773 ? clone.innerHTML 774 : clone.textContent || ''; 775 } 776 777 var SECTION_TAGS = { 778 section: 1, 779 header: 1, 780 footer: 1, 781 main: 1, 782 article: 1, 783 aside: 1, 784 nav: 1, 785 }; 786 var MIN_SECTION_HEIGHT = 100; 787 function isSectionLike(el) { 788 var tag = getTagName(el); 789 if (SECTION_TAGS[tag]) return true; 790 if (tag === 'div' && el.offsetHeight >= MIN_SECTION_HEIGHT) return true; 791 return false;
792 } 793 794 const getSectionId = (el) => el?.getAttribute?.('data-wsb-id') || null; 795 796 const getSectionFile = (el) => { 797 const id = getSectionId(el); 798 return id ? wsbFile(id) : null; 799 }; 800 801 const MIN_NESTED_SECTION_HEIGHT = 48; 802 803 // Out-of-flow elements (hero background layers, overlays) paint on top of 804 // their siblings instead of stacking below them, so reordering them can 805 // never produce a visible change â they must not count as sections. 806 const isOutOfFlow = (el) => { 807 const position = window.getComputedStyle(el).position; 808 return position === 'absolute' || position === 'fixed'; 809 }; 810 811 const isSectionShapedSibling = (el) => { 812 if (SECTION_TAGS[getTagName(el)]) return true; 813 return ( 814 getTagName(el) === 'div' && 815 !isOutOfFlow(el) && 816 el.offsetHeight >= MIN_NESTED_SECTION_HEIGHT 817 ); 818 }; 819 820 const parentHasSemanticSectionChildren = (el) => { 821 const ourFile = getSectionFile(el); 822 if (!el?.parentElement || !ourFile) return false; 823 for (const child of el.parentElement.children) { 824 if (SECTION_TAGS[getTagName(child)] && getSectionFile(child) === ourFile) { 825 return true; 826 } 827 } 828 return false; 829 }; 830 831 const hasGroupedSectionSiblings = (el) => { 832 if ( 833 !el?.parentElement || 834 getTagName(el) !== 'div' || 835 isOutOfFlow(el) || 836 el.offsetHeight < MIN_NESTED_SECTION_HEIGHT || 837 parentHasSemanticSectionChildren(el) 838 ) { 839 return false; 840 } 841 const ourFile = getSectionFile(el); 842 if (!ourFile) return false; 843 for (const sib of el.parentElement.children) { 844 if (sib === el || getSectionFile(sib) !== ourFile) continue; 845 if (isSectionShapedSibling(sib)) return true; 846 } 847 return false; 848 }; 849 850 // A bare div only counts as a section when it stacks among other 851 // section-shaped siblings from the same file. A lone tall div is layout 852 // (a hero's content wrapper), and treating it as a section hijacks 853 // section actions from the real <section> above it: move up/down would 854 // swap the hero's internal layers â a visual no-op (WSB2-387). 855 const isSectionCandidate = (el) => 856 SECTION_TAGS[getTagName(el)] ? true : hasGroupedSectionSiblings(el); 857 858 const isPageSectionContainer = (el) => { 859 if (getTagName(el) !== 'main') return false; 860 const descendants = el.querySelectorAll('[data-wsb-id]'); 861 for (const descendant of descendants) { 862 if ( 863 descendant !== el && 864 !isFixedSectionTag(descendant) && 865 isSectionCandidate(descendant) 866 ) { 867 return true; 868 } 869 } 870 return false; 871 }; 872 873 const FIXED_SECTION_TAGS = new Set(['header', 'nav', 'footer']); 874 const isFixedSectionTag = (el) => 875 !!el && FIXED_SECTION_TAGS.has(getTagName(el)); 876 const isFixedSectionInList = (list, index) => { 877 if (index < 0 || index >= list.length) return true; 878 if (isFixedSectionTag(list[index])) return true; 879 return false; 880 }; 881 882 // True when `container` is itself a wrapper sitting among section-candidate 883 // siblings of the same file â i.e. it is one of several route-level blocks, 884 // not the route content root. Sections nested inside such a wrapper are 885 // sub-route content and must not move independently. The route content root 886 // (e.g. the sole stamped child of <main>) has no candidate siblings, so its 887 // sections stay movable. 888 const containerHasSectionSiblings = (container) => { 889 const parent = container && container.parentElement; 890 if (!parent) return false; 891 const ourFile = getSectionFile(container); 892 if (!ourFile) return false; 893 for (const sib of parent.children) { 894 if (sib === container) continue; 895 if (isSectionCandidate(sib) && getSectionFile(sib) === ourFile) 896 return true; 897 } 898 return false; 899 }; 900 901 // Reordering is limited to page-level sections. Nested/grouped candidates 902 // can still receive clicks, but movement should resolve to their owning 903 // top-level section so cards/columns cannot move independently. 904 const resolveSectionContext = (el) => { 905 const contexts = []; 906 for (let cur = el; cur; cur = cur.parentElement) { 907 if (!cur.parentElement || !isSectionCandidate(cur)) continue; 908 const ourFile = getSectionFile(cur); 909 if (!ourFile) continue; 910 const siblings = []; 911 let idx = -1; 912 for (const sib of cur.parentElement.children) { 913 if (!isSectionCandidate(sib) || getSectionFile(sib) !== ourFile) 914 continue; 915 if (sib === cur) idx = siblings.length; 916 siblings.push(sib); 917 } 918 if (idx === -1) continue;
919 contexts.push({ section: cur, list: siblings, index: idx }); 920 } 921 if (!contexts.length) return null; 922 const selectedContext = contexts[0]; 923 const owningSectionContext = contexts 924 .slice(1) 925 .find((context) => !isPageSectionContainer(context.section)); 926 // A nested wrapper owner (a plain <div> grouping the selection) does not 927 // produce its own context, so detect it structurally: the selection's 928 // container sits among route-level section siblings. 929 const nestedInsideRouteWrapper = 930 !isPageSectionContainer(selectedContext.section) && 931 containerHasSectionSiblings(selectedContext.section.parentElement); 932 if (owningSectionContext || nestedInsideRouteWrapper) { 933 return { 934 section: selectedContext.section, 935 list: [selectedContext.section], 936 index: 0, 937 }; 938 } 939 for (let i = contexts.length - 1; i >= 0; i -= 1) { 940 if (isPageSectionContainer(contexts[i].section)) continue; 941 if (contexts[i].list.length > 1) return contexts[i]; 942 } 943 return contexts[0]; 944 }; 945 946 function findSections() { 947 var allEls = document.querySelectorAll('[data-wsb-id]'); 948 if (!allEls.length) return { root: null, sections: [] }; 949 var first = allEls[0]; 950 var firstId = first.getAttribute('data-wsb-id'); 951 var firstFile = wsbFile(firstId); 952 var root = null, 953 topEl = first, 954 p = first.parentElement; 955 while (p) { 956 var pid = p.getAttribute('data-wsb-id'); 957 if (pid && wsbFile(pid) === firstFile) { 958 root = p; 959 } 960 p = p.parentElement; 961 } 962 if (!root) { 963 root = first; 964 } 965 var rootLine = wsbLine(root.getAttribute('data-wsb-id')); 966 var rootFile = wsbFile(root.getAttribute('data-wsb-id')); 967 var sections = []; 968 var ch = root.children; 969 for (var ci = 0; ci < ch.length; ci++) { 970 var cid = ch[ci].getAttribute('data-wsb-id'); 971 if ( 972 cid && 973 wsbFile(cid) === rootFile && 974 wsbLine(cid) > rootLine && 975 isSectionLike(ch[ci]) 976 ) 977 sections.push(ch[ci]); 978 } 979 return { root: root, sections: sections }; 980 } 981 982 var INLINE_TAGS = { 983 span: 1, 984 strong: 1, 985 em: 1, 986 b: 1, 987 i: 1, 988 u: 1, 989 s: 1, 990 mark: 1, 991 code: 1, 992 a: 1, 993 }; 994 function isTextEditable(el) { 995 if (!el.textContent.trim()) return false; 996 for (var i = 0; i < el.childNodes.length; i++) { 997 var child = el.childNodes[i]; 998 if (child.nodeType === 3) continue; // text node â ok 999 if (child.nodeType === 1 && getTagName(child) === 'br') continue; // void, contributes no content 1000 if ( 1001 child.nodeType === 1 && 1002 INLINE_TAGS[getTagName(child)] && 1003 !child.querySelector( 1004 'div,section,article,header,footer,nav,aside,main,figure,ul,ol,table,h1,h2,h3,h4,h5,h6,img', 1005 ) 1006 ) 1007 continue; // inline element â ok 1008 return false; 1009 } 1010 return el.childNodes.length > 0; 1011 } 1012 function isTextOnly(el) { 1013 return isTextEditable(el); 1014 } 1015 1016 var TEXT_SELECTION_TAGS = { 1017 h1: 1, 1018 h2: 1, 1019 h3: 1, 1020 h4: 1, 1021 h5: 1, 1022 h6: 1, 1023 p: 1, 1024 span: 1, 1025 a: 1, 1026 label: 1, 1027 li: 1, 1028 dt: 1, 1029 dd: 1, 1030 blockquote: 1, 1031 caption: 1, 1032 figcaption: 1, 1033 td: 1, 1034 th: 1, 1035 button: 1, 1036 }; 1037 var IMAGE_SELECTION_TAGS = { 1038 img: 1, 1039 picture: 1, 1040 svg: 1, 1041 use: 1, 1042 canvas: 1, 1043 video: 1, 1044 }; 1045 1046 function inferSelectionKind(el, isProductCard, isPostCard, isServiceCard) { 1047 var tag = getTagName(el); 1048 if (isProductCard) return 'product'; 1049 if (isPostCard) return 'post'; 1050 if (isServiceCard) return 'service'; 1051 if (TEXT_SELECTION_TAGS[tag]) return 'text'; 1052 if (IMAGE_SELECTION_TAGS[tag]) return 'image'; 1053 if (isTextEditable(el)) return 'text'; 1054 return 'section'; 1055 } 1056 1057 function stopEditing() { 1058 if (!state.editingEl) return; 1059 var el = state.editingEl; 1060 var originalContent = state.editingOriginalContent; 1061 state.editingEl = null; 1062 state.editingOriginalContent = null; 1063 el.contentEditable = 'false'; 1064 el.style.outline = ''; 1065 el.style.minWidth = ''; 1066 // If a range is still pending, the wsb-apply-range-style handler will persist after applying 1067 if (savedRange) return; 1068 var wsbId = el.getAttribute('data-wsb-id'); 1069 var callId = el.getAttribute('data-wsb-call-id'); 1070 var targetId = callId || wsbId; 1071 if (targetId) { 1072 var content = serializeInlineContent(el); 1073 var hasInlineMarkup = hasSupportedInlineMarkup(content); 1074 1075 1076 if (content !== originalContent) { 1077 editEventHandlers[EDIT_EVENTS.TEXT_CONTENT_CHANGED]({ 1078 selectedId: targetId, 1079 callId: callId || null, 1080 content: content, 1081 previousContent: originalContent, 1082 }); 1083 } 1084 } 1085 } 1086 // Get the closest element that has been tagged either with data-wsb-call-id or data-wsb-id 1087 function getClosestStampedElement(e) { 1088 var node = e.target; 1089 while (node && node !== document) { 1090 if (node.nodeType === 1) { 1091 if ( 1092 node.hasAttribute('data-wsb-call-id') || 1093 node.hasAttribute('data-wsb-id') 1094 ) { 1095 return node; 1096 } 1097 } 1098 node = node.parentNode; 1099 } 1100 return null; 1101 } 1102 1103 1104 function getLoopId(el){ 1105 return getNearestAttributeValue(el, "data-wsb-loop-id", ["-1"]); 1106 } 1107 1108 // Product card root detection â used by both hover and click handlers. 1109 // Matches elements whose class list contains a product card root class, 1110 // but NOT sub-elements like product-card__img, product-card__body, etc. 1111 function isProductCardRoot(node) { 1112 if (!node || typeof node.className !== 'string') return false;
1113 var classes = node.className.split(/\s+/); 1114 for (var ci = 0; ci < classes.length; ci++) { 1115 var c = classes[ci]; 1116 // Match: product-card, product-item, product-tile, productCard, etc. 1117 // Reject: product-card__img, product-card__body, product-card__footer (BEM children) 1118 if (/^product[-_]?(card|item|tile|box|wrap)?$/i.test(c)) return true; 1119 } 1120 return false; 1121 } 1122 1123 // Backend product id on a card root: the WooCommerce numeric id 1124 // (data-wc-product-id) or the commerce-engine uuid (data-ce-product-id). 1125 // The editor parent validates the value per provider. Returns null when 1126 // neither attribute is present. 1127 function getProductId(node) { 1128 if (!node || node.nodeType !== 1) return null; 1129 return ( 1130 node.getAttribute('data-wc-product-id') || 1131 node.getAttribute('data-ce-product-id') 1132 ); 1133 } 1134 1135 // Selector matching a product-card root by its backend-id attribute, for 1136 // either commerce provider (WooCommerce or commerce-engine). 1137 var PRODUCT_ID_SELECTOR = '[data-wc-product-id],[data-ce-product-id]'; 1138 1139 const findBlogContentArea = (clickedEl) => { 1140 // Bail only if the click is inside page-level chrome: a <header> or <footer> 1141 // that has no <article> ancestor. Post templates often use <header> inside 1142 // <article> for the hero/title area â those should still be promoted. 1143 let cur = clickedEl; 1144 while (cur && cur !== document.body) { 1145 const t = (cur.tagName || '').toLowerCase(); 1146 if ((t === 'header' || t === 'footer') && !cur.closest('article')) 1147 return null; 1148 cur = cur.parentElement; 1149 } 1150 1151 // Try <main> first (query globally â some themes don't wrap <main> around the click target). 1152 // Prefer its first direct <article> child so we get the post component wsb-id 1153 // (e.g. PostDetail.jsx) rather than the layout wrapper (e.g. App.jsx). 1154 // Sidebar <article> elements are nested deeper inside the post article, so they 1155 // won't be returned as direct children of <main>. 1156 const mainEl = document.querySelector('main'); 1157 if (mainEl) { 1158 for (const child of mainEl.children) { 1159 if ((child.tagName || '').toLowerCase() === 'article') return child; 1160 } 1161 return mainEl; 1162 } 1163 1164 // No <main> (e.g. classic WordPress themes) â use the first <article> that 1165 // is not inside a <header>, <footer>, or <nav>. 1166 const CHROME_TAGS = new Set(['header', 'footer', 'nav']); 1167 for (const article of document.querySelectorAll('article')) { 1168 let ancestor = article.parentElement; 1169 let inChrome = false; 1170 while (ancestor && ancestor !== document.body) { 1171 if (CHROME_TAGS.has((ancestor.tagName || '').toLowerCase())) { 1172 inChrome = true; 1173 break; 1174 } 1175 ancestor = ancestor.parentElement; 1176 } 1177 if (!inChrome) return article; 1178 } 1179 1180 // Final fallback: first direct body child that is not header/footer/nav/script/style 1181 const SKIP_TAGS = new Set(['header', 'footer', 'nav', 'script', 'style']); 1182 for (const child of document.body.children) { 1183 if (!SKIP_TAGS.has((child.tagName || '').toLowerCase())) return child; 1184 } 1185 return null; 1186 }; 1187 1188 /** 1189 * Returns true when `href` looks like an internal single-post URL. 1190 * 1191 * A qualifying href must: 1192 * - be a relative path (no scheme, `//`, `#`, `mailto:`, or `tel:`) 1193 * - differ from the current page path (so a "Blog" nav link on /blog doesn't count) 1194 * - contain at least two non-empty path segments after stripping query/hash 1195 * (e.g. `/blog/my-post` qualifies; `/blog` does not) 1196 * 1197 * @param {string} href - The raw href attribute value from an anchor element. 1198 * @param {string} curPath - `window.location.pathname` of the current page. 1199 * @returns {boolean} 1200 */ 1201 const isInternalPostHref = (href, curPath) => { 1202 if (!href || /^(https?:|\/\/|#|mailto:|tel:)/.test(href)) return false; 1203 const p = href.split('?')[0].split('#')[0].replace(/\/$/, '') || '/'; 1204 if (p === (curPath.replace(/\/$/, '') || '/')) return false; 1205 return p.replace(/^\//, '').split('/').filter(Boolean).length >= 2; 1206 }; 1207 1208 /** 1209 * Collects all unique internal post hrefs found within `el`. 1210 * 1211 * Includes `el` itself if it is an `<a>`, then queries all descendant 1212 * `<a href>` elements. Deduplicates via a Set and filters each href through 1213 * `isInternalPostHref` so navigation links and external URLs are excluded. 1214 * 1215 * @param {Element} el - The element to search. 1216 * @param {string} curPath - `window.location.pathname` of the current page. 1217 * @returns {string[]} Deduplicated array of qualifying post hrefs. 1218 */ 1219 const collectPostHrefs = (el, curPath) => { 1220 const seen = new Set(); 1221 const anchors = [ 1222 ...(getTagName(el) === 'a' ? [el] : []), 1223 ...el.querySelectorAll('a[href]'), 1224 ]; 1225 const out = []; 1226 for (const anchor of anchors) { 1227 const h = anchor.getAttribute('href') || ''; 1228 if (h && isInternalPostHref(h, curPath) && !seen.has(h)) { 1229 seen.add(h); 1230 out.push(h); 1231 } 1232 } 1233 return out; 1234 }; 1235 1236 /** 1237 * Heuristic that decides whether `el` looks like a self-contained post card. 1238 * 1239 * Scores one point each for the presence of an `<img>`, a heading 1240 * (`h1`â`h6`), and a `<p>` anywhere in the subtree. An element scores 1241 * true when it reaches at least 2 out of 3, so a card with just an image 1242 * and a heading (no excerpt paragraph) still qualifies, but a bare heading 1243 * alone does not. 1244 * 1245 * @param {Element} el - The element to test. 1246 * @returns {boolean} 1247 */ 1248 const looksLikePostCard = (el) => 1249 !!el.querySelector('img') + 1250 !!el.querySelector('h1,h2,h3,h4,h5,h6') + 1251 !!el.querySelector('p') >= 1252 2; 1253 1254 /** 1255 * Walks up the DOM from `startEl` to find the root element of a blog post 1256 * card, promoting any click on a card's internals to the card root. 1257 * 1258 * Algorithm (up to 10 ancestors, stops at `document.body`): 1259 * 1. Only considers elements that carry a `data-wsb-id` or 1260 * `data-wsb-call-id` attribute (i.e. stamped by the compiler). 1261 * 2. Collects internal post hrefs for each candidate via `collectPostHrefs`. 1262 * 3. An element with exactly one qualifying href that also passes 1263 * `looksLikePostCard` becomes the initial `best`. 1264 * 4. Wider ancestors that link to the same single href extend `best` 1265 * upward â unless a direct child count check reveals two or more sibling 1266 * post cards inside that ancestor (which would mean it is a list wrapper, 1267 * not the card itself). 1268 * 5. Stops early when hrefs diverge (different post) or when a zero-href 1269 * element is reached after a candidate has already been found. 1270 * 1271 * @param {Element} startEl - The element that was clicked or hovered. 1272 * @returns {{ root: Element, href: string } | null} The post card root and 1273 * its post href, or `null` if no post card structure was detected. 1274 */ 1275 const findPostCardRoot = (startEl) => { 1276 const curPath = window.location.pathname; 1277 let best = null, 1278 bestHref = null, 1279 el = startEl; 1280 for ( 1281 let i = 0; 1282 i < 10 && el && el !== document.body; 1283 i++, el = el.parentElement 1284 ) { 1285 if ( 1286 !el.getAttribute('data-wsb-id') && 1287 !el.getAttribute('data-wsb-call-id') 1288 ) 1289 continue; 1290 const hrefs = collectPostHrefs(el, curPath); 1291 if (hrefs.length === 0) { 1292 if (best) break; 1293 } else if (hrefs.length === 1) { 1294 if (!bestHref) { 1295 if (looksLikePostCard(el)) { 1296 best = el; 1297 bestHref = hrefs[0]; 1298 } 1299 } else if (hrefs[0] === bestHref) { 1300 // Don't extend best to a container that holds multiple sibling post cards 1301 const cardCount = Array.from(el.children).filter( 1302 looksLikePostCard, 1303 ).length; 1304 if (cardCount >= 2) break; 1305 best = el; 1306 } else break; 1307 } else break; 1308 } 1309 return best ? { root: best, href: bestHref } : null; 1310 }; 1311 1312 /** 1313 * Returns true when `node` contains exactly one stored Shore service name â 1314 * the structural signature of a service card root. 1315 */ 1316 const isServiceCardRoot = (node) => { 1317 if (!node || !storedServices.length) return false;
1318 const text = (node.textContent || '').toLowerCase(); 1319 let matchCount = 0; 1320 for (const svc of storedServices) { 1321 if (text.includes(svc.name.toLowerCase())) matchCount++; 1322 if (matchCount > 1) return false; 1323 } 1324 return matchCount === 1; 1325 }; 1326 1327 /** 1328 * Walks up from `startEl` looking for a node that contains exactly one 1329 * stored Shore service name. Returns the root element and its serviceId, 1330 * or `null` if no service card structure is detected. 1331 * 1332 * Does not steal elements already identified as blog post cards. 1333 * 1334 * @returns {{ root: Element, serviceId: string } | null} 1335 */ 1336 const findServiceCardRoot = (startEl) => { 1337 if (!storedServices.length) return null; 1338 let best = null; 1339 let bestServiceId = null; 1340 let el = startEl; 1341 for (let i = 0; i < 10 && el && el !== document.body; i++, el = el.parentElement) { 1342 if (!el.getAttribute('data-wsb-id') && !el.getAttribute('data-wsb-call-id')) continue; 1343 const text = (el.textContent || '').toLowerCase(); 1344 let matchCount = 0; 1345 let matchedId = null; 1346 for (const svc of storedServices) { 1347 if (text.includes(svc.name.toLowerCase())) { 1348 matchCount++; 1349 if (matchCount === 1) matchedId = svc.serviceId; 1350 } 1351 } 1352 if (matchCount === 1) { 1353 best = el; 1354 bestServiceId = matchedId; 1355 } else if (matchCount > 1) { 1356 break; 1357 } 1358 } 1359 // Don't steal blog post cards that happen to mention a service name. 1360 if (best && findPostCardRoot(best)) return null; 1361 return best ? { root: best, serviceId: bestServiceId } : null; 1362 }; 1363 1364 document.addEventListener( 1365 'mousemove', 1366 function (e) { 1367 if (!state.enabled || state.editingEl) return; 1368 if (state.selectedEl) return; 1369 var el = document.elementFromPoint(e.clientX, e.clientY); 1370 if (!el) { 1371 hideOverlayGroup(ui.overlayNodes); 1372 state.hoveredEl = null; 1373 return; 1374 } 1375 1376 // Same ordering as the click handler: promote first, fall back to the 1377 // nearest stamped ancestor. Hover never enters edit mode, so we don't 1378 // apply the uniqueness gate â the overlay is purely visual feedback 1379 // that an element is selectable. 1380 var hoverAnchor = null; 1381 var cur = el; 1382 while (cur && cur !== document) { 1383 if ( 1384 cur.nodeType === 1 && 1385 (cur.hasAttribute('data-wsb-call-id') || 1386 cur.hasAttribute('data-wsb-id')) 1387 ) { 1388 hoverAnchor = cur; 1389 break; 1390 } 1391 cur = cur.parentNode; 1392 } 1393 1394 var target = null; 1395 // Product card promotion on hover â highlight the whole card, not child elements 1396 var hoverCardRoot = el.closest && el.closest(PRODUCT_ID_SELECTOR); 1397 if (!hoverCardRoot) { 1398 var hCheck = el; 1399 for (var _hpc = 0; _hpc < 8 && hCheck; _hpc++) { 1400 if ( 1401 hCheck.nodeType === 1 && 1402 isProductCardRoot(hCheck) && 1403 (hCheck.getAttribute('data-wsb-id') || 1404 hCheck.getAttribute('data-wsb-call-id')) 1405 ) { 1406 hoverCardRoot = hCheck; 1407 break; 1408 } 1409 hCheck = hCheck.parentElement; 1410 } 1411 } 1412 var isHoverProduct = !!( 1413 hoverCardRoot && 1414 (hoverCardRoot.getAttribute('data-wsb-id') || 1415 hoverCardRoot.getAttribute('data-wsb-call-id') || 1416 getProductId(hoverCardRoot)) 1417 ); 1418 if (isHoverProduct) { 1419 target = hoverCardRoot; 1420 } 1421 var isHoverPost = false; 1422 if (!isHoverProduct) { 1423 var hoverPostResult = findPostCardRoot(el); 1424 if (hoverPostResult) { 1425 target = hoverPostResult.root; 1426 isHoverPost = true; 1427 } 1428 } 1429 var isHoverService = false; 1430 if (!isHoverProduct && !isHoverPost) { 1431 var hoverServiceResult = findServiceCardRoot(el); 1432 if (hoverServiceResult) { 1433 target = hoverServiceResult.root; 1434 isHoverService = true; 1435 } 1436 } 1437 if (!target) { 1438 target = hoverAnchor; 1439 } 1440 if (!target) { 1441 hideOverlayGroup(ui.overlayNodes); 1442 state.hoveredEl = null; 1443 return; 1444 } 1445 if (target === state.hoveredEl) return; 1446 state.hoveredEl = target; 1447 var r; 1448 if (isHoverProduct || isHoverPost || isHoverService) { 1449 r = positionSingleOverlay(target, ui.overlayNodes, ui.overlay); 1450 } else { 1451 r = positionOverlayGroupForElement(target, ui.overlayNodes, ui.overlay); 1452 } 1453 }, 1454 true, 1455 ); 1456 1457 function getPickerBoundaryRect() { 1458 var bnd; 1459 if (state.pickerAfterEl && state.pickerBeforeEl) { 1460 bnd = 1461 (state.pickerAfterEl.getBoundingClientRect().bottom + 1462 state.pickerBeforeEl.getBoundingClientRect().top) / 1463 2; 1464 } else if (state.pickerAfterEl) { 1465 bnd = state.pickerAfterEl.getBoundingClientRect().bottom; 1466 } else if (state.pickerBeforeEl) { 1467 bnd = state.pickerBeforeEl.getBoundingClientRect().top; 1468 } else { 1469 return null; 1470 } 1471 return { top: bnd, left: 0, width: window.innerWidth, height: 0 }; 1472 } 1473 1474 document.addEventListener( 1475 'click', 1476 function (e) { 1477 if (!state.enabled) return; 1478 if (state.editingEl && !state.editingEl.contains(e.target)) { 1479 stopEditing(); 1480 } 1481 1482 // Anchor first, promote second, gate last. 1483 // 1484 // Dynamic-DATA cards (products / posts / services / blog content) are 1485 // rendered from a .map() and therefore share their `data-wsb-id` / 1486 // `data-wsb-call-id` with siblings. The uniqueness gate would reject 1487 // them, so the data-driven promotion helpers run BEFORE the gate. If 1488 // promotion matches, we trust those helpers' identity (wc-product-id, 1489 // post href, service id). 1490 // 1491 // Otherwise, we fall back to the closest stamped ancestor. If that 1492 // anchor is uniquely identifiable in the document, it's a real unique 1493 // element and manual editing is allowed. If it is duplicated, we still 1494 // surface a selection (so the user sees feedback) but mark it as 1495 // dynamic-static â the parent hides text/style/upload/delete controls 1496 // and only the AI "Ask for changes" entry remains. 1497 var anchor = getClosestStampedElement(e); 1498 1499 if ( 1500 state.editingEl && 1501 (anchor === state.editingEl || state.editingEl.contains(e.target)) 1502 ) { 1503 e.preventDefault(); 1504 e.stopPropagation(); 1505 return; 1506 } 1507 1508 // Prevent all regular event propagations 1509 e.preventDefault(); 1510 e.stopPropagation(); 1511 1512 // Product card promotion â if clicking inside a product card, select the card root instead. 1513 var cardRoot = e.target.closest && e.target.closest(PRODUCT_ID_SELECTOR); 1514 if (!cardRoot) { 1515 // Check e.target itself and ancestors for product card root class 1516 var check = e.target; 1517 for (var _pc = 0; _pc < 8 && check; _pc++) { 1518 if ( 1519 check.nodeType === 1 && 1520 isProductCardRoot(check) && 1521 (check.getAttribute('data-wsb-id') || 1522 check.getAttribute('data-wsb-call-id')) 1523 ) { 1524 cardRoot = check; 1525 break; 1526 } 1527 check = check.parentElement; 1528 } 1529 } 1530 var isProductCard = !!( 1531 cardRoot &&
1532 (cardRoot.getAttribute('data-wsb-id') || 1533 cardRoot.getAttribute('data-wsb-call-id') || 1534 getProductId(cardRoot)) 1535 ); 1536 1537 var el = null; 1538 if (isProductCard) { 1539 el = cardRoot; 1540 } 1541 1542 // Blog post mode â promote any click to the page content area (excluding header/footer) 1543 var isBlogContent = false; 1544 if (!isProductCard && state.blogPostMode) { 1545 var blogAnchor = anchor || e.target; 1546 var contentArea = findBlogContentArea(blogAnchor); 1547 if (contentArea) { 1548 el = contentArea; 1549 isBlogContent = true; 1550 } 1551 } 1552 var isPostCard = false; 1553 var clickPostHref = null; 1554 if (!isProductCard && !isBlogContent) { 1555 var clickPostResult = findPostCardRoot(e.target); 1556 if (clickPostResult) { 1557 el = clickPostResult.root; 1558 clickPostHref = clickPostResult.href; 1559 isPostCard = true; 1560 } 1561 } 1562 var isServiceCard = false; 1563 var clickServiceResult = null; 1564 if (!isProductCard && !isPostCard && !isBlogContent) { 1565 clickServiceResult = findServiceCardRoot(e.target); 1566 if (clickServiceResult) { 1567 el = clickServiceResult.root; 1568 isServiceCard = true; 1569 } 1570 } 1571 1572 var isDynamicData = 1573 isProductCard || isPostCard || isServiceCard || isBlogContent; 1574 1575 1576 if (!isDynamicData) { 1577 if (!anchor) { 1578 // Clicked on blank / unstamped area â deselect and notify parent. 1579 if (state.selectedEl) { 1580 state.selectedEl = null; 1581 hideOverlayGroup(ui.selectedOverlayNodes); 1582 window.parent.postMessage({ type: 'wsb-deselect' }, '*'); 1583 } 1584 return; 1585 } 1586 el = anchor; 1587 } 1588 state.selectedEl = el; 1589 state.isPostCard = isPostCard; 1590 state.isServiceCard = isServiceCard; 1591 resizeObserver.disconnect(); 1592 resizeObserver.observe(el); 1593 // (multiple cards share the same data-wsb-id since they come from the same JSX line) 1594 var r; 1595 if (isProductCard || isPostCard || isServiceCard) { 1596 r = positionSingleOverlay( 1597 el, 1598 ui.selectedOverlayNodes, 1599 ui.selectedOverlay, 1600 ); 1601 } else { 1602 r = positionOverlayGroupForElement( 1603 el, 1604 ui.selectedOverlayNodes, 1605 ui.selectedOverlay, 1606 ); 1607 } 1608 hideOverlayGroup(ui.overlayNodes); 1609 let prevSibId = null, 1610 nextSibId = null, 1611 sectionId = null; 1612 let sectionEl = null; 1613 const sectionCtx = resolveSectionContext(el); 1614 if (sectionCtx) { 1615 const selectedIsPlainChildContainer = 1616 sectionCtx.section !== el && 1617 getTagName(el) === 'div' && 1618 !isOutOfFlow(el) && 1619 !isSectionCandidate(el); 1620 if (selectedIsPlainChildContainer) { 1621 sectionEl = el; 1622 sectionId = getSectionId(el); 1623 } else { 1624 sectionEl = sectionCtx.section; 1625 sectionId = getSectionId(sectionEl); 1626 const { list, index } = sectionCtx; 1627 if (!isFixedSectionInList(list, index)) { 1628 if (!isFixedSectionInList(list, index - 1)) { 1629 prevSibId = getSectionId(list[index - 1]); 1630 } 1631 if (!isFixedSectionInList(list, index + 1)) { 1632 nextSibId = getSectionId(list[index + 1]); 1633 } 1634 } 1635 } 1636 } 1637 var imgChildId = null; 1638 var hasBgImage = false; 1639 var oldUrl = ''; 1640 if (getTagName(el) !== 'img') { 1641 var imgChild = el.querySelector('img[data-wsb-id]'); 1642 if (!imgChild && el.parentElement) { 1643 imgChild = el.parentElement.querySelector('img[data-wsb-id]'); 1644 } 1645 if (imgChild) { 1646 imgChildId = imgChild.getAttribute('data-wsb-id'); 1647 oldUrl = imgChild.getAttribute('src') || imgChild.src || ''; 1648 } 1649 var bgImg = el.style.backgroundImage; 1650 if (bgImg && bgImg !== 'none' && bgImg.indexOf('url') !== -1) { 1651 hasBgImage = true; 1652 oldUrl = extractUrlFromCssImage(bgImg); 1653 } 1654 } else { 1655 oldUrl = el.getAttribute('src') || el.src || ''; 1656 } 1657 // WooCommerce product data extraction â scrape product info directly from the card DOM 1658 var wcProductId = null; 1659 var wcProductData = null; 1660 if (isProductCard) { 1661 // el is already promoted to the card root 1662 wcProductId = 1663 getProductId(el) || 1664 el.getAttribute('data-id') || 1665 null; 1666 var _nameEl = el.querySelector( 1667 "h1,h2,h3,h4,[class*='name'],[class*='title']:not([class*='section'])", 1668 ); 1669 var _priceEl = el.querySelector("[class*='price']"); 1670 var _catEl = el.querySelector("[class*='category']"); 1671 var _descEl = el.querySelector("[class*='desc']"); 1672 var _imgEl = el.querySelector('img'); 1673 wcProductData = {
1674 name: _nameEl ? (_nameEl.textContent || '').trim() : '', 1675 price: _priceEl ? (_priceEl.textContent || '').trim() : '', 1676 category: _catEl ? (_catEl.textContent || '').trim() : '', 1677 description: _descEl ? (_descEl.textContent || '').trim() : '', 1678 image: _imgEl ? _imgEl.getAttribute('src') || '' : '', 1679 }; 1680 } 1681 var postData = isPostCard ? { href: clickPostHref || '' } : null; 1682 var serviceData = isServiceCard && clickServiceResult ? { serviceId: clickServiceResult.serviceId } : null; 1683 var selectionKind = inferSelectionKind(el, isProductCard, isPostCard, isServiceCard); 1684 1685 if (!sectionId && selectionKind === 'section') { 1686 sectionEl = el; 1687 sectionId = getSectionId(el); 1688 } 1689 var cs = window.getComputedStyle(el); 1690 var elementId = findElementId(el); 1691 1692 window.parent.postMessage( 1693 { 1694 type: 'wsb-select', 1695 id: elementId, 1696 tag: getTagName(el), 1697 loopId: getLoopId(el) ?? null, 1698 selectionKind: selectionKind, 1699 isBlogContent: isBlogContent, 1700 pathname: window.location.pathname, 1701 rect: { top: r.top, left: r.left, width: r.width, height: r.height }, 1702 sectionId: sectionId, 1703 sectionHtml: sectionEl ? sectionEl.outerHTML : null, 1704 prevSiblingId: prevSibId, 1705 nextSiblingId: nextSibId, 1706 imgChildId: imgChildId, 1707 hasBgImage: hasBgImage, 1708 oldUrl: oldUrl, 1709 wcProductId: wcProductId, 1710 wcProductData: wcProductData, 1711 postData: postData, 1712 serviceData: serviceData, 1713 computedStyles: { 1714 fontSize: cs.fontSize, 1715 fontWeight: cs.fontWeight, 1716 fontStyle: cs.fontStyle, 1717 textDecorationLine: cs.textDecorationLine, 1718 textDecoration: cs.textDecoration, 1719 textAlign: cs.textAlign, 1720 textTransform: cs.textTransform, 1721 color: cs.color, 1722 backgroundColor: cs.backgroundColor, 1723 opacity: cs.opacity, 1724 marginLeft: cs.marginLeft, 1725 marginRight: cs.marginRight, 1726 marginTop: cs.marginTop, 1727 marginBottom: cs.marginBottom, 1728 paddingLeft: cs.paddingLeft, 1729 paddingRight: cs.paddingRight, 1730 paddingTop: cs.paddingTop, 1731 paddingBottom: cs.paddingBottom, 1732 }, 1733 inlineStyles: { 1734 fontSize: el.style.fontSize, 1735 fontWeight: el.style.fontWeight, 1736 fontStyle: el.style.fontStyle, 1737 textDecoration: el.style.textDecoration, 1738 textAlign: el.style.textAlign, 1739 textTransform: el.style.textTransform, 1740 color: el.style.color, 1741 background: el.style.background, 1742 opacity: el.style.opacity, 1743 marginLeft: el.style.marginLeft, 1744 marginRight: el.style.marginRight, 1745 marginTop: el.style.marginTop, 1746 marginBottom: el.style.marginBottom, 1747 paddingLeft: el.style.paddingLeft, 1748 paddingRight: el.style.paddingRight, 1749 paddingTop: el.style.paddingTop, 1750 paddingBottom: el.style.paddingBottom, 1751 }, 1752 }, 1753 '*', 1754 ); 1755 }, 1756 true, 1757 ); 1758 1759 document.addEventListener( 1760 'dblclick', 1761 function (e) { 1762 // Only on double click operations do we want to edit text 1763 if (!state.enabled) return; 1764 // Dynamic-static items cannot be text-edited per-instance â their 1765 // duplicated wsb-id would silently fan changes out to siblings. 1766 var el = state.selectedEl; 1767 if (!el || !el.contains(e.target) || !isTextOnly(el)) return; 1768 1769 1770 const isLoopedElement = (getLoopId(el) ?? null) !== null; 1771 1772 if (isLoopedElement && state.quotaExceeded) return; 1773 1774 e.preventDefault(); 1775 e.stopPropagation(); 1776 state.editingEl = el; 1777 // Serialize the baseline the same way stopEditing does, so the undo 1778 // snapshot and the new value are directly comparable. 1779 state.editingOriginalContent = serializeInlineContent(el); 1780 el.contentEditable = 'true'; 1781 el.style.minWidth = '1em'; 1782 el.focus(); 1783 var range = document.createRange(); 1784 range.selectNodeContents(el); 1785 var sel = window.getSelection(); 1786 sel.removeAllRanges(); 1787 sel.addRange(range); 1788 }, 1789 true, 1790 ); 1791 1792 document.addEventListener( 1793 'keydown', 1794 function (e) { 1795 if (!state.editingEl) return; 1796 1797 if (e.key === 'Escape') { 1798 e.preventDefault(); 1799 state.editingEl.blur(); 1800 return; 1801 } 1802 1803 if (e.key === 'Enter') { 1804 if (e.shiftKey) { 1805 e.preventDefault(); 1806 1807 var inserted = false;
1808 try { 1809 inserted = document.execCommand('insertLineBreak'); 1810 } catch (_err) { 1811 inserted = false; 1812 } 1813 1814 if (!inserted) { 1815 var sel = window.getSelection(); 1816 if (!sel || sel.rangeCount === 0) return; 1817 var range = sel.getRangeAt(0); 1818 range.deleteContents(); 1819 var br = document.createElement('br'); 1820 range.insertNode(br); 1821 range.setStartAfter(br); 1822 range.setEndAfter(br); 1823 sel.removeAllRanges(); 1824 sel.addRange(range); 1825 } 1826 return; 1827 } 1828 1829 e.preventDefault(); 1830 state.editingEl.blur(); 1831 } 1832 }, 1833 true, 1834 ); 1835 1836 document.addEventListener( 1837 'focusout', 1838 function (e) { 1839 if (state.editingEl && e.target === state.editingEl) stopEditing(); 1840 }, 1841 true, 1842 ); 1843 1844 document.addEventListener( 1845 'mouseup', 1846 function () { 1847 if (!state.editingEl) return; 1848 var sel = window.getSelection(); 1849 if (!sel || sel.isCollapsed || sel.rangeCount === 0) { 1850 if (savedRange) { 1851 savedRange = null; 1852 window.parent.postMessage({ type: 'wsb-range-deselect' }, '*'); 1853 } 1854 return; 1855 } 1856 var range = sel.getRangeAt(0); 1857 if (!state.editingEl.contains(range.commonAncestorContainer)) { 1858 savedRange = null; 1859 window.parent.postMessage({ type: 'wsb-range-deselect' }, '*'); 1860 return; 1861 } 1862 savedRange = range.cloneRange(); 1863 savedEditingEl = state.editingEl; 1864 window.parent.postMessage({ type: 'wsb-range-select' }, '*'); 1865 }, 1866 true, 1867 ); 1868 1869 document.addEventListener('selectionchange', function () { 1870 if (!state.editingEl || !savedRange) return; 1871 // If the iframe no longer has focus the selection collapsed because the user 1872 // clicked on the parent toolbar â keep savedRange so the style can still apply. 1873 if (!document.hasFocus()) return; 1874 var sel = window.getSelection(); 1875 if (!sel || sel.isCollapsed) { 1876 savedRange = null; 1877 window.parent.postMessage({ type: 'wsb-range-deselect' }, '*'); 1878 } 1879 }); 1880 1881 function updatePositions() { 1882 if (state.hoveredEl && state.hoveredEl.isConnected && !state.selectedEl) { 1883 if ( 1884 getProductId(state.hoveredEl) || 1885 isProductCardRoot(state.hoveredEl) || 1886 isServiceCardRoot(state.hoveredEl) 1887 ) { 1888 positionSingleOverlay(state.hoveredEl, ui.overlayNodes, ui.overlay); 1889 } else { 1890 positionOverlayGroupForElement( 1891 state.hoveredEl, 1892 ui.overlayNodes, 1893 ui.overlay, 1894 ); 1895 } 1896 } 1897 if (state.uploadTarget && state.uploadTarget.element.isConnected) { 1898 // Send rect updates on scroll 1899 r = state.uploadTarget.element.getBoundingClientRect(); 1900 window.parent.postMessage( 1901 { 1902 type: 'wsb-target-image-rect-update', 1903 rect: { top: r.top, left: r.left, width: r.width, height: r.height }, 1904 }, 1905 '*', 1906 ); 1907 } 1908 if (state.selectedEl && state.selectedEl.isConnected) { 1909 var r; 1910 if ( 1911 getProductId(state.selectedEl) || 1912 isProductCardRoot(state.selectedEl) || 1913 state.isPostCard || 1914 state.isServiceCard 1915 ) { 1916 r = positionSingleOverlay( 1917 state.selectedEl, 1918 ui.selectedOverlayNodes, 1919 ui.selectedOverlay, 1920 ); 1921 } else { 1922 r = positionOverlayGroupForElement( 1923 state.selectedEl, 1924 ui.selectedOverlayNodes, 1925 ui.selectedOverlay, 1926 ); 1927 } 1928 window.parent.postMessage( 1929 { 1930 type: 'wsb-rect-update', 1931 rect: { top: r.top, left: r.left, width: r.width, height: r.height }, 1932 }, 1933 '*', 1934 ); 1935 } 1936 if (state.pickerOpen) { 1937 var pr = getPickerBoundaryRect(); 1938 if (pr) { 1939 window.parent.postMessage( 1940 { 1941 type: 'wsb-add-component-rect-update', 1942 rect: pr, 1943 }, 1944 '*', 1945 ); 1946 } 1947 } 1948 } 1949 window.addEventListener( 1950 'scroll', 1951 function () { 1952 updatePositions(); 1953 window.parent.postMessage( 1954 { type: 'wsb-scroll', scrollY: window.scrollY }, 1955 '*', 1956 ); 1957 }, 1958 true, 1959 ); 1960 window.addEventListener('resize', updatePositions); 1961 1962 var resizeObserver = new ResizeObserver(function () { 1963 if (state.selectedEl && state.selectedEl.isConnected) updatePositions(); 1964 }); 1965 1966 function hideAll() { 1967 stopEditing(); 1968 resizeObserver.disconnect(); 1969 state.selectedEl = null; 1970 state.hoveredEl = null; 1971 state.isPostCard = false;
1972 state.isServiceCard = false; 1973 state.isManualEditDisabled = false; 1974 hideOverlayGroup(ui.overlayNodes); 1975 hideOverlayGroup(ui.selectedOverlayNodes); 1976 state.pickerOpen = false; 1977 state.pickerAfterEl = null; 1978 state.pickerBeforeEl = null; 1979 savedRange = null; 1980 savedEditingEl = null; 1981 } 1982 1983 // Like hideAll(), but keeps the selected element so the editor 1984 // can continue to function after temporary disabling. 1985 function disableWithoutClearingSelection() { 1986 stopEditing(); 1987 resizeObserver.disconnect(); 1988 state.hoveredEl = null; 1989 hideOverlayGroup(ui.overlayNodes); 1990 hideOverlayGroup(ui.selectedOverlayNodes); 1991 state.pickerOpen = false; 1992 state.pickerAfterEl = null; 1993 state.pickerBeforeEl = null; 1994 savedRange = null; 1995 savedEditingEl = null; 1996 } 1997 1998 window.addEventListener('message', function (e) { 1999 if (!e.data) return; 2000 if (e.data.type === 'wsb-set-services') { 2001 storedServices = Array.isArray(e.data.services) ? e.data.services : []; 2002 } 2003 if (e.data.type === 'wsb-enable') { 2004 state.enabled = true; 2005 document.body.classList.add('wsb-active'); 2006 updatePositions(); 2007 window.parent.postMessage( 2008 { type: 'wsb-pathname', pathname: window.location.pathname }, 2009 '*', 2010 ); 2011 } 2012 if (e.data.type === 'wsb-quota-exceeded'){ 2013 state.quotaExceeded = Boolean(e.data.data); 2014 } 2015 if (e.data.type === 'wsb-disable') { 2016 state.enabled = false; 2017 document.body.classList.remove('wsb-active'); 2018 disableWithoutClearingSelection(); 2019 } 2020 if (e.data.type === 'wsb-deselect') { 2021 hideAll(); 2022 } 2023 if (e.data.type === 'wsb-set-mode') { 2024 state.blogPostMode = !!e.data.blogPost; 2025 } 2026 if (e.data.type === 'wsb-picker-closed') { 2027 state.pickerOpen = false; 2028 state.pickerAfterEl = null; 2029 state.pickerBeforeEl = null; 2030 } 2031 if (e.data.type === 'wsb-scroll-to') { 2032 window.scrollTo(0, e.data.scrollY); 2033 } 2034 if (e.data.type === 'wsb-get-scroll') { 2035 window.parent.postMessage( 2036 { type: 'wsb-scroll', scrollY: window.scrollY }, 2037 '*', 2038 ); 2039 } 2040 2041 if ( 2042 e.data.type === 'wsb-apply-range-style' && 2043 savedRange && 2044 e.data.update 2045 ) { 2046 var span = document.createElement('span'); 2047 var update = e.data.update; 2048 var props = Object.keys(update); 2049 for (var pi = 0; pi < props.length; pi++) { 2050 var p = props[pi]; 2051 if (update[p]) span.style[p] = update[p]; 2052 } 2053 var previousContent = savedEditingEl 2054 ? serializeInlineContent(savedEditingEl) 2055 : null; 2056 try { 2057 savedRange.surroundContents(span); 2058 } catch (_err) { 2059 // Selection spans multiple nodes â extract and rewrap 2060 var frag = savedRange.extractContents(); 2061 span.appendChild(frag); 2062 savedRange.insertNode(span); 2063 } 2064 // Persist the updated HTML to the file 2065 if (savedEditingEl) { 2066 var wsbId = savedEditingEl.getAttribute('data-wsb-id'); 2067 if (wsbId) { 2068 editEventHandlers[EDIT_EVENTS.TEXT_CONTENT_CHANGED]({ 2069 selectedId: wsbId, 2070 content: serializeInlineContent(savedEditingEl), 2071 previousContent: previousContent, 2072 }); 2073 } 2074 } 2075 // Clear selection UI after applying 2076 var sel = window.getSelection(); 2077 if (sel) sel.removeAllRanges(); 2078 savedRange = null; 2079 savedEditingEl = null; 2080 window.parent.postMessage({ type: 'wsb-range-deselect' }, '*'); 2081 } 2082 2083 var editHandler = editEventHandlers[e.data.type]; 2084 if (typeof editHandler === 'function') { 2085 editHandler(e.data.data || e.data); 2086 } 2087 }); 2088 2089 function getProductsUrl(data) { 2090 var wpHost = data && (data.wpHost || data.wpUrl); 2091 if (typeof wpHost === 'string') { 2092 wpHost = wpHost.trim().replace(/\/+$/, ''); 2093 if (wpHost) { 2094 return wpHost + '/wp-json/wc/store/v1/products?per_page=100'; 2095 } 2096 } 2097 2098 return '/wp-json/wc/store/v1/products?per_page=100'; 2099 } 2100 2101 // Handle product fetch request from editor. Prefer project wpHost; keep the 2102 // relative fallback for local same-origin WordPress setups. 2103 window.addEventListener('message', function (e) { 2104 if (e.data && e.data.type === 'wsb-fetch-products') { 2105 fetch(getProductsUrl(e.data)) 2106 .then(function (r) { 2107 return r.ok ? r.json() : []; 2108 }) 2109 .then(function (products) { 2110 var simplified = []; 2111 for (var i = 0; i < products.length; i++) { 2112 simplified.push({ id: products[i].id, name: products[i].name }); 2113 } 2114 window.parent.postMessage( 2115 { type: 'wsb-products-response', products: simplified },
2116 '*', 2117 ); 2118 }) 2119 .catch(function () { 2120 window.parent.postMessage( 2121 { type: 'wsb-products-response', products: [] }, 2122 '*', 2123 ); 2124 }); 2125 } 2126 }); 2127 2128 // Forward Vite compile/runtime errors to the parent editor so onboarding only 2129 // reveals the homepage after the iframe has rendered a healthy app. 2130 function postViteStatus(healthy, message) { 2131 window.parent.postMessage( 2132 { 2133 type: healthy ? 'wsb-vite-ready' : 'wsb-vite-error', 2134 message: message || null, 2135 }, 2136 '*', 2137 ); 2138 } 2139 2140 function forwardHmrError(message) { 2141 window.parent.postMessage({ type: 'wsb-hmr-error', message: message }, '*'); 2142 postViteStatus(false, message); 2143 } 2144 2145 var HMR_ERROR_PATTERNS = [ 2146 'does not provide an export named', 2147 'Failed to fetch dynamically imported module', 2148 'Failed to load module script', 2149 ]; 2150 2151 function isHmrError(message) { 2152 if (!message) return false; 2153 for (var i = 0; i < HMR_ERROR_PATTERNS.length; i++) { 2154 if (message.indexOf(HMR_ERROR_PATTERNS[i]) !== -1) return true; 2155 } 2156 return false; 2157 } 2158 2159 window.addEventListener('error', function (e) { 2160 if (isHmrError(e.message)) forwardHmrError(e.message); 2161 }); 2162 2163 window.addEventListener('unhandledrejection', function (e) { 2164 var msg = e.reason && (e.reason.message || String(e.reason)); 2165 if (isHmrError(msg)) forwardHmrError(msg); 2166 }); 2167 2168 var viteStatusTimer = null; 2169 var lastViteStatusKey = null; 2170 2171 function getViteErrorOverlayMessage() { 2172 var overlay = document.querySelector('vite-error-overlay'); 2173 if (!overlay) return null; 2174 var text = overlay.textContent || ''; 2175 return text.trim() || 'Vite error overlay is visible'; 2176 } 2177 2178 function reportViteOverlayStatus() { 2179 var message = getViteErrorOverlayMessage(); 2180 var statusKey = message ? 'error:' + message : 'ready'; 2181 if (statusKey === lastViteStatusKey) return; 2182 lastViteStatusKey = statusKey; 2183 postViteStatus(!message, message); 2184 } 2185 2186 function scheduleViteOverlayStatus(delayMs) { 2187 if (viteStatusTimer !== null) window.clearTimeout(viteStatusTimer); 2188 viteStatusTimer = window.setTimeout(function () { 2189 viteStatusTimer = null; 2190 reportViteOverlayStatus(); 2191 }, delayMs); 2192 } 2193 2194 try { 2195 var viteOverlayObserver = new MutationObserver(function () { 2196 scheduleViteOverlayStatus(100); 2197 }); 2198 viteOverlayObserver.observe(document.documentElement, { 2199 childList: true, 2200 subtree: true, 2201 characterData: true, 2202 }); 2203 } catch (_err) { 2204 // MutationObserver is best-effort; load/error listeners still cover the 2205 // normal browser path. 2206 } 2207 2208 window.addEventListener('load', function () { 2209 scheduleViteOverlayStatus(500); 2210 }); 2211 scheduleViteOverlayStatus(1000); 2212 2213 var style = document.createElement('style'); 2214 style.textContent = ` 2215 body.wsb-active [data-wsb-id]:not([contenteditable='true']) { 2216 cursor: pointer !important; 2217 } 2218 [contenteditable='true'] { 2219 outline: none; 2220 } 2221 body.wsb-active *, 2222 body.wsb-active *::before, 2223 body.wsb-active *::after { 2224 transition: none !important; 2225 animation-play-state: paused !important; 2226 animation-delay: -9999s !important; 2227 animation-fill-mode: forwards !important; 2228 } 2229 body.wsb-active *:hover, 2230 body.wsb-active *:hover::before, 2231 body.wsb-active *:hover::after { 2232 transition: none !important; 2233 animation-play-state: paused !important; 2234 animation-delay: -9999s !important; 2235 animation-fill-mode: forwards !important; 2236 transform: none !important; 2237 filter: none !important; 2238 backdrop-filter: none !important; 2239 box-shadow: none !important; 2240 text-shadow: none !important; 2241 } 2242 `; 2243 document.head.appendChild(style); 2244 2245 /** 2246 * Auto-scroll: scroll to a `data-wsb-focus-now`-stamped section after 2247 * the parent posts `wsb-ai-response-complete`; MO handles late renders. 2248 */ 2249 var FOCUS_ATTR = 'data-wsb-focus-now'; 2250 var FOCUS_SELECTOR = '[' + FOCUS_ATTR + ']'; 2251 var SCROLL_PADDING_PX = 20; 2252 var HEADER_TOP_THRESHOLD_PX = 5; 2253 var SCROLL_END_FALLBACK_MS = 1500; 2254 // Tear-down budget so a missing marker doesn't leak the observer. 2255 const FOCUS_OBSERVER_TIMEOUT_MS = 10000; 2256 2257 var scrolledFocusEls = new WeakSet(); 2258 var focusFlowRunning = false;
2259 let focusObserver = null; 2260 let focusObserverTimer = null; 2261 2262 function getHeaderOffset() { 2263 var candidates = document.querySelectorAll( 2264 'header, nav, [role="banner"], [data-wsb-header]', 2265 ); 2266 var maxBottom = 0; 2267 for (var i = 0; i < candidates.length; i++) { 2268 var el = candidates[i]; 2269 if (el.hasAttribute(FOCUS_ATTR)) continue; 2270 var cs = window.getComputedStyle(el); 2271 if (cs.position !== 'fixed' && cs.position !== 'sticky') continue; 2272 var rect = el.getBoundingClientRect(); 2273 if (rect.top > HEADER_TOP_THRESHOLD_PX || rect.height <= 0) continue; 2274 if (rect.bottom > maxBottom) maxBottom = rect.bottom; 2275 } 2276 return maxBottom; 2277 } 2278 2279 // Prefers the native `scrollend` event (Chromium 114+, Firefox 109+) and 2280 // falls back to a timer so the caller always progresses on older engines. 2281 function waitForScrollEnd() { 2282 return new Promise(function (resolve) { 2283 var done = false; 2284 function finish() { 2285 if (done) return; 2286 done = true; 2287 window.removeEventListener('scrollend', finish); 2288 clearTimeout(timer); 2289 resolve(); 2290 } 2291 window.addEventListener('scrollend', finish, { once: true }); 2292 var timer = setTimeout(finish, SCROLL_END_FALLBACK_MS); 2293 }); 2294 } 2295 2296 function stripFocusAttrsFromDom() { 2297 var stuck = document.querySelectorAll(FOCUS_SELECTOR); 2298 for (var i = 0; i < stuck.length; i++) { 2299 stuck[i].removeAttribute(FOCUS_ATTR); 2300 } 2301 } 2302 2303 function pickFocusTarget() { 2304 // Last marker-bearing element in document order wins: multiple markers 2305 // may coexist if the previous turn's server sweep hadn't finished, and 2306 // freshly-inserted sections are appended last. 2307 var candidates = document.querySelectorAll(FOCUS_SELECTOR); 2308 for (var i = candidates.length - 1; i >= 0; i--) { 2309 if (!scrolledFocusEls.has(candidates[i])) return candidates[i]; 2310 } 2311 return null; 2312 } 2313 2314 function runFocusFlow(el) { 2315 if (!el || scrolledFocusEls.has(el) || focusFlowRunning) return; 2316 // Mark up-front so re-entrant mutations (React reconciliation flicker) 2317 // don't queue a second scroll for the same node. 2318 scrolledFocusEls.add(el); 2319 focusFlowRunning = true; 2320 2321 requestAnimationFrame(function () { 2322 if (!el.isConnected) { 2323 focusFlowRunning = false; 2324 return; 2325 } 2326 2327 var rect = el.getBoundingClientRect(); 2328 var targetY = 2329 rect.top + window.scrollY - getHeaderOffset() - SCROLL_PADDING_PX; 2330 if (targetY < 0) targetY = 0; 2331 window.scrollTo({ top: targetY, behavior: 'smooth' }); 2332 2333 waitForScrollEnd().then(function () { 2334 focusFlowRunning = false; 2335 // Strip from the DOM ourselves so the preview is visually clean 2336 // even if the server-side source sweep lags or fails. 2337 stripFocusAttrsFromDom(); 2338 // The server discovers affected files itself â we never send an 2339 // identifier so a compromised iframe cannot name a file to edit. 2340 window.parent.postMessage({ type: 'wsb-clear-focus-now' }, '*'); 2341 }); 2342 }); 2343 } 2344 2345 /** Disconnect the focus observer and cancel its safety timer. Idempotent. */ 2346 const teardownFocusObserver = () => { 2347 if (focusObserver) { 2348 focusObserver.disconnect(); 2349 focusObserver = null; 2350 } 2351 if (focusObserverTimer) { 2352 clearTimeout(focusObserverTimer); 2353 focusObserverTimer = null; 2354 } 2355 }; 2356 2357 /** 2358 * Find the latest marker and start the scroll. Posts `wsb-focus-hold` 2359 * first so the parent overlay holds through the scroll. 2360 * @returns {boolean} `true` if a target was found. 2361 */ 2362 const tryFocusScrollOnce = () => { 2363 const target = pickFocusTarget(); 2364 if (!target) return false; 2365 teardownFocusObserver(); 2366 window.parent.postMessage({ type: 'wsb-focus-hold' }, '*'); 2367 runFocusFlow(target); 2368 return true; 2369 }; 2370 2371 /** 2372 * Search for a focus marker. Try synchronously first (marker may 2373 * already be in the DOM); fall back to a MutationObserver bounded 2374 * by `FOCUS_OBSERVER_TIMEOUT_MS`. 2375 */ 2376 const startFocusSearch = () => { 2377 teardownFocusObserver(); 2378 if (tryFocusScrollOnce()) return; 2379 2380 focusObserver = new MutationObserver(() => {
2381 tryFocusScrollOnce(); 2382 }); 2383 focusObserver.observe(document.body, { 2384 childList: true, 2385 subtree: true, 2386 attributeFilter: [FOCUS_ATTR], 2387 }); 2388 focusObserverTimer = setTimeout( 2389 teardownFocusObserver, 2390 FOCUS_OBSERVER_TIMEOUT_MS, 2391 ); 2392 }; 2393 2394 /** 2395 * AI-turn lifecycle from the parent editor: 2396 * - `wsb-ai-response-start` resets per-turn dedupe. 2397 * - `wsb-ai-response-complete` triggers the marker search. 2398 * Foreign sources are dropped via the source check. 2399 */ 2400 window.addEventListener('message', (e) => { 2401 if (e.source !== window.parent) return; 2402 if (!e.data) return; 2403 if (e.data.type === 'wsb-ai-response-start') { 2404 scrolledFocusEls = new WeakSet(); 2405 teardownFocusObserver(); 2406 } else if (e.data.type === 'wsb-ai-response-complete') { 2407 startFocusSearch(); 2408 } 2409 }); 2410})(); 2411</script>
2411 2412 </body> 2413</html>
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.