/**
* Style Customizer v2 — live preview bridge. Owns the `?evf_preview` iframe: tags the form
* wrapper, injects the rule template, writes token values as CSS variables, and maps clicks
* inside the iframe back to their style section for click-to-edit.
*/
import { getActiveSync } from './BuilderSync';
import { resolveValue, tokenDeclarations } from './cssVars';
import { ALL_FORCE_CLASSES, PREVIEW_TARGETS } from './constants';
import { StyleStore } from './store';
import { Token } from './types';
const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s );
const CUSTOM_STYLE_ID = 'evf-scv2-custom-css';
const TEMPLATE_STYLE_ID = 'evf-scv2-rule-template';
const CHROME_STYLE_ID = 'evf-scv2-chrome';
const SELECT_STYLE_ID = 'evf-scv2-select';
const DEVICE_STYLE_ID = 'evf-scv2-device';
const FONT_LINK_ID = 'evf-scv2-font';
/** Legacy `?evf_preview` theme-toggle class: adding it applies theme styling, removing it applies EVF's default. */
const PREVIEW_THEME_CLASS = 'evf-frontend-form-preview';
const HOVER_CLASS = 'evf-scv2-hover';
const SELECTED_CLASS = 'evf-scv2-selected';
/** Mirrors FrontendEnqueue::container_class()'s `evf-choice-{variation}` classes. */
const CHOICE_VARIATION_CLASSES = [ 'evf-choice-outline', 'evf-choice-filled' ];
/** Mirrors FrontendEnqueue::container_class()'s `evf-choice-align-{center|right}` classes. */
const CHOICE_ALIGN_CLASSES = [ 'evf-choice-align-center', 'evf-choice-align-right' ];
/** Mirrors EverestForms_MultiPart::field_submit_visibility_class()'s `everest-forms-nav-align--{value}` class. */
const PAGINATION_NAV_ALIGN_CLASSES = [
'everest-forms-nav-align--left',
'everest-forms-nav-align--right',
'everest-forms-nav-align--center',
'everest-forms-nav-align--split',
];
// indicatorType can't be live-patched client-side — its themes render genuinely different child
// DOM per value (progress bar vs. an
/ of steps, see
// EverestForms_MultiPart::output_part_indicator()), so it needs the same server-reload path a
// Fields-tab edit uses. Every other pagination.* token (color/margin) has a real CSS var the
// static everest-forms-multi-part.css now reads directly, so those preview instantly like any
// other color/box4 token — no special-casing needed.
const PAGINATION_STRUCTURAL_KEYS = [ 'pagination.indicatorType' ];
/** Mirrors FrontendEnqueue::container_class()'s `evf-btn-width-fill` class. */
const BTN_WIDTH_FILL_CLASS = 'evf-btn-width-fill';
/** How long to keep polling for the form wrapper before giving up (ms). */
const READY_DEADLINE = 15000;
/** Poll interval while waiting for the wrapper (ms). */
const POLL_INTERVAL = 200;
export interface SelectionInfo {
section: string;
variant?: string;
label: string;
}
interface JQueryValidateLike {
fn: {
valid?: ( () => boolean ) & { evfScv2Patched?: boolean };
};
}
interface BridgeHandlers {
onReady: () => void;
onError: () => void;
onSelect?: ( info: SelectionInfo ) => void;
/** Any click inside the iframe's own document — used to close an open panel popover. */
onIframeClick?: () => void;
/** Ctrl/Cmd+Z and Ctrl+Shift+Z / Ctrl+Y pressed inside the iframe's own document — keyboard
* undo/redo would otherwise only work while focus is in the panel, never the preview. */
onUndo?: () => void;
onRedo?: () => void;
}
/** Module-level cache of the fetched rule-template CSS text, keyed by URL. */
const cssTextCache: Record< string, Promise< string > > = {};
function fetchCss( url: string ): Promise< string > {
if ( ! cssTextCache[ url ] ) {
cssTextCache[ url ] = fetch( url, { credentials: 'same-origin' } ).then( ( r ) => {
if ( ! r.ok ) {
throw new Error( 'css ' + r.status );
}
return r.text();
} );
}
return cssTextCache[ url ];
}
export class PreviewBridge {
private store: StyleStore;
private iframe: HTMLIFrameElement;
private wrapper: HTMLElement | null = null;
private ready = false;
private destroyed = false;
private onReady: () => void;
private onError: () => void;
private onSelect?: ( info: SelectionInfo ) => void;
private onIframeClick?: () => void;
private onUndo?: () => void;
private onRedo?: () => void;
private deadline = 0;
private pollTimer: ReturnType< typeof setTimeout > | null = null;
private selectedEl: HTMLElement | null = null;
private hoverEl: HTMLElement | null = null;
private previewedKeys: Set< string > = new Set();
private deviceWidth: number | null = null;
private currentForceClass: string | null = null;
private dummyMessageEl: HTMLElement | null = null;
private mutationObserver: MutationObserver | null = null;
private observedDoc: Document | null = null;
private mutationScheduled = false;
/** Last value actually sent to the server per PAGINATION_STRUCTURAL_KEYS key — lets applyKeys()
* skip the resync when the value didn't really change (e.g. a template hover/revert cycle,
* which always re-applies the whole schema including these keys, but never touches the store). */
private lastSyncedStructural: Record< string, unknown > = {};
constructor( iframe: HTMLIFrameElement, store: StyleStore, handlers: BridgeHandlers ) {
this.iframe = iframe;
this.store = store;
this.onReady = handlers.onReady;
this.onError = handlers.onError;
this.onSelect = handlers.onSelect;
this.onIframeClick = handlers.onIframeClick;
this.onUndo = handlers.onUndo;
this.onRedo = handlers.onRedo;
}
/** Wire onto the iframe's load event and begin polling for the wrapper. */
attach() {
this.deadline = Date.now() + READY_DEADLINE;
this.iframe.addEventListener( 'load', this.handleLoad );
// The frame may already be (or become) ready before/around listener attach.
this.poll();
}
detach() {
this.destroyed = true;
if ( this.pollTimer ) {
clearTimeout( this.pollTimer );
this.pollTimer = null;
}
this.stopWatching();
this.iframe.removeEventListener( 'load', this.handleLoad );
this.teardownSelection();
}
private handleLoad = () => {
// A fresh navigation inside the frame — re-arm and re-detect the wrapper.
this.ready = false;
this.wrapper = null;
this.stopWatching();
this.deadline = Date.now() + READY_DEADLINE;
this.poll();
};
/** Resolves the form wrapper by id, falling back to the base plugin's `.evf-container` div. */
private findWrapper( doc: Document ): HTMLElement | null {
const byId = doc.getElementById( this.store.settings.wrapperId );
if ( byId ) {
return byId;
}
const fallback = doc.querySelector( '.evf-container' ) as HTMLElement | null;
if ( fallback ) {
fallback.id = this.store.settings.wrapperId;
return fallback;
}
return null;
}
/** Watches the iframe document for the wrapper being inserted, faster than the fixed-interval poll. */
private watchForWrapper( doc: Document ) {
if ( this.observedDoc === doc && this.mutationObserver ) {
return;
}
this.stopWatching();
if ( ! doc.documentElement || typeof MutationObserver === 'undefined' ) {
return;
}
this.observedDoc = doc;
this.mutationObserver = new MutationObserver( () => this.onMutation( doc ) );
this.mutationObserver.observe( doc.documentElement, { childList: true, subtree: true } );
}
/** Coalesce bursts of mutations (a full page render fires many) into one check per frame. */
private onMutation( doc: Document ) {
if ( this.destroyed || this.ready || this.mutationScheduled ) {
return;
}
this.mutationScheduled = true;
requestAnimationFrame( () => {
this.mutationScheduled = false;
if ( this.destroyed || this.ready ) {
return;
}
try {
const wrapper = this.findWrapper( doc );
if ( wrapper ) {
this.bootstrap( doc, wrapper );
}
} catch ( e ) {
// Swallow — a monkey-patched DOM API (browser extensions) shouldn't wedge detection.
}
} );
}
private stopWatching() {
if ( this.mutationObserver ) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
this.observedDoc = null;
}
/** Reload the preview page inside the iframe (used when the builder's form structure changes). */
reload() {
if ( this.destroyed ) {
return;
}
this.ready = false;
this.wrapper = null;
try {
const win = this.iframe.contentWindow;
if ( win ) {
win.location.reload();
return;
}
} catch ( e ) {
// fall through to the src reset below.
}
// eslint-disable-next-line no-self-assign
this.iframe.src = this.iframe.src;
}
/** Poll for the wrapper until found or the deadline passes. */
private poll = () => {
if ( this.destroyed || this.ready ) {
return;
}
let doc: Document | null = null;
try {
doc = this.iframe.contentDocument;
} catch ( e ) {
doc = null; // Transient during navigation, or cross-origin.
}
if ( doc ) {
try {
this.hideChrome( doc );
this.watchForWrapper( doc );
const wrapper = this.findWrapper( doc );
if ( wrapper ) {
this.bootstrap( doc, wrapper );
return;
}
} catch ( e ) {
// Swallow — a monkey-patched DOM API (browser extensions) shouldn't kill wrapper detection.
}
}
if ( Date.now() >= this.deadline ) {
if ( ! this.ready ) {
this.onError();
}
// Stop polling but leave the MutationObserver attached — a late wrapper still self-heals.
return;
}
this.pollTimer = setTimeout( this.poll, POLL_INTERVAL );
};
/** Wrapper found — tag it, inject rules, paint variables, wire selection. */
private bootstrap( doc: Document, wrapper: HTMLElement ) {
if ( this.pollTimer ) {
clearTimeout( this.pollTimer );
this.pollTimer = null;
}
this.stopWatching();
// A previous dummy message element lived in the old (reloaded) document; it's already gone.
this.dummyMessageEl = null;
this.wrapper = wrapper;
wrapper.classList.add( this.store.settings.markerClass );
this.disableLegacySheet( doc );
this.injectRuleTemplate( doc, () => {
if ( this.destroyed ) {
return;
}
// Extensions can throw mid-sequence; never leave the bridge stuck "not responding".
try {
this.applyAll();
this.applyCustomCss();
this.applyDeviceWidth();
this.applyForceClass();
this.injectSelectionStyles( doc );
this.setupSelection( doc );
} catch ( e ) {
// swallow — see comment above.
}
this.ready = true;
this.onReady();
} );
}
/** Neutralise the legacy per-form compiled stylesheet so v2 tokens always win. */
private disableLegacySheet( doc: Document ) {
const id = this.store.settings.formId;
if ( ! id ) {
return;
}
const needle = `everest_forms_styles/everest-forms-${ id }.css`;
doc.querySelectorAll( 'link[rel="stylesheet"]' ).forEach( ( node ) => {
const link = node as HTMLLinkElement;
if ( link.href && link.href.indexOf( needle ) !== -1 ) {
link.disabled = true;
link.remove();
}
} );
}
/** Hides the `?evf_preview` route's page chrome so only the form fills the frame. */
private hideChrome( doc: Document ) {
if ( ! doc.head || doc.getElementById( CHROME_STYLE_ID ) ) {
return;
}
const css = `
html {
margin-top: 0 !important;
/* Reserve the scrollbar's width in the layout up front, so a border on a
full-width child never falls short of (or is pushed past) the true edge once
content grows taller than the iframe's viewport. */
scrollbar-gutter: stable;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin-top: 0 !important; padding-top: 0 !important; }
#wpadminbar,
#nav-menu-header,
.major-publishing-actions,
.evf-form-preview-dropdown-container,
.evf-form-preview-devices,
.evf-form-preview-sidepanel-toggler,
.evf-form-side-panel { display: none !important; }
body.evf-multi-device-form-preview { background: #fff !important; }
.evf-form-preview-main-content,
.evf-form-preview-overlay {
display: block !important;
position: static !important;
inset: auto !important;
margin: 0 !important;
padding: 12px !important;
width: 100% !important;
max-width: 100% !important;
min-height: 0 !important;
height: auto !important;
box-shadow: none !important;
background: transparent !important;
}
/* Below 992px (evf-form-preview.scss) .evf-form-preview-overlay grows a ::after dark
scrim (originally the "side panel is open, dim the content behind it" backdrop) —
and the template always renders BOTH classes combined on the same element, so this
is not conditional at all. The BUILDER'S iframe is very often narrower than 992px on
its own, so this triggered on nearly every device/window size — overriding the
parent's background above does nothing to it since it is a separate
absolutely-positioned pseudo-element box. */
.evf-form-preview-overlay::after { display: none !important; }
/* .evf-preview-content only — NOT .everest-forms.evf-frontend-form-preview, which used to
be grouped in here too. That rule zeroed the form's own 24px preview-card padding
specifically when "Apply Theme Style" was on, making the toggle look like it changes
the form's spacing. It doesn't: the real (non-preview) frontend has no such rule tied
to that toggle at all (see everest-forms-default-frontend.css) — this 24px is purely
this admin preview card's own decoration, unrelated to theme-style. */
.evf-preview-content { padding: 0 !important; }
.evf-form-preview-form {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
}
.evf-preview-content {
width: 100% !important;
max-width: 100% !important;
}`;
const style = doc.createElement( 'style' );
style.id = CHROME_STYLE_ID;
style.textContent = css;
doc.head.appendChild( style );
}
/** Injects the shared rule template, ID-scoped to the wrapper so v2 tokens always win. */
private injectRuleTemplate( doc: Document, done: () => void ) {
if ( doc.getElementById( TEMPLATE_STYLE_ID ) ) {
done();
return;
}
const id = this.store.settings.wrapperId;
fetchCss( this.store.settings.frontendCssUrl )
.then( ( text ) => {
if ( this.destroyed || doc.getElementById( TEMPLATE_STYLE_ID ) ) {
done();
return;
}
// `.evf-style-v2` (not followed by a name char) → `.evf-style-v2#evf-{id}`.
const scoped = text.replace( /\.evf-style-v2(?![\w-])/g, `.evf-style-v2#${ id }` );
const style = doc.createElement( 'style' );
style.id = TEMPLATE_STYLE_ID;
style.textContent = scoped;
doc.head.appendChild( style );
done();
} )
.catch( () => {
if ( this.destroyed || doc.getElementById( TEMPLATE_STYLE_ID ) ) {
done();
return;
}
// Last-resort fallback — ensure done() still fires so bootstrap() doesn't stall.
try {
const link = doc.createElement( 'link' );
link.id = TEMPLATE_STYLE_ID;
link.rel = 'stylesheet';
link.href = this.store.settings.frontendCssUrl;
link.onload = () => done();
link.onerror = () => done();
doc.head.appendChild( link );
} catch ( e ) {
done();
}
} );
}
/* --------------------------------------------------------------------- *
* Variable application
* --------------------------------------------------------------------- */
/** Re-apply everything (device switch / palette / undo / initial load). */
applyAll() {
if ( ! this.wrapper ) {
return;
}
const themeFont = this.store.themeFont();
this.store.schema.forEach( ( token ) => this.applyToken( token, themeFont ) );
this.ensureFont();
this.applyThemeStyle();
// Custom CSS lives outside the schema token loop above — without this, Reset/Undo/Redo
// (all of which notify(null) via resetAll()/applySnapshot()) leave a stale