(() => { 'use strict'; if (window.XperimentVisualEditor) { window.XperimentVisualEditor.open(); return; } const state = { changes: [], undoStack: [], redoStack: [], selected: null, hovered: null, enabled: true, counter: 0 }; const EDITOR_ATTR = 'data-xperiment-editor'; const style = document.createElement('style'); style.id = 'xperiment-editor-styles'; style.setAttribute(EDITOR_ATTR, '1'); style.textContent = ` #xpe-bar{position:fixed;z-index:2147483646;left:50%;bottom:14px;transform:translateX(-50%);display:flex;gap:7px;align-items:center;padding:8px 10px;background:linear-gradient(180deg,#202433,#151823);color:#fff;border:1px solid #41475d;border-radius:15px;box-shadow:0 12px 38px rgba(0,0,0,.38);font:13px/1.2 system-ui,sans-serif;user-select:none;touch-action:none} #xpe-bar button,.xpe-btn{appearance:none;border:0;border-radius:9px;background:#2c3040;color:#fff;padding:8px 10px;cursor:pointer;font:inherit}.xpe-btn:hover,#xpe-bar button:hover{background:#41475d}.xpe-btn.primary{background:#5865f2}.xpe-btn.danger{background:#b42318}.xpe-btn:disabled,#xpe-bar button:disabled{opacity:.42;cursor:not-allowed} #xpe-bar .xpe-icon-btn{display:grid;place-items:center;width:36px;height:36px;padding:0;color:#e8eaf2;transition:background .15s ease,color .15s ease,transform .15s ease}#xpe-bar .xpe-icon-btn:hover{color:#fff;transform:translateY(-1px)}#xpe-bar .xpe-icon-btn svg{width:19px;height:19px;display:block;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;pointer-events:none} #xpe-drag{display:grid;place-items:center;width:25px;height:36px;color:#8f96aa;cursor:grab;border-right:1px solid #3b4052;margin-right:2px;padding-right:7px;touch-action:none}#xpe-drag:active{cursor:grabbing;color:#fff}#xpe-drag svg{width:17px;height:17px;fill:currentColor;pointer-events:none} #xpe-hover{position:fixed;z-index:2147483644;pointer-events:none;border:2px solid #5865f2;background:rgba(88,101,242,.08);display:none} #xpe-label{position:fixed;z-index:2147483645;pointer-events:none;background:#5865f2;color:#fff;padding:3px 6px;border-radius:4px;font:11px system-ui,sans-serif;display:none;max-width:60vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .xpe-backdrop{position:fixed;inset:0;z-index:2147483647;background:rgba(13,17,23,.62);display:grid;place-items:center;padding:20px;font:14px system-ui,sans-serif} .xpe-modal{width:min(780px,96vw);max-height:88vh;overflow:auto;background:#fff;color:#20242d;border-radius:15px;box-shadow:0 24px 80px rgba(0,0,0,.45)} .xpe-head,.xpe-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 17px;border-bottom:1px solid #e5e7eb}.xpe-foot{border-top:1px solid #e5e7eb;border-bottom:0;justify-content:flex-end}.xpe-body{padding:17px}.xpe-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.xpe-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.xpe-action{padding:16px!important;text-align:left}.xpe-action b{display:block;margin-bottom:4px}.xpe-action span{color:#c6cad6;font-size:12px}.xpe-field{display:grid;gap:5px}.xpe-field.full{grid-column:1/-1}.xpe-field label{font-weight:650}.xpe-field input,.xpe-field select,.xpe-field textarea{box-sizing:border-box;width:100%;border:1px solid #cfd4dc;border-radius:8px;padding:9px;background:#fff;color:#111827;font:13px ui-monospace,SFMono-Regular,Consolas,monospace}.xpe-field textarea{min-height:250px;resize:vertical}.xpe-note{padding:9px 11px;background:#f1f5f9;border-radius:8px;color:#475569;margin-bottom:12px}.xpe-path{display:flex;align-items:center;gap:5px;overflow-x:auto;padding:10px 11px;margin-bottom:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:9px}.xpe-path button{flex:0 0 auto;appearance:none;border:0;border-radius:6px;padding:5px 8px;background:#e9edff;color:#3730a3;cursor:pointer;font:12px ui-monospace,SFMono-Regular,Consolas,monospace}.xpe-path button:hover{background:#5865f2;color:#fff}.xpe-path button.active{background:#3730a3;color:#fff}.xpe-path-sep{color:#94a3b8}.xpe-tag{font:12px ui-monospace,monospace;background:#eef2ff;color:#3730a3;padding:3px 6px;border-radius:5px;word-break:break-all} @media(max-width:650px){.xpe-grid,.xpe-actions{grid-template-columns:1fr}#xpe-bar{left:8px;right:8px;transform:none;overflow:auto;justify-content:flex-start}} `; document.documentElement.appendChild(style); const hoverBox = make('div', { id: 'xpe-hover', [EDITOR_ATTR]: '1' }); const hoverLabel = make('div', { id: 'xpe-label', [EDITOR_ATTR]: '1' }); const bar = make('div', { id: 'xpe-bar', [EDITOR_ATTR]: '1' }); document.documentElement.append(hoverBox, hoverLabel, bar); const icons = { drag: '', undo: '', redo: '', js: '', css: '', json: '', generate: '', close: '' }; const dragHandle = make('div', { id:'xpe-drag', title:'Move toolbar', 'aria-label':'Move toolbar', role:'button', tabindex:'0' }); dragHandle.innerHTML = icons.drag; bar.appendChild(dragHandle); const buttons = [ ['undo', 'Undo', undo], ['redo', 'Redo', redo], ['js', 'Custom JavaScript', editJavaScript], ['css', 'Custom CSS', editCss], ['json', 'Changes JSON', editJson], ['generate', 'Generate A/B snippet', generateDialog], ['close', 'Close editor', close] ]; buttons.forEach(([icon, title, handler]) => { const b = make('button', { class:'xpe-icon-btn', title, 'aria-label':title, 'data-action':icon }); b.innerHTML = icons[icon]; b.addEventListener('click', e => { e.stopPropagation(); handler(); }); bar.appendChild(b); }); const undoBtn = bar.querySelector('[data-action="undo"]'); const redoBtn = bar.querySelector('[data-action="redo"]'); enableToolbarDrag(); restoreToolbarPosition(); updateButtons(); document.addEventListener('mousemove', onMove, true); document.addEventListener('click', onClick, true); window.addEventListener('scroll', refreshHover, true); window.addEventListener('resize', refreshHover, true); function enableToolbarDrag() { let drag = null; dragHandle.addEventListener('pointerdown', e => { if (e.button !== 0) return; const rect = bar.getBoundingClientRect(); drag = { pointerId:e.pointerId, dx:e.clientX-rect.left, dy:e.clientY-rect.top }; dragHandle.setPointerCapture(e.pointerId); bar.style.left = rect.left + 'px'; bar.style.top = rect.top + 'px'; bar.style.right = 'auto'; bar.style.bottom = 'auto'; bar.style.transform = 'none'; e.preventDefault(); }); dragHandle.addEventListener('pointermove', e => { if (!drag || e.pointerId !== drag.pointerId) return; const maxX = Math.max(0, window.innerWidth - bar.offsetWidth); const maxY = Math.max(0, window.innerHeight - bar.offsetHeight); bar.style.left = Math.min(maxX, Math.max(0, e.clientX-drag.dx)) + 'px'; bar.style.top = Math.min(maxY, Math.max(0, e.clientY-drag.dy)) + 'px'; }); const finish = e => { if (!drag || e.pointerId !== drag.pointerId) return; drag = null; try { localStorage.setItem('xpe-toolbar-position', JSON.stringify({ left:bar.style.left, top:bar.style.top })); } catch {} }; dragHandle.addEventListener('pointerup', finish); dragHandle.addEventListener('pointercancel', finish); window.addEventListener('resize', keepToolbarInViewport); } function restoreToolbarPosition() { try { const pos = JSON.parse(localStorage.getItem('xpe-toolbar-position')); if (!pos?.left || !pos?.top) return; bar.style.left = pos.left; bar.style.top = pos.top; bar.style.right = 'auto'; bar.style.bottom = 'auto'; bar.style.transform = 'none'; requestAnimationFrame(keepToolbarInViewport); } catch {} } function keepToolbarInViewport() { if (!bar.style.top) return; const rect = bar.getBoundingClientRect(); bar.style.left = Math.min(Math.max(0, rect.left), Math.max(0, innerWidth-bar.offsetWidth)) + 'px'; bar.style.top = Math.min(Math.max(0, rect.top), Math.max(0, innerHeight-bar.offsetHeight)) + 'px'; } function make(tag, attrs = {}, text = '') { const el = document.createElement(tag); Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v)); if (text !== '') el.textContent = text; return el; } function isEditorNode(el) { return !!el?.closest?.(`[${EDITOR_ATTR}]`); } function onMove(e) { if (!state.enabled || isEditorNode(e.target)) return hideHover(); state.hovered = e.target; refreshHover(); } function refreshHover() { const el = state.hovered; if (!el || !el.isConnected || isEditorNode(el)) return hideHover(); const r = el.getBoundingClientRect(); Object.assign(hoverBox.style, { display:'block', left:r.left+'px', top:r.top+'px', width:r.width+'px', height:r.height+'px' }); hoverLabel.textContent = selectorFor(el); Object.assign(hoverLabel.style, { display:'block', left:Math.max(0,r.left)+'px', top:Math.max(0,r.top-22)+'px' }); } function hideHover() { hoverBox.style.display = hoverLabel.style.display = 'none'; } function onClick(e) { if (!state.enabled || isEditorNode(e.target)) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); state.selected = e.target; elementMenu(e.target); } function selectorFor(el) { if (!(el instanceof Element)) return ''; if (el.id && /^[A-Za-z][\w:.-]*$/.test(el.id)) return '#' + CSS.escape(el.id); const parts = []; let node = el; while (node && node.nodeType === 1 && node !== document.documentElement) { let part = node.localName; const stable = [...node.attributes].find(a => /^data-(testid|qa|id|component)$/.test(a.name) && a.value); if (stable) part += `[${stable.name}="${CSS.escape(stable.value)}"]`; else { const siblings = node.parentElement ? [...node.parentElement.children].filter(x => x.localName === node.localName) : []; if (siblings.length > 1) part += `:nth-of-type(${siblings.indexOf(node)+1})`; } parts.unshift(part); const candidate = parts.join(' > '); try { if (document.querySelectorAll(candidate).length === 1) return candidate; } catch {} node = node.parentElement; } return parts.join(' > '); } function modal(title, bodyNode, actions = []) { state.enabled = false; hideHover(); const backdrop = make('div', { class:'xpe-backdrop', [EDITOR_ATTR]:'1' }); const box = make('div', { class:'xpe-modal' }); const head = make('div', { class:'xpe-head' }); const h = make('strong', {}, title); const x = make('button', { class:'xpe-btn', type:'button' }, 'Close'); head.append(h, x); const body = make('div', { class:'xpe-body' }); body.append(bodyNode); const foot = make('div', { class:'xpe-foot' }); const dismiss = () => { backdrop.remove(); state.enabled = true; }; x.onclick = dismiss; backdrop.addEventListener('mousedown', e => { if (e.target === backdrop) dismiss(); }); actions.forEach(a => { const b = make('button', { class:'xpe-btn '+(a.className||''), type:'button' }, a.label); b.onclick = () => a.onClick(dismiss); foot.appendChild(b); }); box.append(head, body, foot); backdrop.appendChild(box); document.documentElement.appendChild(backdrop); return { backdrop, dismiss }; } function field(label, value='', type='text', full=false) { const wrap = make('div', { class:'xpe-field'+(full?' full':'') }); const lab = make('label', {}, label); const input = type === 'textarea' ? make('textarea') : make('input', { type }); input.value = value; wrap.append(lab, input); return { wrap, input }; } function elementMenu(el) { state.selected = el; const body = make('div'); const note = make('div', { class:'xpe-note' }); note.innerHTML = 'Selected: '; note.querySelector('span').textContent = selectorFor(el); const path = make('div', { class:'xpe-path', 'aria-label':'HTML element path' }); const ancestors = []; let node = el; while (node && node instanceof Element && node !== document.documentElement) { if (!isEditorNode(node)) ancestors.unshift(node); node = node.parentElement; } ancestors.forEach((pathEl, index) => { if (index) path.appendChild(make('span', { class:'xpe-path-sep', 'aria-hidden':'true' }, '›')); const label = pathEl.localName + (pathEl.id ? '#' + pathEl.id : '') + (!pathEl.id && pathEl.classList.length ? '.' + [...pathEl.classList].slice(0,2).join('.') : ''); const button = make('button', { type:'button', title:selectorFor(pathEl), class:pathEl===el?'active':'' }, label); button.onclick = () => { document.querySelector('.xpe-backdrop')?.remove(); state.enabled = true; state.selected = pathEl; state.hovered = pathEl; elementMenu(pathEl); }; path.appendChild(button); }); const actions = make('div', { class:'xpe-actions' }); [ ['Edit HTML','Edit the complete selected element, including its tag and inner HTML',() => htmlDialog(el)], ['Edit element','Change common inline style properties',() => styleDialog(el)], ['Delete element','Remove the selected node',() => deleteElement(el)], ['Add HTML','Insert HTML relative to the selected element',() => insertDialog(el)] ].forEach(([t,d,fn]) => { const b=make('button',{class:'xpe-btn xpe-action'},''); b.innerHTML=''; b.querySelector('b').textContent=t; b.querySelector('span').textContent=d; b.onclick=()=>{ document.querySelector('.xpe-backdrop')?.remove(); state.enabled=true; fn(); }; actions.appendChild(b); }); body.append(note,path,actions); modal('Edit page element', body); requestAnimationFrame(() => { path.scrollLeft = path.scrollWidth; }); } function commit(change, apply, revert) { change.id = change.id || `change_${Date.now()}_${++state.counter}`; state.changes.push(change); apply(); state.undoStack.push({ change, apply, revert }); state.redoStack.length = 0; updateButtons(); } function undo() { const op = state.undoStack.pop(); if (!op) return; op.revert(); const i=state.changes.findIndex(c=>c.id===op.change.id); if(i>=0) state.changes.splice(i,1); state.redoStack.push(op); updateButtons(); } function redo() { const op=state.redoStack.pop(); if(!op)return; op.apply(); state.changes.push(op.change); state.undoStack.push(op); updateButtons(); } function updateButtons(){ undoBtn.disabled=!state.undoStack.length; redoBtn.disabled=!state.redoStack.length; } function htmlDialog(el) { const selector=selectorFor(el), f=field('Complete element HTML',el.outerHTML,'textarea',true); const body=make('div'); const note=make('div',{class:'xpe-note'},'The editor replaces the complete selected element, including its opening tag, attributes, inner HTML and closing tag. Enter exactly one root element.'); body.append(note,f.wrap); modal('Edit complete element HTML',body,[{label:'Apply',className:'primary',onClick:d=>{ const before=el.outerHTML, after=f.input.value.trim(); const template=document.createElement('template'); template.innerHTML=after; const roots=[...template.content.children]; if(roots.length!==1 || template.content.childNodes.length!==1){ alert('Please enter exactly one root HTML element without additional text nodes.'); return; } let current=el; const replaceWithHtml=html=>{ const t=document.createElement('template'); t.innerHTML=html.trim(); const replacement=t.content.firstElementChild; if(!replacement) throw new Error('No valid root element found'); current.replaceWith(replacement); current=replacement; state.selected=current; state.hovered=current; }; commit({type:'html',target:selector,html:after,mode:'outerHTML'},()=>replaceWithHtml(after),()=>replaceWithHtml(before)); d(); }}]); } function styleDialog(el) { const cs=getComputedStyle(el), selector=selectorFor(el), grid=make('div',{class:'xpe-grid'}); const defs=[['Color',cs.color,'color'],['Background',cs.background,'background'],['Font size',cs.fontSize,'fontSize'],['Font family',cs.fontFamily,'fontFamily'],['Font weight',cs.fontWeight,'fontWeight'],['Line height',cs.lineHeight,'lineHeight'],['Text align',cs.textAlign,'textAlign'],['Width',cs.width,'width'],['Height',cs.height,'height'],['Padding',cs.padding,'padding'],['Margin',cs.margin,'margin'],['Border',cs.border,'border'],['Border radius',cs.borderRadius,'borderRadius'],['Display',cs.display,'display'],['Opacity',cs.opacity,'opacity'],['Box shadow',cs.boxShadow,'boxShadow']]; const inputs={}; defs.forEach(([label,val,key])=>{const f=field(label,val);inputs[key]=f.input;grid.append(f.wrap)}); modal('Edit element styles',grid,[{label:'Apply',className:'primary',onClick:d=>{ const styles={}, before={}; Object.entries(inputs).forEach(([k,i])=>{styles[k]=i.value;before[k]=el.style[k]}); commit({type:'style',target:selector,styles},()=>Object.assign(el.style,styles),()=>Object.assign(el.style,before)); d(); }}]); } function deleteElement(el) { const selector=selectorFor(el), parent=el.parentNode, next=el.nextSibling; commit({type:'remove',target:selector},()=>el.remove(),()=>parent.insertBefore(el,next)); } function insertDialog(el) { const pos=field('Position',''), html=field('HTML','