import React, { useCallback, useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { createPortal } from 'react-dom'; import { PageDrawer } from './components/page-drawer'; import './index.css'; import './styles/elementor-isolation.css'; /** * Elementor editor integration entry (adoption plan Feature 4, lane F-B). * * Renders an LLMagnet score button in the Elementor editor — primary: DOM * injection into the v3 top bar (React portal); fallback: floating action * button — and opens the existing Page/Product drawer over the editor. * * Loaded only inside the Elementor editor by includes/class-elementor.php. */ type ElementorData = LlmagnetElementorData; /** * Top-bar container candidates, most-specific first. Elementor's v3 top bar * ("editor v2" UI) is MUI-based React with NO stable public API for * third-party buttons, so these WILL need patching across Elementor * versions — keep every selector in this one block (spec Feature 4, * risk note 5). When none match within the timeout the floating fallback * button is shown instead, which also covers the legacy (non-top-bar) * editor. */ const TOP_BAR_CONTAINER_SELECTORS: string[] = [ // v3.20+ top bar: utility icon cluster on the end side of the header. '#elementor-editor-wrapper-v2 header .MuiGrid-container > .MuiGrid-root:last-of-type .MuiStack-root', '#elementor-editor-wrapper-v2 header .MuiStack-root:last-of-type', // Any header inside the v2 wrapper as a last resort. '#elementor-editor-wrapper-v2 header', ]; /** How long to wait for the top bar before falling back (ms). */ const TOP_BAR_TIMEOUT = 8000; function findTopBarContainer(): Element | null { for (const selector of TOP_BAR_CONTAINER_SELECTORS) { try { const el = document.querySelector(selector); if (el) return el; } catch { // Invalid selector on this Elementor version — try the next one. } } return null; } /** Whether the Elementor document has unsaved changes (defensive). */ function isElementorDirty(): boolean { try { const saver = window.elementor?.saver; if (saver && typeof saver.isEditorChanged === 'function') { return !!saver.isEditorChanged(); } } catch { // Elementor internals changed — assume clean. } return false; } function scoreColor(score: number | null): string { if (score === null) return '#9ca3af'; if (score >= 90) return '#22c55e'; if (score >= 50) return '#f97316'; return '#ef4444'; } function LlmagnetIcon() { return ( ); } function ScoreButton({ score, label, onClick, floating, }: { score: number | null; label: string; onClick: () => void; floating: boolean; }) { const base: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', position: 'relative', cursor: 'pointer', border: 'none', borderRadius: 8, background: 'none', color: '#ffffff', lineHeight: 0, padding: 0, flexShrink: 0, }; const style: React.CSSProperties = floating ? { ...base, position: 'fixed', insetBlockEnd: 24, insetInlineEnd: 24, zIndex: 999998, width: 40, height: 40, boxShadow: '0 4px 14px rgba(0,0,0,0.18)', } : { ...base, width: 32, height: 32, marginInline: 4, verticalAlign: 'middle', }; return ( ); } function ElementorApp({ data }: { data: ElementorData }) { const [score, setScore] = useState(null); const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [topBarContainer, setTopBarContainer] = useState(null); const [useFallback, setUseFallback] = useState(false); const [showUnsavedNotice, setShowUnsavedNotice] = useState(false); const fetchScore = useCallback(async () => { try { const res = await fetch( `${data.restRoot}${data.restNamespace}/scores?post_ids=${data.postId}&context=elementor`, { headers: { 'X-WP-Nonce': data.restNonce }, credentials: 'same-origin', } ); if (!res.ok) return; const json = await res.json(); const row = json?.scores?.[String(data.postId)]; setScore(row && typeof row.score === 'number' ? row.score : null); } catch { // Score stays null — the button still works without it. } }, [data.restRoot, data.restNamespace, data.postId, data.restNonce]); useEffect(() => { fetchScore(); }, [fetchScore]); // Tier 1: watch for the top bar; tier 2: floating fallback on timeout. useEffect(() => { const existing = findTopBarContainer(); if (existing) { setTopBarContainer(existing); return; } let done = false; let observer: MutationObserver | null = null; const timer = window.setTimeout(() => { if (!done) { done = true; observer?.disconnect(); setUseFallback(true); } }, TOP_BAR_TIMEOUT); try { observer = new MutationObserver(() => { const found = findTopBarContainer(); if (found && !done) { done = true; window.clearTimeout(timer); observer?.disconnect(); setTopBarContainer(found); } }); observer.observe(document.body, { childList: true, subtree: true }); } catch { window.clearTimeout(timer); setUseFallback(true); } return () => { done = true; window.clearTimeout(timer); observer?.disconnect(); }; }, []); // If the top bar node gets unmounted by Elementor (e.g. responsive mode // re-render), degrade to the floating button instead of disappearing. useEffect(() => { if (!topBarContainer) return; const interval = window.setInterval(() => { if (!document.body.contains(topBarContainer)) { setTopBarContainer(null); setUseFallback(true); } }, 3000); return () => window.clearInterval(interval); }, [topBarContainer]); const openDrawer = () => { setShowUnsavedNotice(isElementorDirty()); setIsDrawerOpen(true); }; const closeDrawer = () => { setIsDrawerOpen(false); setShowUnsavedNotice(false); }; const handleUpdated = () => { // Re-fetch after a drawer save so the badge reflects the new score. // The persisted meta is refreshed asynchronously by the backfill, so // also queue one delayed refresh. fetchScore(); window.setTimeout(fetchScore, 90 * 1000); }; const label = score === null ? data.i18n.noScore : data.i18n.scoreLabel.replace('%d', String(score)); const button = ( ); const drawer = ( ); return ( <> {/* Lift the whole drawer subtree above Elementor's chrome (panel is ~z-index 99999). The wrapper is inert when the drawer is closed. */} {topBarContainer && !useFallback ? createPortal(button, topBarContainer) : button}
{isDrawerOpen && showUnsavedNotice && (
{data.i18n.unsavedNotice}
)} {drawer}
); } function mount() { const data = window.llmagnetElementorData; if (!data || !data.postId) { return; } // The drawers read window.wpApiSettings.nonce; Elementor localizes its // own copy — reuse it if present, never overwrite (spec Feature 4). const w = window as any; if (!w.wpApiSettings || !w.wpApiSettings.nonce) { w.wpApiSettings = { root: data.restRoot, nonce: data.restNonce }; } let rootEl = document.getElementById('llmagnet-elementor-root'); if (!rootEl) { rootEl = document.createElement('div'); rootEl.id = 'llmagnet-elementor-root'; rootEl.className = 'llmagnet-surface'; document.body.appendChild(rootEl); } try { createRoot(rootEl).render(); } catch (err) { // Never break the Elementor editor because of our integration. console.error('[LLMagnet] Elementor integration failed to mount', err); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mount); } else { mount(); }