1window.abdrivenEditorCss="/* The visual editor is a dark sidebar docked to the customer's own page, inside a shadow root, so it carries its own styles.\n Dense and aligned like a design tool's inspector: 30px controls, a two-column label/control grid, collapsible sections,\n Inter, and gold only for what has been changed. */\n:host{\n --bg:#181817;--card:#211f1e;--fill:#292927;--fill-hover:#343432;--line:#2f2f2d;--edge:#3d3d3a;--track:#0f0f0e;\n --ink:#f0f0ec;--muted:#a3a39c;--faint:#8a8a84;--gold:#d9a23e;--gold-soft:rgba(217,162,62,.16);--danger:#e0776a;\n}\n/* The frame itself has every style reset (see visual-editor.js), so the type and colours start here. */\n*{box-sizing:border-box;scrollbar-color:#454542 transparent;scrollbar-width:thin}\n.shell{font:13px/1.5 Inter,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;color:var(--ink);color-scheme:dark;font-synthesis:none;text-align:left;-webkit-font-smoothing:antialiased;position:relative;height:100%;display:flex;flex-direction:column;background:var(--bg);border-left:1px solid var(--line);box-shadow:-12px 0 40px rgba(0,0,0,.28)}\n\n/* Header and toolbar: one compact block. Only the body scrolls. */\nheader{flex:none;display:flex;align-items:center;gap:6px;padding:10px 8px 8px 12px}\n.mark{flex:none;display:flex;gap:3px;align-items:center;justify-content:center;width:26px;height:26px;margin-right:6px;border-radius:7px;background:var(--ink)}\n.mark i{width:3px;height:13px;border-radius:2px;background:var(--bg);transform:skewY(-25deg)}\n.mark i:nth-child(2){height:9px;align-self:flex-start;margin-top:6px}\n.title{min-width:0;flex:1;display:flex;align-items:center;gap:8px}\nh2{margin:0;font-size:14px;line-height:1.3;letter-spacing:-.2px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.chip{flex:none;font-size:11px;line-height:16px;padding:1px 7px;border-radius:3px;background:var(--gold-soft);color:#f1d9a3;font-weight:500}\n.bar{flex:none;display:flex;align-items:center;gap:4px;padding:0 10px 10px 8px;border-bottom:1px solid var(--line)}\n.grow{flex:1}\n#body{flex:1;min-height:0;overflow:auto;padding:0 12px 24px}\n/* Above the buttons, one quiet line: whether everything is saved, and how long this editing session lasts. */\n.status{flex:none;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border-top:1px solid var(--line);font-size:11.5px;line-height:1.4;color:var(--faint)}\n#sessionExpiry{text-align:right}\n#sessionExpiry a{color:var(--ink)}\nfooter{flex:none;display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);background:var(--bg)}\n#state{flex:none;color:var(--muted);display:flex;align-items:center;gap:7px}\n#state::before{content:'';width:7px;height:7px;border-radius:50%;background:#5fae7a}\n#state.unsaved{color:var(--ink)}\n#state.unsaved::before{background:var(--gold)}\nfooter .primary{min-width:104px}\n\n/* Messages float above the footer, so they never push the panel around. */\n#notice{position:absolute;left:12px;right:12px;bottom:98px;z-index:5;margin:0;padding:10px 12px;border-radius:6px;background:#2f2717;color:#f1d9a3;font-size:12px;line-height:1.5;overflow-wrap:anywhere;box-shadow:0 8px 24px rgba(0,0,0,.4),inset 0 0 0 1px rgba(217,162,62,.35)}\n\n/* Nothing selected yet. */\n.empty{display:grid;justify-items:center;gap:5px;text-align:center;margin-top:14px;padding:28px 20px;border-radius:6px;background:var(--card);color:var(--muted)}\n.empty svg{color:var(--ink);margin-bottom:6px}\n.empty b{color:var(--ink);font-size:13px;font-weight:600}\n.empty span{font-size:12px;line-height:1.6}\n\n/* The selected element stays in view while the settings scroll beneath it. */\nfieldset{border:0;margin:0;padding:0;min-width:0}\nfieldset:disabled{opacity:.6;pointer-events:none}\n.sticky{position:sticky;top:0;z-index:3;margin:0 -12px;padding:10px 12px 8px;background:var(--bg);border-bottom:1px solid var(--line)}\n.target{display:flex;align-items:center;gap:6px}\n.pill{flex:none;font:600 11px/16px ui-monospace,\"Cascadia Mono\",Consolas,monospace;background:var(--ink);color:var(--bg);padding:1px 7px;border-radius:3px}\n.what{flex:1;min-width:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.crumbs{display:flex;align-items:center;flex-wrap:nowrap;gap:2px;margin-top:4px;font:11px/18px ui-monospace,\"Cascadia Mono\",Consolas,monospace;color:var(--muted);overflow:hidden;white-space:nowrap}\n.crumbs b{color:var(--ink);font-weight:600}\n.crumbs .sep{opacity:.6;padding:0 1px}\n.crumb{padding:0 3px;font:inherit;font-weight:400;color:var(--muted);background:transparent;line-height:18px;min-width:0;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto}\n.crumbs b{flex:none}\n.crumb:hover:not(:disabled){color:var(--ink);background:var(--fill)}\n\n/* Collapsible sections. */\n.group{border-top:1px solid var(--line)}\n.sticky + .group{border-top:0}\nsummary{display:flex;align-items:center;gap:8px;min-height:38px;cursor:pointer;list-style:none;font-size:12px;font-weight:600;color:var(--ink);user-select:none}\nsummary::-webkit-details-marker{display:none}\n.chev{flex:none;width:14px;
1height:14px;position:relative}\n.chev::before{content:'';position:absolute;left:3px;top:2px;width:6px;height:6px;border:solid var(--muted);border-width:0 1.6px 1.6px 0;transform:rotate(-45deg);transition:transform .15s}\ndetails[open] > summary .chev::before{transform:rotate(45deg);top:1px}\nsummary:hover .chev::before{border-color:var(--ink)}\n.cnt{margin-left:auto;font-style:normal;font-size:11px;font-weight:500;color:var(--muted)}\n.cnt:not(:empty)::before{content:'';display:inline-block;width:6px;height:6px;margin-right:6px;border-radius:50%;background:var(--gold)}\n#changes .cnt::before,[data-sec=changes] .cnt::before{display:none}\n.sec{padding:0 0 14px}\n\n/* Settings: a label on the left, the control on the right. A gold dot marks what differs from the page. */\n.row{display:grid;grid-template-columns:96px minmax(0,1fr);align-items:center;gap:6px;min-height:34px;margin-bottom:2px}\n.lab{display:flex;align-items:center;gap:2px;min-width:0;font-size:12px;color:var(--muted)}\n.lab span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.row.changed .lab,.stack.changed .lab{color:var(--ink)}\n.dot{flex:none;width:14px;height:14px;padding:0;position:relative;background:transparent;visibility:hidden}\n.dot::before{content:'';position:absolute;left:4px;top:4px;width:6px;height:6px;border-radius:50%;background:var(--gold)}\nbutton.dot:hover:not(:disabled){background:var(--gold-soft);border-radius:50%}\n.changed > .lab .dot,.changed > .head .dot,.changed > .tag .dot,.tag.changed .dot{visibility:visible}\n.ctl{display:flex;align-items:center;gap:6px;min-width:0}\n.stack{margin:2px 0 8px}\n.head{display:flex;align-items:center;min-height:26px}\n.hint{font-size:12px;color:var(--muted);margin:4px 0 8px;line-height:1.6}\n.quiet{width:100%;margin-top:6px;height:30px;padding:0 10px;text-align:left;color:var(--muted);font-weight:500;background:var(--card)}\n.quiet:hover:not(:disabled){color:var(--ink)}\n\n/* Controls: all 30px tall, all the same type. */\ninput,textarea,select{\n font-size:12.5px;line-height:18px;font-family:inherit;display:block;width:100%;min-width:0;height:30px;padding:0 9px;border:1px solid var(--edge);border-radius:3px;\n background:var(--card);color:var(--ink);outline:0;\n}\ntextarea{height:auto;resize:vertical;min-height:60px;padding:7px 9px}\ninput.mono{font-family:ui-monospace,\"Cascadia Mono\",Consolas,monospace;font-size:11.5px}\ninput::placeholder,textarea::placeholder{color:var(--faint)}\ninput:hover:not(:disabled),textarea:hover:not(:disabled){border-color:#55554f}\ninput:focus,textarea:focus{border-color:var(--gold);background:#1b1a19}\ninput:disabled,textarea:disabled,select:disabled{cursor:not-allowed}\n/* A colour swatch; transparent colours show a checkerboard. */\ninput[type=color].swatch{flex:none;width:30px;padding:3px;cursor:pointer;background:conic-gradient(#3a3a37 25%,#4a4a46 0 50%,#3a3a37 0 75%,#4a4a46 0) 0 0/8px 8px}\ninput[type=color]::-webkit-color-swatch-wrapper{padding:0}\ninput[type=color]::-webkit-color-swatch{border:0;border-radius:2px}\n/* A switch for yes/no settings. */\ninput.switch{-webkit-appearance:none;appearance:none;flex:none;width:34px;height:20px;padding:0;border:0;border-radius:10px;background:var(--fill-hover);position:relative;cursor:pointer;transition:background .18s}\ninput.switch::after{content:'';position:absolute;top:3px;left:3px;width:14px;height:14px;border-radius:50%;background:#c9c9c2;transition:transform .18s,background .18s}\ninput.switch:checked{background:var(--gold)}\ninput.switch:checked::after{transform:translateX(14px);background:#181817}\n/* Dropdowns: a soft fill and a chevron, no outline, like every other dropdown in the product. */\nselect{\n -webkit-appearance:none;appearance:none;padding-right:30px;cursor:pointer;border-color:transparent;background-color:var(--fill);white-space:nowrap;text-overflow:ellipsis;\n background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='%23f0f0ec' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1.5 1.5 6 6l4.5-4.5'/%3E%3C/svg%3E\");\n background-repeat:no-repeat;background-position:right 10px center;background-size:10px 7px;\n}\nselect:hover:not(:disabled){background-color:var(--fill-hover)}\n\nbutton{font-size:12.5px;font-weight:600;line-height:18px;font-family:inherit;border:0;border-radius:3px;background:var(--fill);color:var(--ink);padding:6px 12px;cursor:pointer}\nbutton:hover:not(:disabled){background:var(--fill-hover)}\nbutton:disabled{opacity:.4;cursor:default}\n.icon{flex:none;width:30px;height:30px;padding:0;display:grid;place-items:center;font-size:16px;line-height:1;color:var(--muted);background:transparent;text-decoration:none;border-radius:3px}\n.icon:hover:not(:disabled){background:var(--fill);color:var(--ink)}\n.primary{height:34px;background:var(--ink);color:var(--bg)}\n.primary:hover:not(:disabled){background:#fff}\n\n/* Two-way switches in the toolbar. */\n.seg{display:flex;gap:2px;padding:2px;background:var(--track);border-radius:5px}\n.seg button{height:26px;padding:0 9px;background:transparent;color:var(--muted);font-size:12px;font-weight:500;border-radius:3px}\n.seg button:hover:not(:disabled){color:var(--ink);background:transparent}\n.seg button.active{background:var(--fill-hover);color:var(--ink);font-weight:600}\n#viewOriginal.active{background:var(--gold);color:#181817}\n\n/* Alignment as icons. */\n.icons{display:flex;gap:2px;padding:2px;background:var(--track);border-radius:5px}\n.ic{width:34px;height:26px;padding:0;display:grid;place-items:center;background:transparent;color:var(--muted);border-radius:3px}\n.ic:hover:not(:disabled){color:var(--ink);background:transparent}\n.ic.on{background:var(--fill-hover);color:var(--ink)}\n.ic.on.inherited{color:var(--muted)}\n\n/* Margin and padding as nested boxes, like a browser's box-model diagram. */\n.box{position:relative;border-radius:6px;padding:20px 8px 6px;background:#1d1d1c;box-shadow:inset 0 0 0 1px var(--edge)}\n.box.inner{margin:2px 30px;background:#242423}\n.tag{position:absolute;top:3px;left:6px;display:flex;align-items:center;font-size:9.5px;font-weight:600;letter-spacing:.9px;text-transform:uppercase;color:var(--muted)}\n.tag .dot{width:12px}\n.tag .dot::before{left:3px}\n.c{display:flex;justify-content:center}\n.bxrow{display:flex;align-items:center;justify-content:space-between}\n.core{flex:1;height:14px;margin:0 4px;border-radius:3px;background:var(--track)}\ninput.bx{width:38px;height:24px;padding:0;text-align:center;font-size:12px;background:transparent;border-color:transparent}\ninput.bx:hover:not(:disabled){border-color:var(--edge)}\ninput.bx:focus{background:var(--bg)}\n\n/* The list of changes, one row per element. */\nol{padding:0;margin:0;list-style:none;display:grid;gap:4px}\nli{display:flex;align-items:stretch;gap:2px}\nli.none{color:var(--muted);font-size:12px;padding:2px 0 4px}\n.change{flex:1;min-width:0;display:grid;gap:4px;text-align:left;padding:8px 10px;background:var(--card);font-weight:400}\n.change:hover:not(:disabled){background:var(--fill)}\n.change.on{box-shadow:inset 2px 0 0 var(--gold)}\n.change b{font-weight:600;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.tags{display:flex;flex-wrap:wrap;gap:4px}\n.tags i{display:inline-flex;align-items:center;gap:5px;font-style:normal;font-size:11px;line-height:16px;padding:1px 6px;border-radius:3px;background:var(--fill-hover);color:#d8d8d2;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.sw{flex:none;width:9px;height:9px;border-radius:2px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.35)}\nli > .icon{height:auto;width:28px;align-self:stretch;background:var(--card)}\nli > .icon:hover:not(:disabled){background:#3a2522;color:var(--danger)}\n\n.back{display:block;text-align:center;margin:14px 0 0;color:var(--ink)}\nbutton:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--gold);outline-offset:1px}\n[hidden]{display:none!important}\n\n/* Collapsed: a slim rail that only offers to reopen the editor. */\n:h
1ost([data-collapsed]) .shell > :not(header){display:none}\n:host([data-collapsed]) header{padding:12px 0;flex-direction:column;justify-content:flex-start}\n:host([data-collapsed]) .title,:host([data-collapsed]) header a{display:none}\n:host([data-collapsed]) .mark{margin:0}\n\n/* On a phone the editor is a sheet along the bottom of the screen. */\n@media(max-width:719px){\n .shell{border-left:0;border-top:1px solid var(--line);border-radius:14px 14px 0 0;box-shadow:0 -12px 40px rgba(0,0,0,.3)}\n :host([data-collapsed]) header{flex-direction:row;padding:10px 14px}\n :host([data-collapsed]) .title{display:flex}\n .row{grid-template-columns:88px minmax(0,1fr)}\n #notice{bottom:58px}\n}\n\n/* Chromium's customisable select: the open list is a dark flat card, like the dropdowns in the dashboard. */\n@supports (appearance:base-select){\n select,::picker(select){-webkit-appearance:base-select;appearance:base-select}\n select{background-image:none;display:flex;align-items:center;justify-content:space-between}\n select::picker-icon{content:'';flex:none;width:7px;height:7px;margin-left:8px;border:solid var(--ink);border-width:0 1.8px 1.8px 0;transform:translateY(-2px) rotate(45deg)}\n select:open::picker-icon{transform:translateY(2px) rotate(225deg)}\n ::picker(select){padding:4px;margin-block:4px;border:1px solid var(--edge);border-radius:3px;background:#1f1f1e;box-shadow:0 14px 40px rgba(0,0,0,.45);outline:0}\n select option{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:2px;font-size:13px;color:var(--ink);cursor:pointer}\n select option:hover,select option:focus-visible{background:var(--fill-hover);outline:0}\n select option:checked{background:var(--gold-soft);color:#f1d9a3;font-weight:600}\n select option::checkmark{color:var(--gold);order:1;margin-left:auto}\n}\n@media(prefers-reduced-motion:reduce){*{transition:none!important}}\n\n/* A change made on another page shows that page; choosing it goes there, and the editor comes along. */\n#changelist .where{display:block;margin:1px 0 3px;font:11px/1.4 ui-monospace,\"Cascadia Mono\",Consolas,monospace;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n#changelist li.elsewhere .change b{color:var(--muted);font-weight:500}\n/* A page the experiment does not run on: nothing to edit here. */\n.empty.outside{border:1px solid rgba(217,162,62,.35);background:var(--gold-soft)}\n.empty.outside b{color:#f1d9a3}\n\n/* Placing a section: the choice of position next to the selected element. */\n.empty.placing{border:1px solid rgba(217,162,62,.45);background:var(--gold-soft)}\n.empty.placing b{color:#f1d9a3}\n.place-choices{display:grid;gap:6px;width:100%;margin:10px 0 4px}\n.place-choices button{width:100%;padding:9px 10px;border-radius:6px;background:var(--gold);color:#1b1a18;font-weight:600}\n.place-choices button:hover{background:#e6b457}\n\n/* A placed section: its settings and blocks, where it sits, and images to upload or choose. */\n.sub{margin:12px 0 6px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--faint)}\n.seg.wide{width:100%}\n.seg.wide button{flex:1}\n.hint code{font:11px/1.5 ui-monospace,\"Cascadia Mono\",Consolas,monospace;color:var(--ink);word-break:break-all}\n.quiet.danger{color:var(--danger)}\n.quiet.danger:hover:not(:disabled){color:#f09a8f}\n.blk{margin:0 0 10px;padding:8px 10px 4px;border:1px solid var(--line);border-radius:6px;background:var(--card)}\n.blkhead{display:flex;align-items:center;gap:2px;margin-bottom:4px}\n.blkhead b{flex:1;min-width:0;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.addblk select{flex:1;min-width:0}\n.imgpick{display:flex;gap:8px;align-items:flex-start;margin-bottom:6px}\n.thumb{flex:none;width:52px;height:52px;border-radius:5px;overflow:hidden;background:var(--track) linear-gradient(135deg,transparent 46%,var(--edge) 47%,var(--edge) 53%,transparent 54%);border:1px solid var(--line)}\n.thumb img{display:block;width:100%;height:100%;object-fit:cover}\n.imgctl{display:grid;gap:6px;flex:1;min-width:0}\n.imgrow{display:flex;gap:6px}\n.upload{position:relative;display:inline-flex;align-items:center;justify-content:center;flex:1;height:28px;padding:0 10px;border-radius:3px;background:var(--fill);color:var(--ink);font-size:12px;font-weight:600;cursor:pointer;overflow:hidden}\n.upload:hover{background:var(--fill-hover)}\n.upload input{position:absolute;inset:0;opacity:0;cursor:pointer}\n.upload.busy{opacity:.55;pointer-events:none}\n"; 2// The visual editor: a dark sidebar docked to the right of the customer's own page. Click anything on the page and its 3// settings appear in the sidebar, ready to edit. Edits show on the page as they are made and are kept as changes; nothing 4// needs applying. It runs inside a shadow root so the site's CSS cannot touch it. 5(async function () { 6 if (document.querySelector('#split-editor-host')) return; 7 const script = document.currentScript, base_url = script.dataset.base, site = script.dataset.site; 8 const sdk = window.abdriven || window.SplitTest, visual = window.abdrivenVisual || window.SplitVisual; 9 let token = script.dataset.preview || new URLSearchParams(location.search).get('split_preview'); delete script.dataset.preview; 10 const placeId = script.dataset.place || ''; delete script.dataset.place; 11 const lifetime = new AbortController(); 12 let info = null; 13 const listen = (target, type, callback, options = {}) => target.addEventListener(type, callback, { ...(typeof options === 'boolean' ? { capture: options } : options), signal: lifetime.signal }); 14 const html = document.documentElement; 15 const margins = ['margin-right', 'margin-bottom'].map(name => [name, html.style.getPropertyValue(name), html.style.getPropertyPriority(name)]); 16 // The editor's frame is an element of its own kind with every page style reset and its own settings marked important: a shop 17 // theme's rules for divs (Shopify's Dawn hides every empty div, and this one only has a shadow root) cannot hide or reshape it. 18 const host = document.createElement('abdriven-editor'); 19 const pin = (el, css) => { el.style.cssText = 'all:initial !important;' + css.split(';
19').filter(Boolean).map(d => d + ' !important').join(';'); }; 20 function restoreFrame() { lifetime.abort(); host.remove(); makeRoom(0); for (const [name, value, priority] of margins) { if (value) html.style.setProperty(name,value,priority); else html.style.removeProperty(name); } } 21 host.id = 'split-editor-host'; 22 document.body.append(host); 23 // Cookie banners, cart drawers, chat and trust badges often claim the highest z-index as well (a German shop easily has four). 24 // The browser's top layer sits above all of them, so the editor goes there where the browser supports it. 25 const onTop = () => { try { if (host.showPopover && host.isConnected && !host.matches(':popover-open')) { host.popover = 'manual'; host.showPopover(); } } catch { /* the z-index remains */ } }; 26 // Fixed bars the width of the window (a header, a notice) would run under the editor: while it is open they end where it 27 // starts. What they had is put back when it closes. 28 const squeezed = new Map(); 29 function makeRoom(width) { 30 for (const [el, [value, priority]] of squeezed) { if (value) el.style.setProperty('max-width', value, priority); else el.style.removeProperty('max-width'); } 31 squeezed.clear(); 32 if (!width) return; 33 for (const el of document.body.querySelectorAll('*')) { 34 if (el === host || el.id === 'split-preview-status' || el.tagName.startsWith('ABDRIVEN-')) continue; 35 const box = el.getBoundingClientRect(); 36 if (box.width < innerWidth * 0.5 || box.right <= innerWidth - width || getComputedStyle(el).position !== 'fixed') continue; 37 squeezed.set(el, [el.style.getPropertyValue('max-width'), el.style.getPropertyPriority('max-width')]); 38 el.style.setProperty('max-width', `calc(100% - ${width}px)`, 'important'); 39 } 40 } 41 const root = host.attachShadow({ mode: 'open' }); 42 // The server puts the stylesheet in front of this script; applied as a constructed sheet, neither a tracking gateway that 43 // forwards only scripts nor the shop's content security policy can keep it away. The link is for older browsers. 44 let styled = false; 45 if (typeof window.abdrivenEditorCss === 'string' && 'adoptedStyleSheets' in root) { try { const own = new CSSStyleSheet(); own.replaceSync(window.abdrivenEditorCss); root.adoptedStyleSheets = [own]; styled = true; } catch { /* fall back to the link */ } } 46 if (!styled) { const sheet = document.createElement('link'); sheet.rel = 'stylesheet'; sheet.href = base_url + '/visual-editor.css'; root.append(sheet); } 47 const shell = document.createElement('div'); shell.className = 'shell'; root.append(shell); 48 49 const newId = () => 'op_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join(''); 50 const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); 51 const $ = selector => root.querySelector(selector); 52 // Rendering and uploading for sections are newer than some tracking gateways; when the website's gateway does not forward 53 // them yet (it then refuses the request), they go to abdriven directly. 54 async function api(path, data, origin = base_url) { 55 const direct = ['/render', '/asset'].includes(path) && info?.dashboard && origin !== info.dashboard ? info.dashboard : ''; 56 let r; 57 try { r = await fetch(`${origin}/sdk/editor${path}?site=${encodeURIComponent(site)}&preview=${encodeURIComponent(token)}`, { method: data ? 'POST' : 'GET', credentials: 'omit', headers: data ? { 'Content-Type': 'application/json' } : {}, body: data ? JSON.stringify(data) : undefined }); } 58 catch { if (direct) return api(path, data, direct); throw Error('abdriven could not be reached. Check the connection and try again.'); } 59 if (direct && r.status === 404 && !/json/.test(r.headers.get('content-type') || '')) return api(path, data, direct); 60 const out = await r.json().catch(() => ({ error: `abdriven could not be reached (${r.status}). Try again in a moment.` })); if (!r.ok) throw Object.assign(Error(out.error), { details: out.details }); return out; 61 } 62 const BRAND = '<span class="mark"><i></i><i></i><i></i></span>'; 63 const ICON = { 64 undo: '<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4 3.5 7.5 7 11"/><path d="M4 7.5h7.5a4.5 4.5 0 0 1 0 9H8"/></svg>', 65 redo: '<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M13 4l3.5 3.5L13 11"/><path d="M16 7.5H8.5a4.5 4.5 0 0 0 0 9H12"/></svg>', 66 left: '<path d="M3 5h14M3 9h9M3 13h14M3 17h9"/>', center: '<path d="M3 5h14M6 9h8M3 13h14M6 17h8"/>', right: '<path d="M3 5h14M8 9h9M3 13h14M8 17h9"/>', justify: '<path d="M3 5h14M3 9h14M3 13h14M3 17h14"/>', 67 }; 68 69 let ready = false;
70 const narrow = () => innerWidth < 720; 71 // ---- Docking: the page gives up the width (or, on a phone, the height) the editor uses ---- 72 function layout() { 73 const collapsed = host.hasAttribute('data-collapsed'); 74 const w = collapsed ? 56 : 340, h = collapsed ? 56 : Math.round(innerHeight * 0.6); 75 pin(host, 'display:block;position:fixed;z-index:2147483647;' + (narrow() ? `left:0;right:0;bottom:0;height:${h}px` : `top:0;right:0;bottom:0;width:${w}px`)); 76 html.style.setProperty('margin-right', narrow() ? '' : w + 'px', 'important'); 77 html.style.setProperty('margin-bottom', narrow() ? h + 'px' : '', 'important'); 78 onTop(); makeRoom(narrow() ? 0 : w); 79 const banner = document.querySelector('#split-preview-status'); 80 if (banner) { banner.style.right = (narrow() ? 12 : w + 12) + 'px'; banner.style.bottom = (narrow() ? h + 12 : 12) + 'px'; } 81 requestAnimationFrame(() => { if (ready) refreshBoxes(); }); 82 } 83 layout(); 84 listen(window, 'resize', layout); 85 86 // The session and any unsaved edits are kept in this tab (sessionStorage), so the editor follows the person to other pages 87 // of the website. Closing the editor, or a session that no longer works, clears both. 88 const sessionKey = `split:${site}:editor`, draftKey = `split:${site}:editor-draft`; 89 const keep = (key, data) => { try { if (data === null) sessionStorage.removeItem(key); else sessionStorage.setItem(key, JSON.stringify(data)); } catch { /* blocked storage: the editor then stays on this page */ } }; 90 const kept = key => { try { return JSON.parse(sessionStorage.getItem(key) || 'null'); } catch { return null; } }; 91 // The way back to abdriven. The snippet may load through the website's own tracking subdomain, which only forwards the SDK, 92 // so the dashboard's address comes from the server. 93 try { info = await api(''); } catch (e) { keep(sessionKey, null); keep(draftKey, null); shell.innerHTML = `<header>${BRAND}<div class="title"><h2>Editor unavailable</h2></div></header><div id="body"><p class="hint">${esc(e.message)}</p><a class="back" href="${esc(e.details?.dashboard || base_url)}/app">Return to abdriven</a><button id="closeUnavailable">Close editor</button></div>`; listen($('#closeUnavailable'), 'click', restoreFrame); return; } 94 const dashboard = info.dashboard || base_url, testLink = `${dashboard}/app#experiment/${info.experiment_id}`; 95 96 // ---- State ---- 97 keep(sessionKey, { token, expires: info.expires }); 98 let ops = structuredClone(info.visual), saved = JSON.stringify(ops), undoStack = [], redoStack = [], lastKey = null, lastAt = 0; 99 // Unsaved edits from another page of this session come along, unless the draft was saved elsewhere in the meantime. 100 const draft = kept(draftKey); 101 if (draft?.experiment_id === info.experiment_id && draft.variant === info.variant && draft.base === saved && Array.isArray(draft.ops)) ops = draft.ops; else keep(draftKey, null); 102 const remember = () => keep(draftKey, saved !== JSON.stringify(ops) ? { experiment_id: info.experiment_id, variant: info.variant, base: saved, ops } : null); 103 // Each change belongs to the page it was made on (the snippet decides which pages that covers). On a page the experiment 104 // does not run on, visitors would never see a change, so editing waits until the person browses to one of its pages. 105 const here = o => (sdk._visualHere ? sdk._visualHere(o) : true); 106 const outside = sdk._pageInTest ? !sdk._pageInTest() : false; 107 const describeRules = () => (info.page_rules || []).map(r => r.match_type === 'all' ? 'every page' : r.match_type === 'prefix' ? `pages starting with ${r.value}` : r.value).join(', ') || 'its pages'; 108 let selected = null, selector = '', base = null, picking = !outside, compare = false, saving = false, linkKind = 'url', chain = []; 109 // A test started from a section: the editor opens ready to place it, so only the position is left to choose. 110 let placing = placeId ? info.sections.find(s => s.id === placeId) || null : null; 111 const openSection = new Map(); 112 // A placed section being edited: its change id, the values shown in the panel and its schema (which fields it has). 113 let sectionEdit = null, renderTimer = 0, renderRun = 0, renderNow = null; 114 const schemas = new Map(); 115 // What a placed section keeps: its rendered result and, for a library section, where it came from and the values it has. 116 const placedSection = s => s.kind === 'liquid' ? { name: s.name, kind: 'liquid', html: s.html || '', css: s.css || '', ...(s.id ? { source: { id: s.id, settings: s.settings || {}
116, blocks: s.blocks || [] } } : {}) } : s; 117 const sectionNode = id => document.querySelector(`[data-split-op="${CSS.escape(id)}"]`); 118 const SIDES = ['top', 'right', 'bottom', 'left']; 119 const STYLE_LABEL = { color: 'Text color', 'background-color': 'Background', 'font-size': 'Font size', padding: 'Padding', margin: 'Margin', 'border-radius': 'Corner radius', 'max-width': 'Max width', 'text-align': 'Alignment' }; 120 const SIZED = ['font-size', 'border-radius', 'max-width']; 121 const LINK_KINDS = { url: ['Web address', 'https://example.com'], path: ['Page on this site', '/pricing'], section: ['Section on this page', ''], email: ['Email address', '[email protected]'], phone: ['Phone number', '+1 555 0100'] }; 122 const find = (type, property) => ops.find(o => o.selector === selector && o.type === type && (property === undefined || o.property === property) && here(o)); 123 const value = f => root.querySelector(`[data-f="${f}"]`)?.value ?? ''; 124 125 // ---- Outlines drawn over the page: a dashed one under the pointer, a solid one on the selection ---- 126 // They are protected from the theme's styles in the same way as the editor itself. 127 function outline(solid) { 128 const box = document.createElement('abdriven-outline'), label = document.createElement('abdriven-outline-label'); 129 const look = `position:fixed;pointer-events:none;z-index:2147483645;box-sizing:border-box;border:${solid ? '2px solid' : '1.5px dashed'} #d9a23e;background:rgba(217,162,62,${solid ? '.12' : '.07'});border-radius:4px`; 130 const show = (visible, b = { left: 0, top: 0, width: 0, height: 0 }) => pin(box, `${look};display:${visible ? 'block' : 'none'};left:${b.left}px;top:${b.top}px;width:${b.width}px;height:${b.height}px`); 131 const tag = above => pin(label, `display:block;position:absolute;left:-2px;${above ? 'bottom:100%;margin:0 0 5px' : 'top:100%;margin:5px 0 0'};white-space:nowrap;font:600 11px/1 Inter,-apple-system,"Segoe UI",system-ui,sans-serif;color:#f0f0ec;background:#161615;padding:5px 8px;border-radius:4px;max-width:320px;overflow:hidden;text-overflow:ellipsis`); 132 show(false); tag(true); 133 if (solid) box.append(label); 134 document.body.append(box); return { box, label, show, tag, hide: () => show(false) }; 135 } 136 const hover = outline(false), chosen = outline(true); 137 function place({ show, label, tag }, el) { 138 const b = el.getBoundingClientRect(); 139 show(true, b); 140 label.textContent = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (b.width && b.height ? ' ' + Math.round(b.width) + 'Ã' + Math.round(b.height) : ''); 141 tag(b.top > 34); 142 } 143 function refreshBoxes() { 144 if (sectionEdit && !selected?.isConnected) selected = sectionNode(sectionEdit.opId) || selected; 145 if (selected?.isConnected && !compare) place(chosen, selected); else chosen.hide(); 146 } 147 listen(window, 'scroll', () => requestAnimationFrame(refreshBoxes), true); 148 // Bring an element into the part of the page the editor is not covering. 149 function reveal(el) { 150 const r = el.getBoundingClientRect(), covered = narrow() ? host.getBoundingClientRect().top : innerHeight; 151 if (r.top < 0 || r.bottom > covered - 8) window.scrollBy({ top: r.top - Math.max(24, (covered - r.height) / 3), behavior: 'smooth' }); 152 } 153 154 // ---- Helpers for values ---- 155 const snap = el => { 156 const cs = getComputedStyle(el), css = {}; 157 for (const p of Object.keys(STYLE_LABEL)) css[p] = cs.getPropertyValue(p); 158 for (const p of ['margin', 'padding']) for (const s of SIDES) css[`${p}-${s}`] = cs.getPropertyValue(`${p}-${s}`); 159 return { text: el.children.length ? null : el.textContent, href: el.getAttribute('href') || '', blank: el.target === '_blank', src: el.currentSrc || el.src || '', alt: el.alt || '', css }; 160 }; 161 // What an element is on the page with none of the visual edits, however many have been made. 162 function baseline(el) { 163 let out; try { sdk._visualApply([]); out = snap(el); } finally { try { sdk._visualApply(compare ? [] : ops); } catch { /* the page keeps its last state */ } } 164 return out; 165 } 166 const toHex = css => { try { const c = document.createElement('canvas').getContext('2d'); c.fillStyle = '#000000'; c.fillStyle = css; return c.fillStyle.startsWith('#') ? c.fillStyle : '#000000'; } catch { return '#000000'; } }; 167 const pageIds = () => [...document.querySelectorAll('[id]')].filter(e => !e.closest('#split-editor-host') && e.id && !/\s/.test(e.id)).map(e => e.id).slice(0, 200); 168 const kindOf = href => /^mailto:/i.test(href) ? 'email' : /^tel:/i.test(href) ? 'phone' : /^https?:/i.test(href) ? 'url' : href.startsWith('#') ? 'section' : 'path'; 169 const hrefField = href => linkKind === 'section' ? (href.startsWith('#') ? href.slice(1) : '') : linkKind === kindOf(href) ? href.replace(/^(mailto|tel):/i, '') : ''; 170 function composeHref(kind, raw) { 171 raw = raw.trim(); if (!raw) return ''; 172 if (kind === 'email') return 'mailto:' + raw.replace(/^mailto:/i, ''); 173 if (kind === 'phone') return 'tel:' + raw.replace(/^tel:/i, '').replace(/\s+/g, ''); 174 if (kind === 'section') return '#' + raw.replace(/^#/, ''); 175 if (kind === 'path') return /^[/#?.]/.test(raw) ? raw : '/' + raw; 176 return /^https?:/i.test(raw) ? raw : 'https://' + raw; 177 } 178 // Theme templates indent their text; unless the element keeps white space as written, it is shown and compared tidied. 179 const tidy = (el, t) => t === null || /^(pre|pre-wrap|pre-line|break-spaces)$/.test(getComputedStyle(el).whiteSpace) ? t : t.replace(/\s+/g, ' ').trim(); 180 // Computed sizes often have many decimals (12.895px); the fields show them to one decimal. 181 const short = v =>
181 String(v || '').replace(/(\d+\.\d)\d+/g, '$1'); 182 // Saved values are shown as they are (they are written back); only the page's own values in the placeholders are rounded. 183 const bare = v => String(v || '').replace(/px$/, ''), plain = v => short(bare(v)); 184 const unit = v => /^-?\d+(\.\d+)?$/.test(v) ? v + 'px' : v; 185 // "10px 20px" -> the four sides, so the box widget can show what an existing change says. 186 function sidesOf(v) { const t = (v || '').trim().split(/\s+/).filter(Boolean); if (!t.length) return ['', '', '', '']; const [a, b = a, c = a, d = b] = t; return [a, b, c, d]; } 187 188 // ---- The panel ---- 189 function render() { 190 shell.innerHTML = `<header>${BRAND}<div class="title"><h2>${esc(info.name)}</h2><span class="chip">Variation ${esc(info.variant)}</span></div><a class="icon" href="${esc(testLink)}" aria-label="Return to this experiment" title="Open abdriven">â</a><button id="collapse" class="icon" aria-label="Collapse editor" title="Collapse">»</button></header> 191<div class="bar"><button id="undo" class="icon" aria-label="Undo" title="Undo (Ctrl+Z)">
191${ICON.undo}</button><button id="redo" class="icon" aria-label="Redo" title="Redo (Ctrl+Shift+Z)">${ICON.redo}</button><span class="grow"></span><div class="seg" role="group" aria-label="Pointer mode"><button id="pick" class="${picking ? 'active' : ''}" title="Click elements on the page to edit them">Edit</button><button id="browse" class="${picking ? '' : 'active'}" title="Use the page normally">Browse</button></div><div class="seg" role="group" aria-label="Page view"><button id="viewEdited" class="active" title="Show the page with your visual edits">Edited</button><button id="viewOriginal" title="Show the page without your visual edits">Original</button></div></div> 192<div id="body"><div id="inspector"></div><details class="group" data-sec="changes" open><summary><span class="chev"></span><span>Changes</span><em id="count" class="cnt"></em></summary><div class="sec"><ol id="changelist"></ol></div></details></div> 193<p id="notice" role="status" hidden></p> 194<div class="status"><span id="state"></span><span id="sessionExpiry"></span></div><footer><button id="exitEditor">Close editor</button><span class="grow"></span><button id="save">Save draft</button><button id="saveClose" class="primary">Save & close</button></footer>`; 195 inspect(); list(); state(); 196 } 197 const notify = text => { const n = $('#notice'); if (!n) return; n.textContent = text; n.hidden = !text; }; 198 const dirty = () => saved !== JSON.stringify(ops); 199 function closeEditor(back = false) { saved = JSON.stringify(ops); keep(sessionKey, null); keep(draftKey, null); window.abdriven.revoke(); hover.box.remove(); chosen.box.remove(); ready = false; restoreFrame(); if (back) location.assign(testLink); } 200 function state() { 201 $('#undo').disabled = !undoStack.length || compare; $('#redo').disabled = !redoStack.length || compare; 202 const expired = Date.now() >= info.expires; 203 $('#saveClose').disabled = saving || expired; 204 $('#sessionExpiry').innerHTML = expired ? `Session expired. <a href="${esc(testLink)}">Open a new editor session in abdriven</a>` : `Session until ${new Date(info.expires).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; 205 $('#save').disabled = saving || expired || !dirty(); $('#save').textContent = saving ? 'Savingâ¦' : 'Save draft'; 206 const s = $('#state'); s.textContent = dirty() ? 'Unsaved changes' : 'All changes saved'; s.classList.toggle('unsaved', dirty()); 207 } 208 const describe = o => ({ text: 'Text', link: 'Link â ' + o.href, image: 'Image', style: `${STYLE_LABEL[o.property] || o.property}: ${o.value}`, hide: 'Hidden', move: 'Moved', section: 'Section: ' + (o.section?.name || '') }[o.type] || o.type); 209 // Changes are grouped by element; those of another page are listed with that page, and choosing one goes there. 210 const keyOf = o => JSON.stringify([here(o) ? '' : o.page || '', o.selector, ...(o.type === 'section' ? [o.id] : [])]); 211 function list() { 212 const groups = new Map(); for (const o of ops) { const key = keyOf(o); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(o); } 213 $('#changelist').innerHTML = [...groups].map(([key, group]) => { 214 const sel = group[0].selector, page = here(group[0]) ? '' : group[0].page; 215 let el = null; if (!page) { try { el = document.querySelector(sel); } catch { /* an invalid selector cannot be shown */ } } 216 const placed = group[0].type === 'section' ? group[0] : null; 217 const title = placed ? placed.section?.name || 'Section' : page ? sel.split('>').pop().trim() : (el?.textContent.trim().replace(/\s+/g, ' ').slice(0, 34)) || el?.tagName.toLowerCase() || sel; 218 const tags = group.map(o => `<i>${o.type === 'style' && /color/.test(o.property) ? `<span class="sw" data-sw="${esc(o.value)}"></span>` : ''}${esc(placed ? 'Section' : describe(o))}</i>`).join(''); 219 const target = page ? `data-page="${esc(page)}" title="Go to ${esc(page)} to edit"` : placed ? `data-op="${esc(placed.id)}" title="Edit this section"` : `data-select="${esc(sel)}" title="${esc(sel)}"`; 220 const on = placed ? sectionEdit?.opId === placed.id : !sectionEdit && sel === selector && selected; 221 return `<li${page ? ' class="elsewhere"' : ''}><button class="change${!page && on ? ' on' : ''}" ${target}><b>${esc(title)}</b>${page ? `<span class="where">${esc(page)}</span>` : ''}<span class="tags">${tags}</span></button><button class="icon" data-remove="${esc(key)}" aria-label="Remove the changes to this element" title="Remove these changes">Ã</button></li>`; 222 }).join('') || '<li class="none">No changes yet. Click something on the page.</li>'; 223 root.querySelectorAll('[data-sw]').forEach(n => { n.style.background = n.dataset.sw; }); // set here, not inline, so a strict CSP cannot block it 224 $('#count').textContent = groups.size ? `${groups.size} element${groups.size === 1 ? '' : 's'} · ${ops.length} edit${ops.length === 1 ? '' : 's'}` : ''; 225 } 226 227 // ---- The inspector: the settings of whatever is selected ---- 228 const dot = key => key ? `<button class="dot" data-reset="${key}" title="Changed. Click to undo this change" aria-label="Undo this change"></button>` : '<span class="dot"></span>';
229 const row = (key, label, control) => `<div class="row" data-row="${key || ''}"><span class="lab">${dot(key)}<span>${label}</span></span><div class="ctl">${control}</div></div>`; 230 const stack = (key, label, control) => `<div class="stack" data-row="${key}"><div class="head"><span class="lab">${dot(key)}<span>${label}</span></span></div>${control}</div>`; 231 const section = (id, title, body) => `<details class="group" data-sec="${id}" ${(openSection.has(id) ? openSection.get(id) : id !== 'advanced') ? 'open' : ''}><summary><span class="chev"></span><span>${title}</span><em class="cnt"></em></summary><div class="sec">${body}</div></details>`; 232 const styleField = (p, kind = 'text') => { 233 const v = find('style', p)?.value || '', ph = short(base.css[p] || ''); 234 return kind === 'color' 235 ? `<input type="color" class="swatch" data-f="swatch:${p}" value="${toHex(v || ph)}" aria-label="${STYLE_LABEL[p]} picker"><input data-f="style:${p}" value="${esc(v)}" placeholder="${esc(ph)}" autocomplete="off" spellcheck="false">` 236 : `<input data-f="style:${p}" value="${esc(v)}" placeholder="${esc(ph)}" autocomplete="off" spellcheck="false">`; 237 }; 238 function boxWidget() { 239 const side = (prop, s, i) => `<input class="bx" data-f="side:${prop}:${s}" value="${esc(bare(sidesOf(find('style', prop)?.value)[i]))}" placeholder="${esc(plain(base.css[`${prop}-${s}`]))}" aria-label="${prop} ${s}" autocomplete="off" spellcheck="false">`; 240 const tag = (key, label) => `<span class="tag" data-row="${key}">${dot(key)}${label}</span>`; 241 return `<div class="box outer">${tag('style:margin', 'Margin')}<div class="c">${side('margin', 'top', 0)}</div><div class="bxrow">${side('margin', 'left', 3)}<div class="box inner">${tag('style:padding', 'Padding')}<div class="c">${side('padding', 'top', 0)}</div><div class="bxrow">${side('padding', 'left', 3)}<span class="core"></span>${side('padding', 'right', 1)}</div><div class="c">${side('padding', 'bottom', 2)}</div></div>${side('margin', 'right', 1)}</div><div class="c">${side('margin', 'bottom', 2)}</div></div>`; 242 } 243 function alignControl() { 244 const set = find('style', 'text-align')?.value, now = set || ({ start: 'left', end: 'right' }[base.css['text-align']] || base.css['text-align']); 245 return `<div class="icons" role="group" aria-label="Alignment">${['left', 'center', 'right', 'justify'].map(a => `<button type="button" class="ic${a === now ? ' on' : ''}${a === now && !set ? ' inherited' : ''}" data-align="${a}" aria-pressed="${a === now}" title="Align ${a}"><svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">${ICON[a]}</svg></button>`).join('')}</div>`; 246 } 247 const crumbLabel = e => e.tagName.toLowerCase() + (e.id ? '#' + e.id : ''); 248 // The fields of a placed section, built from its schema like the dialog in abdriven: texts, colours, choices and images. 249 const IMAGE_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; 250 function sectionField(setting, value, path) { 251 const label = esc(setting.label || setting.id || ''), f = `data-sf="${esc(path)}"`; 252 if (setting.type === 'header') return `<p class="sub">${esc(setting.content || '')}</p>`; 253 if (setting.type === 'paragraph') return `<p class="hint">${esc(setting.content || '')}</p>`; 254 if (!setting.id) return ''; 255 if (setting.type === 'checkbox') return row('', label, `<input type="checkbox" class="switch" ${f} aria-label="${label}" ${value ? 'checked' : ''}>`); 256 if (['select', 'radio'].includes(setting.type)) return row('', label, `<select ${f}>${(setting.options || []).map(o => `<option value="${esc(o.value)}" ${String(o.value) === String(value) ? 'selected' : ''}>${esc(o.label || o.value)}</option>`).join('')}</select>`); 257 if (['range', 'number'].includes(setting.type)) return row('', label + (setting.unit ? ` (${esc(setting.unit)})` : ''), `<input type="number" ${f} value="${esc(value ?? '')}"${setting.min !== undefined ? ` min="${esc(setting.min)}"` : ''}${setting.max !== undefined ? ` max="${esc(setting.max)}"` : ''}${setting.step !== undefined ? ` step="${esc(setting.step)}"` : ''}>`); 258 if (['color', 'color_background'].includes(setting.type)) return row('', label, `<input type="color" class="swatch" data-sfc="${esc(path)}" value="${toHex(value || '#000000')}" aria-label="${label} picker"><input ${f} value="${esc(value ?? '')}" autocomplete="off" spellcheck="false">`); 259 if (['textarea', 'richtext', 'html', 'inline_richtext', 'liquid'].includes(setting.type)) return stack('', label, `<textarea ${f} rows="3">${esc(value ?? '')}</textarea>`); 260 if (setting.type === 'image_picker') return stack('', label, `<div class="imgpick"><span class="thumb">${value ? `<img src="${esc(value)}" alt="">` : ''}</span><div class="imgctl">` 261 + (info.assets.length ? `<select data-sfa="${esc(path)}
261" aria-label="Choose one of your images"><option value="">Your imagesâ¦</option>${info.assets.map(a => `<option value="${esc(dashboard)}/assets/${a.id}">${esc(a.name)}</option>`).join('')}</select>` : '') 262 + `<div class="imgrow"><label class="upload">Upload<input type="file" accept="${IMAGE_TYPES}" data-sfu="${esc(path)}"></label>${value ? `<button type="button" class="upload" data-sfclear="${esc(path)}">Remove</button>` : ''}</div></div></div><input type="url" ${f} value="${esc(value ?? '')}" placeholder="https://â¦" aria-label="${label} address" autocomplete="off" spellcheck="false">`); 263 if (['text', 'url', 'video_url'].includes(setting.type)) return stack('', label, `<input ${setting.type === 'text' ? '' : 'type="url" '}${f} value="${esc(value ?? '')}" autocomplete="off" spellcheck="false">`); 264 return `<p class="hint">${label}: set in the Shopify theme.</p>`; 265 } 266 function sectionPanel() { 267 const op = ops.find(o => o.id === sectionEdit.opId), s = sectionEdit, schema = s.schema; 268 const head = `<div class="sticky"><div class="target"><span class="pill">section</span><span class="what">${esc(op.section?.name || 'Section')}</span><button class="icon" data-act="clear" title="Deselect (Esc)" aria-label="Deselect">
268Ã</button></div></div>`; 269 const where = section('position', 'Position', `<div class="seg wide" role="group" aria-label="Position">${[['beforebegin', 'Above'], ['afterend', 'Below'], ['beforeend', 'Inside']].map(([v, l]) => `<button type="button" data-spos="${v}" class="${op.position === v ? 'active' : ''}" aria-pressed="${op.position === v}">${l}</button>`).join('')}</div><p class="hint">Relative to <code>${esc(op.selector)}</code>.</p><button class="quiet" data-act="section-anchor">Select that element</button><button class="quiet danger" data-act="remove-section">Remove this section</button>`); 270 let body; 271 if (!op.section?.source) body = '<p class="hint">This section was placed before sections could be edited here. Remove it and place it again from the section library to change its texts and images.</p>'; 272 else if (s.error) body = `<p class="hint">${esc(s.error)}</p>`; 273 else if (!schema) body = '<p class="hint">Loading the sectionâs settingsâ¦</p>'; 274 else { 275 const blockDef = type => schema.blocks.find(b => b.type === type) || { settings: [] }; 276 const blocks = s.blocks.map((b, i) => `<div class="blk"><div class="blkhead"><b>${esc(blockDef(b.type).name || b.type)} ${i + 1}</b><button type="button" class="icon" data-blk="move:${i}:-1" ${i ? '' : 'disabled'} aria-label="Move up" title="Move up">â</button><button type="button" class="icon" data-blk="move:${i}:1" ${i < s.blocks.length - 1 ? '' : 'disabled'} aria-label="Move down" title="Move down">â</button><button type="button" class="icon" data-blk="remove:${i}" aria-label="Remove" title="Remove">Ã</button></div>${(blockDef(b.type).settings || []).map(x => sectionField(x, b.settings?.[x.id], `blocks.${i}.${x.id}`)).join('')}</div>`).join(''); 277 const add = schema.blocks.length && s.blocks.length < (schema.max_blocks || 50) ? `<div class="ctl addblk"><select data-f="blockType" aria-label="Block type">${schema.blocks.map(b => `<option value="${esc(b.type)}">${esc(b.name || b.type)}</option>`).join('')}</select><button type="button" data-blk="add">Add</button></div>` : ''; 278 body = section('content', 'Content', schema.settings.map(x => sectionField(x, s.settings[x.id], `settings.${x.id}`)).join('') || '<p class="hint">This section has no settings.</p>') 279 + (schema.blocks.length ? section('blocks', `Blocks`, blocks + add) : '') + `<p class="hint" id="sectionWarnings">${esc((s.warnings || []).join(' '))}</p>`; 280 } 281 return `<fieldset id="fs" ${compare ? 'disabled' : ''}>${head}${body}${where}</fieldset>`; 282 } 283 function inspect() { 284 const box = $('#inspector'); if (!box) return; 285 if (sectionEdit && ops.some(o => o.id === sectionEdit.opId)) { box.innerHTML = sectionPanel(); root.querySelectorAll('.thumb img').forEach(img => img.addEventListener('error', () => img.remove(), { once: true })); return; } 286 if (sectionEdit) sectionEdit = null; 287 if (placing && !outside) { 288 box.innerHTML = selected 289 ? `<div class="empty placing"><b>Where should â${esc(placing.name)}â go?</b><span>Next to the highlighted element. Click another element to choose a different spot.</span><div class="place-choices"><button data-place="beforebegin">Above it</button><button data-place="afterend">Below it</button><button data-place="beforeend">Inside it, at the end</button></div><button class="quiet" data-act="cancel-place">Cancel placing</button></div>` 290 : `<div class="empty placing"><b>Place â${esc(placing.name)}â</b><span>Click the element on the page the section should go next to.</span><button class="quiet" data-act="cancel-place">Cancel placing</button></div>`; 291 return; 292 } 293 if (!selected && outside) { box.innerHTML = `<div class="empty outside"><b>This page is not part of the test</b><span>Visitors see the variation on ${esc(describeRules())}. Browse to one of those pages to edit it; the editor comes along.</span></div>`; return; } 294 if (!selected) { box.innerHTML = `<div class="empty"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m5 3 14 8-6 2-2 6z"/></svg><b>Click anything on the page</b><span>Words, links, images, colours and spacing can all be changed here. Edits show instantly.</span></div>`; return; } 295 const el = selected, tag = el.tagName.toLowerCase(), isLink = tag === 'a', isImg = tag === 'img'; 296 const opText = find('text'), opLink = find('link'), opImg = f
296ind('image'), opMove = find('move'); 297 chain = []; for (let e = el; e && e !== document.body && e !== html; e = e.parentElement) chain.unshift(e); 298 const shown = chain.length > 4 ? chain.slice(-4) : chain, crumbs = (chain.length > 4 ? '<span class="sep">â¦</span>' : '') + shown.map((e, i) => `${i || chain.length > 4 ? '<span class="sep">âº</span>' : ''}${e === el ? `<b>${esc(crumbLabel(e))}</b>` : `<button class="crumb" data-crumb="${chain.indexOf(e)}" title="${esc(crumbLabel(e))}">${esc(crumbLabel(e))}</button>`}`).join(''); 299 const live = (opText ? opText.value : base.text) || '', name = live.trim().replace(/\s+/g, ' ').slice(0, 38) || el.getAttribute('aria-label') || el.id || ''; 300 let content = ''; 301 if (base.text !== null && !isImg) content += stack('text', isLink ? 'Link text' : 'Text', `<textarea data-f="text" rows="2">${esc(opText ? opText.value : base.shown)}</textarea>`); 302 else if (!isImg && !isLink) content += '<p class="hint">This element holds other elements. Click the words inside it to change them.</p>'; 303 if (isLink) { 304 const href = opLink?.href ?? base.href, dest = linkKind === 'section' 305 ? `<select data-f="href"><option value="">Choose a section</option>${pageIds().map(id => `<option value="${esc(id)}" ${'#' + id === href ? 'selected' : ''}>${esc(id)}</option>`).join('')}</select>` 306 : `<input data-f="href" value="${esc(hrefField(href))}" placeholder="${esc(LINK_KINDS[linkKind][1])}" autocomplete="off" spellcheck="false">`; 307 content += row('', 'Link to', `<select data-f="linkkind">${Object.entries(LINK_KINDS).map(([k, [n]]) => `<option value="${k}" ${k === linkKind ? 'selected' : ''}>${n}</option>`).join('')}</select>`) 308 + row('link', 'Address', dest) 309 + row('', 'New tab', `<input type="checkbox" class="switch" data-f="blank" aria-label="Open in a new tab" ${(opLink?.blank ?? base.blank) ? 'checked' : ''}>`); 310 } else if (!isImg && el.closest('a')) content += '<button class="quiet" data-act="parent-link">This is inside a link. Edit the link â</button>'; 311 if (isImg) content += row('', 'Library', `<select data-f="asset"><option value="">Choose an image</option>${info.assets.map(a => `<option value="${esc(dashboard)}/assets/${a.id}">${esc(a.name)}</option>`).join('')}</select>`) 312 + row('image', 'Image URL', `<input data-f="url" type="url" value="${esc(opImg ? opImg.url : base.src)}" placeholder="https://...">`) 313 + row('', 'Description', `<input data-f="alt" value="${esc(opImg ? opImg.alt : base.alt)}">`); 314 box.innerHTML = `<fieldset id="fs" ${compare ? 'disabled' : ''}> 315<div class="sticky"><div class="target"><span class="pill">${esc(tag)}</span><span class="what">${esc(name)}</span><button class="icon" data-act="parent" title="Select the parent element" aria-label="Select the parent element">â</button><button class="icon" data-act="clear" title="Deselect (Esc)" aria-label="Deselect">
315Ã</button></div><nav class="crumbs" aria-label="Element path">${crumbs}</nav></div> 316${content ? section('content', 'Content', content) : ''} 317${section('type', 'Typography', row('style:color', 'Text color', styleField('color', 'color')) + row('style:font-size', 'Font size', styleField('font-size')) + row('style:text-align', 'Alignment', alignControl()))} 318${section('spacing', 'Spacing', boxWidget())} 319${section('look', 'Appearance', row('style:background-color', 'Background', styleField('background-color', 'color')) + row('style:border-radius', 'Corner radius', styleField('border-radius')) + row('style:max-width', 'Max width', styleField('max-width')) + row('hide', 'Hide element', `<input type="checkbox" class="switch" data-f="hide" aria-label="Hide this element" ${find('hide') ? 'checked' : ''}>`))} 320${section('advanced', 'Advanced', `${row('', 'Selector', `<input class="mono" data-f="selector" value="${esc(selector)}" aria-label="CSS selector" spellcheck="false" autocomplete="off">`)} 321${row('move', 'Move to', `<input data-f="moveTarget" value="${esc(opMove?.target || '')}" placeholder="#other-element" autocomplete="off" spellcheck="false">`)} 322${row('', 'Position', `<select data-f="movePos"><option value="before" ${opMove?.position === 'before' ? 'selected' : ''}>Before it</option><option value="after" ${opMove?.position === 'after' ? 'selected' : ''}>After it</option></select>`)} 323${info.sections.length ? `${row('', 'Insert', `<select data-f="section"><option value="">Choose a saved section</option>${info.sections.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('')}</select>`)}${row('', 'Place', `<select data-f="sectionPos"><option value="afterend">After this element</option><option value="beforebegin">Before this element</option><option value="beforeend">Inside, at the end</option></select>`)}<button class="quiet" data-act="insert-section">Insert section</button>` : '<p class="hint">Create reusable sections under Section library in abdriven to insert them here.</p>'}`)} 324</fieldset>`; 325 mark(); 326 } 327 // Rows that differ from the page carry a gold dot (click it to undo that change), and their section shows how many. 328 const isChanged = key => { const [kind, prop] = key.split(':'); return kind === 'style' ? Boolean(find('style', prop)) : Boolean(find(kind)); }; 329 function mark() { 330 root.querySelectorAll('[data-row]').forEach(r => { const key = r.dataset.row; if (key) r.classList.toggle('changed', isChanged(key)); }); 331 root.querySelectorAll('details.group[data-sec]').forEach(d => { const n = d.querySelectorAll('.changed').length, c = d.querySelector(':scope > summary .cnt'); if (c && d.dataset.sec !== 'changes') c.textContent = n || ''; }); 332 } 333 334 // ---- Changes: every edit becomes (or updates, or removes) one change, and shows on the page straight away ---- 335 function commit(next, key) { 336 if (next.length > 50) { notify('Maximum 50 visual changes.'); return false; } 337 try { sdk._visualApply(next); } 338 catch (error) { notify(error.message); try { sdk._visualApply(ops); } catch { /* the page is left as it was */ } return false; } 339 const now = Date.now(); 340 if (!(key && key === lastKey && now - lastAt < 1200)) { undoStack.push(structuredClone(ops)); if (undoStack.length > 100) undoStack.shift(); redoStack = []; } 341 lastKey = key || null; lastAt = now; 342 ops = structuredClone(next); remember(); notify(''); list(); state(); mark(); requestAnimationFrame(refreshBoxes); return true; 343 } 344 function upsert(type, property, op, key) { 345 const match = o => o.selector === selector && o.type === type && (property === undefined || o.property === property) && here(o), prev = ops.find(match); 346 if (!op) return prev ? commit(ops.filter(o => !match(o)), key) : true; 347 if (prev) return commit(ops.map(o => match(o) ? { ...op, id: prev.id, ...(prev.page ? { page: prev.page } : {}), ...(prev.note ? { note: prev.note } : {}) } : o), key); 348 return commit([...ops, { id: newId(), ...op, page: location.pathname }], key); 349 } 350 function setStyle(property, raw) { 351 let v = raw.trim(); if (SIZED.includes(property)) v = unit(v); 352 if (v && (!/^[\w\s#.,()%+\/-]+$/.test(v) || /(?:url|expression|var)\s*\(/i.test(v))) { notify('Use a simple c
352olour, size or alignment.'); return; } 353 upsert('style', property, v ? { type: 'style', selector, property, value: v } : null, 'style' + property + selector); 354 } 355 // Four boxes become one shorthand value. Boxes left empty keep what the page already has. 356 function setBox(property) { 357 const vals = SIDES.map(s => value(`side:${property}:${s}`).trim()); 358 if (vals.every(v => !v)) return setStyle(property, ''); 359 const [t, r, b, l] = vals.map((v, i) => unit(v || base.css[`${property}-${SIDES[i]}`] || '0px')); 360 setStyle(property, t === r && r === b && b === l ? t : t === b && r === l ? `${t} ${r}` : `${t} ${r} ${b} ${l}`); 361 } 362 function edit(f, target) { 363 if (!selected) return; 364 if (f === 'text') { const what = $('.what'); if (what) what.textContent = target.value.trim().replace(/\s+/g, ' ').slice(0, 38); return upsert('text', undefined, target.value === base.shown || target.value === base.text ? null : { type: 'text', selector, value: target.value }, 'text' + selector); } 365 if (f === 'href' || f === 'blank') { 366 const raw = value('href'), blank = root.querySelector('[data-f="blank"]').checked, untouched = base.href && raw.trim() === hrefField(base.href); 367 const href = untouched ? base.href : composeHref(linkKind, raw); 368 return upsert('link', undefined, !href || (href === base.href && blank === base.blank) ? null : { type: 'link', selector, href, ...(blank !== base.blank ? { blank } : {}) }, 'link' + selector); 369 } 370 if (f === 'url' || f === 'alt') { 371 const url = value('url').trim(), alt = value('alt'); 372 if (url && !/^https?:\/\//i.test(url)) return; 373 return upsert('image', undefined, !url || (url === base.src && alt === base.alt) ? null : { type: 'image', selector, url, alt }, 'image' + selector); 374 } 375 if (f === 'asset') { if (target.value) { root.querySelector('[data-f="url"]').value = target.value; edit('url'); } return; } 376 if (f.startsWith('side:')) return setBox(f.split(':')[1]); 377 if (f.startsWith('swatch:')) { const p = f.slice(7); root.querySelector(`[data-f="style:${p}"]`).value = target.value; return setStyle(p, target.value); } 378 if (f.startsWith('style:')) { const p = f.slice(6); const swatch = root.querySelector(`[data-f="swatch:${p}"]`); if (swatch && /^#[0-9a-f]{6}$/i.test(target.value.trim())) swatch.value = target.value.trim(); return setStyle(p, target.value); } 379 if (f === 'hide') return upsert('hide', undefined, target.checked ? { type: 'hide', selector } : null, 'hide' + selector); 380 if (f === 'moveTarget' || f === 'movePos') { const to = value('moveTarget').trim(); return upsert('move', undefined, to ? { type: 'move', selector, target: to, position: value('movePos') } : null, 'move' + selector); } 381 } 382 function selectSection(opId) { 383 const op = ops.find(o => o.id === opId && o.type === 'section'); if (!op) return; 384 if (sectionEdit?.opId !== opId) sectionEdit = { opId, settings: structuredClone(op.section.source?.settings || {}), blocks: structuredClone(op.section.source?.blocks || []), schema: schemas.get(op.section.source?.id) || null, error: '' }; 385 const node = sectionNode(opId); selected = node; selector = ''; base = null; 386 notify(''); inspect(); list(); if (node) { place(chosen, node); reveal(node); } else chosen.hide(); 387 if (op.section.source && !sectionEdit.schema) loadSchema(op); 388 } 389 async function loadSchema(op) { 390 try { 391 const out = await api('/render', { id: op.section.source.id, settings: op.section.source.settings, blocks: op.section.source.blocks }); 392 schemas.set(op.section.source.id, out.schema); 393 if (sectionEdit?.opId === op.id) { sectionEdit.schema = out.schema; sectionEdit.warnings = out.warnings; } 394 } catch (error) { if (sectionEdit?.opId === op.id) sectionEdit.error = error.message; } 395 if (sectionEdit?.opId === op.id) inspect(); 396 } 397 // Edited values are rendered by abdriven (the same Liquid and cleaning as in the section library) and replace the section 398 // on the page. Quick successive edits become one step in the undo history. 399 function rerender(delay = 350) { 400 const edit = sectionEdit; if (!edit) return; 401 clearTimeout(renderTimer); 402 const run = async () => { 403 renderTimer = 0; renderNow = null; 404 const turn = ++renderRun, op = ops.find(o => o.id === edit.opId); if (!op?.section.source) return;
405 try { 406 const out = await api('/render', { id: op.section.source.id, settings: edit.settings, blocks: edit.blocks }); 407 if (turn !== renderRun || !ops.some(o => o.id === edit.opId)) return; 408 commit(ops.map(o => o.id === edit.opId ? { ...o, section: { name: o.section.name, kind: 'liquid', html: out.html, css: out.css, source: out.source } } : o), 'section' + edit.opId); 409 edit.warnings = out.warnings; const w = $('#sectionWarnings'); if (w) w.textContent = out.warnings.join(' '); 410 } catch (error) { if (turn === renderRun) notify(error.message); } 411 }; 412 renderNow = run; renderTimer = setTimeout(run, delay); 413 } 414 // Before saving, an edit still waiting to be rendered is rendered first. 415 async function flushRender() { if (renderTimer && renderNow) { clearTimeout(renderTimer); await renderNow(); } } 416 function setSectionValue(path, value) { 417 if (!sectionEdit) return; 418 const [where, a, b] = path.split('.'); 419 if (where === 'settings') sectionEdit.settings[a] = value; else if (sectionEdit.blocks[Number(a)]) (sectionEdit.blocks[Number(a)].settings ||= {})[b] = value; 420 } 421 async function uploadImage(input) { 422 const file = input.files?.[0]; if (!file) return; 423 const label = input.closest('.upload'); 424 try { 425 if (file.size > 5 * 1024 * 1024) throw Error('Upload an image smaller than 5 MB.'); 426 label?.classList.add('busy'); notify('Uploading the imageâ¦'); 427 const content = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result).split(',')[1]); reader.onerror = reject; reader.readAsDataURL(file); }); 428 const asset = await api('/asset', { name: file.name.slice(0, 150), content }); 429 info.assets = [asset, ...info.assets.filter(a => a.id !== asset.id)]; 430 setSectionValue(input.dataset.sfu, asset.url); inspect(); rerender(0); notify('Image uploaded. It is also kept with this websiteâs images in abdriven.'); 431 } catch (error) { label?.classList.remove('busy'); notify(error.message); } 432 } 433 function select(el, requestedSelector) { 434 if (!el || el === document.body || el === html) return; 435 if (outside) { notify(`This page is not part of the test. Visitors see the variation on ${describeRules()}.`); return; } 436 const placed = el.closest?.('[data-split-op]'); 437 if (placed) { 438 if (placing) { notify('Choose an element of the page itself, not a section placed by this test.'); return; } 439 return selectSection(placed.dataset.splitOp); 440 } 441 sectionEdit = null; 442 base = baseline(el); base.shown = tidy(el, base.text); selected = el; selector = requestedSelector || visual.selector(el); 443 const link = ops.find(o => o.selector === selector && o.type === 'link' && here(o)); linkKind = kindOf(link?.href ?? base.href); 444 notify(''); inspect(); list(); place(chosen, el); reveal(el); 445 } 446 function deselect() { sectionEdit = null; selected = null; selector = ''; inspect(); list(); refreshBoxes(); } 447 function step(from, to) { 448 if (!from.length || compare) return; 449 const next = from.at(-1), current = structuredClone(ops); 450 try { sdk._visualApply(next); } catch (error) { notify(error.message); return; } 451 from.pop(); to.push(current); ops = structuredClone(next); remember(); lastKey = null; notify(''); 452 if (sectionEdit) { clearTimeout(renderTimer); renderTimer = 0; renderRun++; const op = ops.find(o => o.id === sectionEdit.opId); if (op?.section.source) { sectionEdit.settings = structuredClone(op.section.source.settings); sectionEdit.blocks = structuredClone(op.section.source.blocks); } } 453 inspect(); list(); state(); requestAnimationFrame(refreshBoxes); 454 } 455 function setView(original) { 456 if (compare === original) return; 457 compare = original; sdk._visualApply(compare ? [] : ops); 458 $('#viewEdited').classList.toggle('active', !compare); $('#viewOriginal').classList.toggle('active', compare); 459 hover.hide(); root.querySelector('#fs')?.toggleAttribute('disabled', compare); state(); refreshBoxes(); 460 notify(compare ? 'Showing the page without your visual edits. Custom code still applies.' : ''); 461 } 462 463 // ---- Wiring ---- 464 render(); ready = true; layout(); 465 if (placeId && !placing) notify('The section to place is no longer on this website.'); 466 // Unsaved edits brought along from another page show on this one straight away.
467 if (dirty()) { try { sdk._visualApply(ops); list(); } catch (error) { notify(error.message); } } 468 listen(document, 'pointerover', e => { if (picking && !compare && !e.composedPath().includes(host) && e.target instanceof Element && e.target !== hover.box && e.target !== document.body && e.target !== html) place(hover, e.target.closest('[data-split-op]') || e.target); }, true); 469 listen(document, 'pointerout', e => { if (e.relatedTarget === null) hover.hide(); }, true); 470 // Only a person's own click selects. Themes click their own tabs, sliders and variant pickers from code (e.isTrusted is 471 // false then); those clicks go through, so the page starts up as it would without the editor. 472 listen(document, 'click', e => { 473 if (!e.isTrusted || !picking || compare || e.composedPath().includes(host) || !(e.target instanceof Element)) return; 474 e.preventDefault(); e.stopImmediatePropagation(); select(e.target); 475 }, true); 476 listen(document, 'keydown', e => { 477 const typing = root.activeElement && /^(INPUT|TEXTAREA|SELECT)$/.test(root.activeElement.tagName); 478 if (e.key === 'Escape' && selected && !typing) { deselect(); return; } 479 if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !typing && !/^(INPUT|TEXTAREA)$/.test(document.activeElement?.tagName)) { e.preventDefault(); e.shiftKey ? step(redoStack, undoStack) : step(undoStack, redoStack); } 480 }); 481 listen(root, 'input', e => { 482 const t = e.target, sf = t.dataset?.sf, sfc = t.dataset?.sfc; 483 if (sf && sectionEdit) { if (t.type !== 'file') { setSectionValue(sf, t.type === 'checkbox' ? t.checked : t.type === 'number' ? (t.value === '' ? null : Number(t.value)) : t.value); const swatch = root.querySelector(`[data-sfc="${CSS.escape(sf)}"]`); if (swatch && /^#[0-9a-f]{6}$/i.test(t.value.trim())) swatch.value = t.value.trim(); rerender(); } return; } 484 if (sfc && sectionEdit) { setSectionValue(sfc, t.value); const text = root.querySelector(`[data-sf="${CSS.escape(sfc)}"]`); if (text) text.value = t.value; rerender(); return; } 485 const f = t.dataset?.f; if (f && f !== 'selector' && f !== 'linkkind' && f !== 'blockType') edit(f, t); 486 }); 487 listen(root, 'toggle', e => { const d = e.target; if (d.dataset?.sec) openSection.set(d.dataset.sec, d.open); }, true); 488 listen(root, 'change', e => { 489 if (e.target.dataset?.sfu) { uploadImage(e.target); return; } 490 if (e.target.dataset?.sfa && e.target.value) { setSectionValue(e.target.dataset.sfa, e.target.value); inspect(); rerender(0); return; } 491 if (e.target.dataset?.sf && ['checkbox', 'select-one'].includes(e.target.type)) { rerender(0); return; } 492 const f = e.target.dataset?.f; 493 if (f === 'selector') { let match = null; try { const found = document.querySelectorAll(e.target.value); if (found.length === 1 && !found[0].closest('#split-editor-host')) match = found[0]; } catch { /* not a selector */ } if (match) select(match, e.target.value.trim()); else { notify('That selector must match exactly one element.'); e.target.value = selector; } } 494 if (f === 'linkkind') { linkKind = e.target.value; inspect(); root.querySelector('[data-f="linkkind"]')?.focus(); } 495 }); 496 listen(root, 'click', async e => { 497 const button = e.target.closest('button'); if (!button) return; 498 try { 499 if (button.dataset.select) { const el = document.querySelector(button.dataset.select); if (el) select(el); else notify('That element is not on this page.'); } 500 if (button.dataset.op) selectSection(button.dataset.op); 501 if (button.dataset.spos && sectionEdit) { const id = sectionEdit.opId; if (commit(ops.map(o => o.id === id ? { ...o, position: button.dataset.spos } : o))) selectSection(id); } 502 if (button.dataset.sfclear && sectionEdit) { setSectionValue(button.dataset.sfclear, ''); inspect(); rerender(0); } 503 if (button.dataset.blk && sectionEdit) { 504 const [what, i, by] = button.dataset.blk.split(':'), list = sectionEdit.blocks, n = Number(i); 505 if (what === 'add') { const type = value('blockType'), def = sectionEdit.schema?.blocks.find(b => b.type === type); if (def) list.push({ type, settings: Object.fromEntries((def.settings || []).filter(x => x.id).map(x => [x.id, x.default ?? ''])) }); } 506 if (what === 'remove') list.splice(n, 1); 507 if (what === 'move') { const [block] = list.splice(n, 1); list.splice(n + Number(by), 0, block); } 508 inspect(); rerender(0); 509 } 510 if (button.dataset.act === 'section-anchor' && sectionEdit) { const op = ops.find(o => o.id === sectionEdit.opId); let el = null; try { el = document.querySelector(op.selector); } catch { /* not a selector */ } if (el) select(el); else notify('That element is not on this page.'); } 511 if (button.dataset.act === 'remove-section' && sectionEdit) { const id = sectionEdit.opId; clearTimeout(renderTimer); renderTimer = 0; renderRun++; if (commit(ops.filter(o => o.id !== id))) deselect(); } 512 if (button.dataset.page) { followingLink = true; location.assign(button.dataset.page); return; } 513 if (button.dataset.remove) { if (commit(ops.filter(o => keyOf(o) !== button.dataset.remove))) inspect(); } 514 if (button.dataset.reset) { const [kind, prop] = button.dataset.reset.split(':'); if (commit(ops.filter(o => !(o.selector === selector && o.type === kind && (kind !== 'style' || o.property === prop) && here(o))))) inspect(); } 515 if (button.dataset.align) { const now = find('style', 'text-align')?.value; setStyle('text-align', now === button.dataset.align ? '' : button.dataset.align); inspect(); } 516 if (button.dataset.crumb !== undefined) select(chain[Number(button.dataset.crumb)]); 517 if (button.dataset.act === 'parent' && selected?.parentElement) select(selected.parentElement); 518 if (button.dataset.act === 'parent-link') select(selected.closest('a')); 519 if (button.dataset.act === 'clear') deselect(); 520 if (button.dataset.place && placing) { 521 const id = newId(); 522 if (commit([...ops, { id, type: 'section', selector, position: button.dataset.place, section: placedSection(placing), page: location.pathname }])) { 523 placing = null; selectSection(id); notify('Section placed. Change its texts and images here, then save.'); 524 } 525 } 526 if (button.dataset.act === 'cancel-place') { placing = null; inspect(); notify(''); } 527 if (button.dataset.act === 'insert-section') { 528 const section = info.sections.find(s => s.id === value('section')); if (!section) throw Error('Choose a saved section first.'); 529 const id = newId(); if (commit([...ops, { id, type: 'section', selector, position: value('sectionPos'), section: placedSection(section), page: location.pathname }])) selectSection(id); 530 } 531 if (button.id === 'exitEditor') { 532 if (saving || (dirty() && !confirm('Discard unsaved visual changes and close the editor?'))) return; 533 closeEditor(); return; 534 } 535 if (button.id === 'collapse') { host.toggleAttribute('data-collapsed'); const c = host.hasAttribute('data-collapsed'); button.textContent = c ? '«' : '»'; button.setAttribute('aria-label', c ? 'Expand editor' : 'Collapse editor'); layout(); } 536 if (button.id === 'pick' || button.id === 'browse') { 537 if (button.id === 'pick' && outside) { notify(`This page is not part of the test. Visitors see the variation on ${describeRules()}.`); return; } 538 picking = button.id === 'pick'; $('#pick').classList.toggle('active', picking); $('#browse').classList.toggle('active', !picking); hover.hide(); 539 notify(picking ? '' : 'Browsing: the page works normally. The editor and
539your unsaved changes come along to other pages of this website.'); 540 } 541 if (button.id === 'undo') step(undoStack, redoStack); 542 if (button.id === 'redo') step(redoStack, undoStack); 543 if (button.id === 'viewEdited') setView(false); 544 if (button.id === 'viewOriginal') setView(true); 545 if (button.id === 'save' || button.id === 'saveClose') { 546 if (!dirty() && !renderTimer && button.id === 'saveClose') { closeEditor(true); return; } 547 saving = true; state(); 548 await flushRender(); 549 const result = await api('/save', { visual: ops }); token = result.token; info.expires = result.expires; 550 // The renewed token stays in this tab (for the next pages), never in the address bar. 551 saved = JSON.stringify(ops); keep(sessionKey, { token, expires: info.expires }); remember(); notify(''); 552 if (button.id === 'saveClose') { closeEditor(true); return; } 553 } 554 } catch (error) { notify(error.message); } finally { saving = false; if (host.isConnected) state(); } 555 }); 556 const expiryTimer = setInterval(() => { if (host.isConnected) state(); }, 30000); lifetime.signal.addEventListener('abort', () => clearInterval(expiryTimer), {once:true}); 557 let followingLink = false; 558 listen(document, 'click', e => { 559 if (picking || !(e.target instanceof Element) || e.composedPath().includes(host)) return; 560 const a = e.target.closest('a[href]'); if (!a || (a.target && a.target !== '_self')) return; 561 try { if (new URL(a.href, location.href).origin === location.origin) { followingLink = true; setTimeout(() => { followingLink = false; }, 1500); } } catch { /* not a web address */ } 562 }); 563 listen(window, 'beforeunload', e => { if (dirty() && !followingLink) { e.preventDefault(); e.returnValue = ''; } }); 564})();
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.