PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.6.0
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.6.0
3.6.0 3.5.3 3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / src / style-customizer-v2 / PreviewBridge.ts
everest-forms / src / style-customizer-v2 Last commit date
AiAssistant.tsx 2 days ago App.tsx 2 days ago BuilderSync.ts 2 days ago ControlRenderer.tsx 2 days ago HoverTip.tsx 2 days ago Popover.tsx 2 days ago PreviewBridge.ts 2 days ago PreviewPane.tsx 2 days ago constants.ts 2 days ago cssVars.ts 2 days ago globals.d.ts 2 days ago index.tsx 2 days ago panes.tsx 2 days ago store.ts 2 days ago style.scss 2 days ago types.ts 2 days ago
PreviewBridge.ts
989 lines
1 /**
2 * Style Customizer v2 — live preview bridge. Owns the `?evf_preview` iframe: tags the form
3 * wrapper, injects the rule template, writes token values as CSS variables, and maps clicks
4 * inside the iframe back to their style section for click-to-edit.
5 */
6 import { getActiveSync } from './BuilderSync';
7 import { resolveValue, tokenDeclarations } from './cssVars';
8 import { ALL_FORCE_CLASSES, PREVIEW_TARGETS } from './constants';
9 import { StyleStore } from './store';
10 import { Token } from './types';
11
12 const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s );
13
14 const CUSTOM_STYLE_ID = 'evf-scv2-custom-css';
15 const TEMPLATE_STYLE_ID = 'evf-scv2-rule-template';
16 const CHROME_STYLE_ID = 'evf-scv2-chrome';
17 const SELECT_STYLE_ID = 'evf-scv2-select';
18 const DEVICE_STYLE_ID = 'evf-scv2-device';
19 const FONT_LINK_ID = 'evf-scv2-font';
20
21 /** Legacy `?evf_preview` theme-toggle class: adding it applies theme styling, removing it applies EVF's default. */
22 const PREVIEW_THEME_CLASS = 'evf-frontend-form-preview';
23
24 const HOVER_CLASS = 'evf-scv2-hover';
25 const SELECTED_CLASS = 'evf-scv2-selected';
26
27 /** Mirrors FrontendEnqueue::container_class()'s `evf-choice-{variation}` classes. */
28 const CHOICE_VARIATION_CLASSES = [ 'evf-choice-outline', 'evf-choice-filled' ];
29
30 /** Mirrors FrontendEnqueue::container_class()'s `evf-choice-align-{center|right}` classes. */
31 const CHOICE_ALIGN_CLASSES = [ 'evf-choice-align-center', 'evf-choice-align-right' ];
32
33 /** Mirrors EverestForms_MultiPart::field_submit_visibility_class()'s `everest-forms-nav-align--{value}` class. */
34 const PAGINATION_NAV_ALIGN_CLASSES = [
35 'everest-forms-nav-align--left',
36 'everest-forms-nav-align--right',
37 'everest-forms-nav-align--center',
38 'everest-forms-nav-align--split',
39 ];
40
41 // indicatorType can't be live-patched client-side — its themes render genuinely different child
42 // DOM per value (progress bar vs. an <ol>/<ul> of steps, see
43 // EverestForms_MultiPart::output_part_indicator()), so it needs the same server-reload path a
44 // Fields-tab edit uses. Every other pagination.* token (color/margin) has a real CSS var the
45 // static everest-forms-multi-part.css now reads directly, so those preview instantly like any
46 // other color/box4 token — no special-casing needed.
47 const PAGINATION_STRUCTURAL_KEYS = [ 'pagination.indicatorType' ];
48
49 /** Mirrors FrontendEnqueue::container_class()'s `evf-btn-width-fill` class. */
50 const BTN_WIDTH_FILL_CLASS = 'evf-btn-width-fill';
51
52 /** How long to keep polling for the form wrapper before giving up (ms). */
53 const READY_DEADLINE = 15000;
54 /** Poll interval while waiting for the wrapper (ms). */
55 const POLL_INTERVAL = 200;
56
57 export interface SelectionInfo {
58 section: string;
59 variant?: string;
60 label: string;
61 }
62
63 interface JQueryValidateLike {
64 fn: {
65 valid?: ( () => boolean ) & { evfScv2Patched?: boolean };
66 };
67 }
68
69 interface BridgeHandlers {
70 onReady: () => void;
71 onError: () => void;
72 onSelect?: ( info: SelectionInfo ) => void;
73 /** Any click inside the iframe's own document — used to close an open panel popover. */
74 onIframeClick?: () => void;
75 /** Ctrl/Cmd+Z and Ctrl+Shift+Z / Ctrl+Y pressed inside the iframe's own document — keyboard
76 * undo/redo would otherwise only work while focus is in the panel, never the preview. */
77 onUndo?: () => void;
78 onRedo?: () => void;
79 }
80
81 /** Module-level cache of the fetched rule-template CSS text, keyed by URL. */
82 const cssTextCache: Record< string, Promise< string > > = {};
83
84 function fetchCss( url: string ): Promise< string > {
85 if ( ! cssTextCache[ url ] ) {
86 cssTextCache[ url ] = fetch( url, { credentials: 'same-origin' } ).then( ( r ) => {
87 if ( ! r.ok ) {
88 throw new Error( 'css ' + r.status );
89 }
90 return r.text();
91 } );
92 }
93 return cssTextCache[ url ];
94 }
95
96 export class PreviewBridge {
97 private store: StyleStore;
98 private iframe: HTMLIFrameElement;
99 private wrapper: HTMLElement | null = null;
100 private ready = false;
101 private destroyed = false;
102 private onReady: () => void;
103 private onError: () => void;
104 private onSelect?: ( info: SelectionInfo ) => void;
105 private onIframeClick?: () => void;
106 private onUndo?: () => void;
107 private onRedo?: () => void;
108 private deadline = 0;
109 private pollTimer: ReturnType< typeof setTimeout > | null = null;
110 private selectedEl: HTMLElement | null = null;
111 private hoverEl: HTMLElement | null = null;
112 private previewedKeys: Set< string > = new Set();
113 private deviceWidth: number | null = null;
114 private currentForceClass: string | null = null;
115 private dummyMessageEl: HTMLElement | null = null;
116 private mutationObserver: MutationObserver | null = null;
117 private observedDoc: Document | null = null;
118 private mutationScheduled = false;
119 /** Last value actually sent to the server per PAGINATION_STRUCTURAL_KEYS key — lets applyKeys()
120 * skip the resync when the value didn't really change (e.g. a template hover/revert cycle,
121 * which always re-applies the whole schema including these keys, but never touches the store). */
122 private lastSyncedStructural: Record< string, unknown > = {};
123
124 constructor( iframe: HTMLIFrameElement, store: StyleStore, handlers: BridgeHandlers ) {
125 this.iframe = iframe;
126 this.store = store;
127 this.onReady = handlers.onReady;
128 this.onError = handlers.onError;
129 this.onSelect = handlers.onSelect;
130 this.onIframeClick = handlers.onIframeClick;
131 this.onUndo = handlers.onUndo;
132 this.onRedo = handlers.onRedo;
133 }
134
135 /** Wire onto the iframe's load event and begin polling for the wrapper. */
136 attach() {
137 this.deadline = Date.now() + READY_DEADLINE;
138 this.iframe.addEventListener( 'load', this.handleLoad );
139 // The frame may already be (or become) ready before/around listener attach.
140 this.poll();
141 }
142
143 detach() {
144 this.destroyed = true;
145 if ( this.pollTimer ) {
146 clearTimeout( this.pollTimer );
147 this.pollTimer = null;
148 }
149 this.stopWatching();
150 this.iframe.removeEventListener( 'load', this.handleLoad );
151 this.teardownSelection();
152 }
153
154 private handleLoad = () => {
155 // A fresh navigation inside the frame — re-arm and re-detect the wrapper.
156 this.ready = false;
157 this.wrapper = null;
158 this.stopWatching();
159 this.deadline = Date.now() + READY_DEADLINE;
160 this.poll();
161 };
162
163 /** Resolves the form wrapper by id, falling back to the base plugin's `.evf-container` div. */
164 private findWrapper( doc: Document ): HTMLElement | null {
165 const byId = doc.getElementById( this.store.settings.wrapperId );
166 if ( byId ) {
167 return byId;
168 }
169 const fallback = doc.querySelector( '.evf-container' ) as HTMLElement | null;
170 if ( fallback ) {
171 fallback.id = this.store.settings.wrapperId;
172 return fallback;
173 }
174 return null;
175 }
176
177 /** Watches the iframe document for the wrapper being inserted, faster than the fixed-interval poll. */
178 private watchForWrapper( doc: Document ) {
179 if ( this.observedDoc === doc && this.mutationObserver ) {
180 return;
181 }
182 this.stopWatching();
183 if ( ! doc.documentElement || typeof MutationObserver === 'undefined' ) {
184 return;
185 }
186 this.observedDoc = doc;
187 this.mutationObserver = new MutationObserver( () => this.onMutation( doc ) );
188 this.mutationObserver.observe( doc.documentElement, { childList: true, subtree: true } );
189 }
190
191 /** Coalesce bursts of mutations (a full page render fires many) into one check per frame. */
192 private onMutation( doc: Document ) {
193 if ( this.destroyed || this.ready || this.mutationScheduled ) {
194 return;
195 }
196 this.mutationScheduled = true;
197 requestAnimationFrame( () => {
198 this.mutationScheduled = false;
199 if ( this.destroyed || this.ready ) {
200 return;
201 }
202 try {
203 const wrapper = this.findWrapper( doc );
204 if ( wrapper ) {
205 this.bootstrap( doc, wrapper );
206 }
207 } catch ( e ) {
208 // Swallow — a monkey-patched DOM API (browser extensions) shouldn't wedge detection.
209 }
210 } );
211 }
212
213 private stopWatching() {
214 if ( this.mutationObserver ) {
215 this.mutationObserver.disconnect();
216 this.mutationObserver = null;
217 }
218 this.observedDoc = null;
219 }
220
221 /** Reload the preview page inside the iframe (used when the builder's form structure changes). */
222 reload() {
223 if ( this.destroyed ) {
224 return;
225 }
226 this.ready = false;
227 this.wrapper = null;
228 try {
229 const win = this.iframe.contentWindow;
230 if ( win ) {
231 win.location.reload();
232 return;
233 }
234 } catch ( e ) {
235 // fall through to the src reset below.
236 }
237 // eslint-disable-next-line no-self-assign
238 this.iframe.src = this.iframe.src;
239 }
240
241 /** Poll for the wrapper until found or the deadline passes. */
242 private poll = () => {
243 if ( this.destroyed || this.ready ) {
244 return;
245 }
246
247 let doc: Document | null = null;
248 try {
249 doc = this.iframe.contentDocument;
250 } catch ( e ) {
251 doc = null; // Transient during navigation, or cross-origin.
252 }
253
254 if ( doc ) {
255 try {
256 this.hideChrome( doc );
257 this.watchForWrapper( doc );
258
259 const wrapper = this.findWrapper( doc );
260 if ( wrapper ) {
261 this.bootstrap( doc, wrapper );
262 return;
263 }
264 } catch ( e ) {
265 // Swallow — a monkey-patched DOM API (browser extensions) shouldn't kill wrapper detection.
266 }
267 }
268
269 if ( Date.now() >= this.deadline ) {
270 if ( ! this.ready ) {
271 this.onError();
272 }
273 // Stop polling but leave the MutationObserver attached — a late wrapper still self-heals.
274 return;
275 }
276 this.pollTimer = setTimeout( this.poll, POLL_INTERVAL );
277 };
278
279 /** Wrapper found — tag it, inject rules, paint variables, wire selection. */
280 private bootstrap( doc: Document, wrapper: HTMLElement ) {
281 if ( this.pollTimer ) {
282 clearTimeout( this.pollTimer );
283 this.pollTimer = null;
284 }
285 this.stopWatching();
286 // A previous dummy message element lived in the old (reloaded) document; it's already gone.
287 this.dummyMessageEl = null;
288 this.wrapper = wrapper;
289 wrapper.classList.add( this.store.settings.markerClass );
290 this.disableLegacySheet( doc );
291 this.injectRuleTemplate( doc, () => {
292 if ( this.destroyed ) {
293 return;
294 }
295 // Extensions can throw mid-sequence; never leave the bridge stuck "not responding".
296 try {
297 this.applyAll();
298 this.applyCustomCss();
299 this.applyDeviceWidth();
300 this.applyForceClass();
301 this.injectSelectionStyles( doc );
302 this.setupSelection( doc );
303 } catch ( e ) {
304 // swallow — see comment above.
305 }
306 this.ready = true;
307 this.onReady();
308 } );
309 }
310
311 /** Neutralise the legacy per-form compiled stylesheet so v2 tokens always win. */
312 private disableLegacySheet( doc: Document ) {
313 const id = this.store.settings.formId;
314 if ( ! id ) {
315 return;
316 }
317 const needle = `everest_forms_styles/everest-forms-${ id }.css`;
318 doc.querySelectorAll( 'link[rel="stylesheet"]' ).forEach( ( node ) => {
319 const link = node as HTMLLinkElement;
320 if ( link.href && link.href.indexOf( needle ) !== -1 ) {
321 link.disabled = true;
322 link.remove();
323 }
324 } );
325 }
326
327 /** Hides the `?evf_preview` route's page chrome so only the form fills the frame. */
328 private hideChrome( doc: Document ) {
329 if ( ! doc.head || doc.getElementById( CHROME_STYLE_ID ) ) {
330 return;
331 }
332 const css = `
333 html {
334 margin-top: 0 !important;
335 /* Reserve the scrollbar's width in the layout up front, so a border on a
336 full-width child never falls short of (or is pushed past) the true edge once
337 content grows taller than the iframe's viewport. */
338 scrollbar-gutter: stable;
339 }
340 *, *::before, *::after { box-sizing: border-box; }
341 body { margin-top: 0 !important; padding-top: 0 !important; }
342 #wpadminbar,
343 #nav-menu-header,
344 .major-publishing-actions,
345 .evf-form-preview-dropdown-container,
346 .evf-form-preview-devices,
347 .evf-form-preview-sidepanel-toggler,
348 .evf-form-side-panel { display: none !important; }
349 body.evf-multi-device-form-preview { background: #fff !important; }
350 .evf-form-preview-main-content,
351 .evf-form-preview-overlay {
352 display: block !important;
353 position: static !important;
354 inset: auto !important;
355 margin: 0 !important;
356 padding: 12px !important;
357 width: 100% !important;
358 max-width: 100% !important;
359 min-height: 0 !important;
360 height: auto !important;
361 box-shadow: none !important;
362 background: transparent !important;
363 }
364 /* Below 992px (evf-form-preview.scss) .evf-form-preview-overlay grows a ::after dark
365 scrim (originally the "side panel is open, dim the content behind it" backdrop) —
366 and the template always renders BOTH classes combined on the same element, so this
367 is not conditional at all. The BUILDER'S iframe is very often narrower than 992px on
368 its own, so this triggered on nearly every device/window size — overriding the
369 parent's background above does nothing to it since it is a separate
370 absolutely-positioned pseudo-element box. */
371 .evf-form-preview-overlay::after { display: none !important; }
372 /* .evf-preview-content only — NOT .everest-forms.evf-frontend-form-preview, which used to
373 be grouped in here too. That rule zeroed the form's own 24px preview-card padding
374 specifically when "Apply Theme Style" was on, making the toggle look like it changes
375 the form's spacing. It doesn't: the real (non-preview) frontend has no such rule tied
376 to that toggle at all (see everest-forms-default-frontend.css) — this 24px is purely
377 this admin preview card's own decoration, unrelated to theme-style. */
378 .evf-preview-content { padding: 0 !important; }
379 .evf-form-preview-form {
380 width: 100% !important;
381 max-width: 100% !important;
382 margin: 0 !important;
383 padding: 0 !important;
384 }
385 .evf-preview-content {
386 width: 100% !important;
387 max-width: 100% !important;
388 }`;
389 const style = doc.createElement( 'style' );
390 style.id = CHROME_STYLE_ID;
391 style.textContent = css;
392 doc.head.appendChild( style );
393 }
394
395 /** Injects the shared rule template, ID-scoped to the wrapper so v2 tokens always win. */
396 private injectRuleTemplate( doc: Document, done: () => void ) {
397 if ( doc.getElementById( TEMPLATE_STYLE_ID ) ) {
398 done();
399 return;
400 }
401 const id = this.store.settings.wrapperId;
402 fetchCss( this.store.settings.frontendCssUrl )
403 .then( ( text ) => {
404 if ( this.destroyed || doc.getElementById( TEMPLATE_STYLE_ID ) ) {
405 done();
406 return;
407 }
408 // `.evf-style-v2` (not followed by a name char) → `.evf-style-v2#evf-{id}`.
409 const scoped = text.replace( /\.evf-style-v2(?![\w-])/g, `.evf-style-v2#${ id }` );
410 const style = doc.createElement( 'style' );
411 style.id = TEMPLATE_STYLE_ID;
412 style.textContent = scoped;
413 doc.head.appendChild( style );
414 done();
415 } )
416 .catch( () => {
417 if ( this.destroyed || doc.getElementById( TEMPLATE_STYLE_ID ) ) {
418 done();
419 return;
420 }
421 // Last-resort fallback — ensure done() still fires so bootstrap() doesn't stall.
422 try {
423 const link = doc.createElement( 'link' );
424 link.id = TEMPLATE_STYLE_ID;
425 link.rel = 'stylesheet';
426 link.href = this.store.settings.frontendCssUrl;
427 link.onload = () => done();
428 link.onerror = () => done();
429 doc.head.appendChild( link );
430 } catch ( e ) {
431 done();
432 }
433 } );
434 }
435
436 /* --------------------------------------------------------------------- *
437 * Variable application
438 * --------------------------------------------------------------------- */
439
440 /** Re-apply everything (device switch / palette / undo / initial load). */
441 applyAll() {
442 if ( ! this.wrapper ) {
443 return;
444 }
445 const themeFont = this.store.themeFont();
446 this.store.schema.forEach( ( token ) => this.applyToken( token, themeFont ) );
447 this.ensureFont();
448 this.applyThemeStyle();
449 // Custom CSS lives outside the schema token loop above — without this, Reset/Undo/Redo
450 // (all of which notify(null) via resetAll()/applySnapshot()) leave a stale <style> tag in
451 // the iframe even after store.customCss has already changed.
452 this.applyCustomCss();
453 }
454
455 /** Apply only the given token keys (a targeted live edit). */
456 applyKeys( keys: string[] ) {
457 if ( ! this.wrapper || ! keys.length ) {
458 return;
459 }
460 const themeFont = this.store.themeFont();
461 keys.forEach( ( key ) => {
462 const token = this.store.byKey[ key ];
463 if ( token ) {
464 this.applyToken( token, themeFont );
465 }
466 } );
467 // The font family/theme-font toggle needs the Google-font stylesheet (re)loaded.
468 if ( keys.indexOf( 'fonts.family' ) !== -1 || keys.indexOf( 'fonts.theme' ) !== -1 ) {
469 this.ensureFont();
470 }
471 // Ask the server to re-render the pagination indicator, same as a Fields-tab edit does —
472 // see PAGINATION_STRUCTURAL_KEYS. Scoped to this targeted-edit path only (never
473 // applyAll()'s bulk re-apply, which also runs on device switch/undo/bootstrap) so this
474 // can't loop or fire needlessly.
475 PAGINATION_STRUCTURAL_KEYS.forEach( ( key ) => {
476 if ( keys.indexOf( key ) === -1 ) {
477 return;
478 }
479 const token = this.store.byKey[ key ];
480 if ( token ) {
481 const value = resolveValue( this.store.tokens[ token.key ], token, this.store.device );
482 // applyKeys() also runs on a template hover/revert cycle (previewedKeys always covers
483 // the whole schema), which never touches the store — skip the round-trip when the
484 // server already has this exact value, so hovering a template can't spam reloads.
485 if ( this.lastSyncedStructural[ key ] === value ) {
486 return;
487 }
488 this.lastSyncedStructural[ key ] = value;
489 getActiveSync()?.syncStyleToken( token.key, value );
490 }
491 } );
492 }
493
494 /**
495 * Loads (or removes) the selected Google font inside the preview. A template hover passes its
496 * own not-yet-committed family/theme-font instead of reading the store — otherwise the CSS var
497 * flips to the template's font correctly (see previewValues()) but the webfont file is never
498 * fetched, so the browser silently falls back to a system font and the hover looks like it did
499 * nothing.
500 */
501 private ensureFont( overrideFamily?: string, overrideThemeFont?: boolean ) {
502 const wrapper = this.wrapper;
503 if ( ! wrapper ) {
504 return;
505 }
506 const doc = wrapper.ownerDocument;
507 const token = this.store.byKey[ 'fonts.family' ];
508 const family =
509 overrideFamily !== undefined
510 ? overrideFamily.trim()
511 : token
512 ? String( resolveValue( this.store.tokens[ 'fonts.family' ], token, 'desktop' ) || '' ).trim()
513 : '';
514 const themeFont = overrideThemeFont !== undefined ? overrideThemeFont : this.store.themeFont();
515 let link = doc.getElementById( FONT_LINK_ID ) as HTMLLinkElement | null;
516
517 if ( themeFont || ! family ) {
518 if ( link ) {
519 link.remove();
520 }
521 return;
522 }
523 // Explicit weights, matching Schema::weight_options() (EVF-2721) — otherwise Google Fonts
524 // only serves the family's single default face and every other Font Style weight falls
525 // back to inconsistent browser synthesis.
526 const href = 'https://fonts.googleapis.com/css?family=' + encodeURIComponent( family ) + ':300,400,700';
527 if ( ! link ) {
528 link = doc.createElement( 'link' );
529 link.id = FONT_LINK_ID;
530 link.rel = 'stylesheet';
531 doc.head.appendChild( link );
532 }
533 if ( link.href !== href ) {
534 link.href = href;
535 }
536 }
537
538 /** Reflects "Apply Theme Style" live, mirroring the legacy `?evf_preview` toggle. */
539 private applyThemeStyle() {
540 const wrapper = this.wrapper;
541 if ( ! wrapper ) {
542 return;
543 }
544 const outer = ( wrapper.closest( '.everest-forms' ) as HTMLElement | null ) || wrapper.parentElement || wrapper;
545 outer.classList.toggle( PREVIEW_THEME_CLASS, this.store.applyThemeStyle );
546 }
547
548 private applyToken( token: Token, themeFont: boolean ) {
549 const wrapper = this.wrapper;
550 if ( ! wrapper ) {
551 return;
552 }
553 const value = resolveValue( this.store.tokens[ token.key ], token, this.store.device );
554 const decls = tokenDeclarations( token, value, themeFont );
555
556 // Clear this token's variables first so an emptied value doesn't leave a stale one.
557 this.varsFor( token ).forEach( ( v ) => wrapper.style.removeProperty( v ) );
558 decls.forEach( ( [ name, val ] ) => wrapper.style.setProperty( name, val ) );
559
560 // choice.variation is a "meta" token (no CSS var) — it drives a wrapper class instead.
561 if ( token.key === 'choice.variation' ) {
562 CHOICE_VARIATION_CLASSES.forEach( ( c ) => wrapper.classList.remove( c ) );
563 if ( value === 'outline' || value === 'filled' ) {
564 wrapper.classList.add( `evf-choice-${ value }` );
565 }
566 }
567
568 // choice.align DOES have a CSS var (used by every other choice type's inherited
569 // text-align), but the Subscription Plan card's name/price row can't be reached by
570 // text-align at all (it's a fixed `space-between` flex row) — mirror the same class
571 // bridge as choice.variation, purely as a supplementary hook for that one field.
572 if ( token.key === 'choice.align' ) {
573 CHOICE_ALIGN_CLASSES.forEach( ( c ) => wrapper.classList.remove( c ) );
574 if ( value === 'center' || value === 'right' ) {
575 wrapper.classList.add( `evf-choice-align-${ value }` );
576 }
577 }
578
579 // pagination.navAlign is a Multi-Part "meta" token (no CSS var) — it's pure positioning on
580 // markup that already exists regardless of value, so (unlike indicatorType, whose themes
581 // render genuinely different child DOM) a class toggle is enough for live preview. Applied
582 // to the nav container itself, matching EverestForms_MultiPart::field_submit_visibility_class().
583 if ( token.key === 'pagination.navAlign' ) {
584 const nav = wrapper.querySelector( '.everest-forms-multi-part-actions' );
585 if ( nav ) {
586 PAGINATION_NAV_ALIGN_CLASSES.forEach( ( c ) => nav.classList.remove( c ) );
587 if ( typeof value === 'string' && value ) {
588 nav.classList.add( `everest-forms-nav-align--${ value }` );
589 }
590 }
591 }
592
593 // btn.widthMode is another "meta" token (no CSS var) — same class-bridge pattern.
594 if ( token.key === 'btn.widthMode' ) {
595 wrapper.classList.remove( BTN_WIDTH_FILL_CLASS );
596 if ( value === 'fill' ) {
597 wrapper.classList.add( BTN_WIDTH_FILL_CLASS );
598 }
599 }
600 }
601
602 /** Every CSS var a token can set (font-style expands to four). */
603 private varsFor( token: Token ): string[] {
604 if ( token.type === 'fontstyle' && token.vars ) {
605 return Object.values( token.vars );
606 }
607 return token.var ? [ token.var ] : [];
608 }
609
610 /**
611 * Constrain the form's content width inside the iframe to simulate a device.
612 * @param width Device content width in px, or null for full width.
613 */
614 setDeviceWidth( width: number | null ) {
615 this.deviceWidth = width && width > 0 ? width : null;
616 this.applyDeviceWidth();
617 }
618
619 private applyDeviceWidth() {
620 const doc = this.wrapper ? this.wrapper.ownerDocument : null;
621 if ( ! doc || ! doc.head ) {
622 return;
623 }
624 let style = doc.getElementById( DEVICE_STYLE_ID ) as HTMLStyleElement | null;
625 if ( this.deviceWidth === null ) {
626 if ( style ) {
627 style.remove();
628 }
629 return;
630 }
631 if ( ! style ) {
632 style = doc.createElement( 'style' );
633 style.id = DEVICE_STYLE_ID;
634 doc.head.appendChild( style );
635 }
636 style.textContent = `.evf-form-preview-form,.evf-preview-content{max-width:${ this.deviceWidth }px!important;margin-left:auto!important;margin-right:auto!important;transition:max-width .25s ease;}`;
637 }
638
639 /** Toggle a single force-state class (focus/hover/message) for state previews. */
640 setForceClass( cls: string | null ) {
641 this.currentForceClass = cls;
642 this.applyForceClass();
643 }
644
645 /** Re-applies the currently active force-state class (also called on every bootstrap after a reload). */
646 private applyForceClass() {
647 if ( ! this.wrapper ) {
648 return;
649 }
650 ALL_FORCE_CLASSES.forEach( ( c ) => this.wrapper!.classList.remove( c ) );
651 if ( this.currentForceClass ) {
652 this.wrapper.classList.add( this.currentForceClass );
653 }
654 this.setDummyMessage( this.currentForceClass );
655 }
656
657 /** Injects a throwaway instance of EVF's real notice/error markup so its styling can be previewed live. */
658 private setDummyMessage( cls: string | null ) {
659 if ( this.dummyMessageEl ) {
660 this.dummyMessageEl.remove();
661 this.dummyMessageEl = null;
662 }
663 const wrapper = this.wrapper;
664 if ( ! wrapper || ! cls ) {
665 return;
666 }
667 const doc = wrapper.ownerDocument;
668 let el: HTMLElement | null = null;
669
670 if ( 'force-msg-success' === cls ) {
671 el = doc.createElement( 'div' );
672 el.className = 'everest-forms-notice everest-forms-notice--success';
673 el.setAttribute( 'role', 'alert' );
674 el.textContent = __( 'Thanks! Your submission has been received.', 'everest-forms' );
675 } else if ( 'force-msg-error' === cls ) {
676 el = doc.createElement( 'div' );
677 el.className = 'everest-forms-notice everest-forms-notice--error';
678 el.setAttribute( 'role', 'alert' );
679 el.textContent = __( 'There was a problem with your submission. Please review the fields below.', 'everest-forms' );
680 } else if ( 'force-msg-validation' === cls ) {
681 el = doc.createElement( 'label' );
682 el.className = 'everest-forms-error evf-error';
683 el.textContent = __( 'This field is required.', 'everest-forms' );
684 }
685 if ( ! el ) {
686 return;
687 }
688 el.setAttribute( 'data-evf-scv2-dummy', '1' );
689
690 if ( 'force-msg-validation' === cls ) {
691 // Mirrors the real inline-validation placement (assets/js/frontend/ajax-submission.js):
692 // the error label lands right after the field's input (or its .input-wrapper), inside
693 // the FIRST field itself. `.evf-frontend-row` is an ANCESTOR of `.evf-field` (a row can
694 // hold several fields side by side via a grid), so a combined `.evf-field, .evf-frontend-row`
695 // selector matched the row first in document order — landing the message after the whole
696 // row, or the whole form when a row wrapper wasn't found at all.
697 const field = wrapper.querySelector( '.evf-field' );
698 const control = field ? field.querySelector( '.input-wrapper, input, select, textarea' ) : null;
699 if ( control ) {
700 control.insertAdjacentElement( 'afterend', el );
701 } else {
702 ( field || wrapper ).appendChild( el );
703 }
704 } else {
705 wrapper.insertBefore( el, wrapper.firstChild );
706 }
707 this.dummyMessageEl = el;
708 }
709
710 /** Live-apply the current custom CSS into the iframe (save-time scoping happens server-side). */
711 applyCustomCss() {
712 const doc = this.wrapper ? this.wrapper.ownerDocument : null;
713 if ( ! doc ) {
714 return;
715 }
716 let style = doc.getElementById( CUSTOM_STYLE_ID ) as HTMLStyleElement | null;
717 const css = this.store.customCss || '';
718 if ( ! css ) {
719 if ( style ) {
720 style.remove();
721 }
722 return;
723 }
724 if ( ! style ) {
725 style = doc.createElement( 'style' );
726 style.id = CUSTOM_STYLE_ID;
727 doc.head.appendChild( style );
728 }
729 style.textContent = css;
730 }
731
732 /** Temporarily paints token values onto the preview without touching the store (template hover previews). */
733 previewValues( overrides: Record< string, unknown > ) {
734 if ( ! this.wrapper ) {
735 return;
736 }
737 // Use THIS preview's own fonts.theme (a template hover always carries one — see
738 // flattenForPreview() in panes.tsx) rather than the store's current, not-yet-applied value —
739 // otherwise a template that turns theme-font off still previews with it forced on, because
740 // clicking would change fonts.theme but hovering never touches the store at all.
741 const themeFont = this.store.themeFont( overrides[ 'fonts.theme' ] as boolean | undefined );
742 Object.entries( overrides ).forEach( ( [ key, value ] ) => {
743 const token = this.store.byKey[ key ];
744 if ( ! token ) {
745 return;
746 }
747 this.previewedKeys.add( key );
748 this.varsFor( token ).forEach( ( v ) => this.wrapper!.style.removeProperty( v ) );
749 tokenDeclarations( token, value as never, themeFont ).forEach( ( [ name, val ] ) =>
750 this.wrapper!.style.setProperty( name, val )
751 );
752 } );
753 // The var above is enough for every other property, but a font also needs its webfont
754 // FILE loaded — without this the var flips correctly but the browser has nothing to render
755 // it with and silently falls back to a system font, looking like the hover did nothing.
756 if ( 'fonts.family' in overrides || 'fonts.theme' in overrides ) {
757 this.ensureFont( overrides[ 'fonts.family' ] as string | undefined, themeFont );
758 }
759 }
760
761 /** Preview a palette's colours live (hover) without committing to the store. */
762 previewPalette( colors: Record< string, string > ) {
763 if ( ! this.wrapper ) {
764 return;
765 }
766 Object.entries( this.store.paletteMap ).forEach( ( [ slot, keys ] ) => {
767 const color = colors[ slot ];
768 if ( color === undefined ) {
769 return;
770 }
771 keys.forEach( ( key ) => {
772 const token = this.store.byKey[ key ];
773 if ( token && token.var ) {
774 this.previewedKeys.add( key );
775 this.wrapper!.style.setProperty( token.var, color );
776 }
777 } );
778 } );
779 }
780
781 /** Restore the committed store state after a hover preview (only the previewed keys). */
782 revert() {
783 if ( ! this.wrapper || ! this.previewedKeys.size ) {
784 return;
785 }
786 const keys = Array.from( this.previewedKeys );
787 this.previewedKeys.clear();
788 this.applyKeys( keys );
789 }
790
791 /* --------------------------------------------------------------------- *
792 * Element selection (click-to-edit)
793 * --------------------------------------------------------------------- */
794
795 private injectSelectionStyles( doc: Document ) {
796 if ( doc.getElementById( SELECT_STYLE_ID ) ) {
797 return;
798 }
799 // pointer-events guards against a theme/plugin reset rule disabling hit-testing in the preview.
800 const css = `
801 #${ this.store.settings.wrapperId }, #${ this.store.settings.wrapperId } * { pointer-events: auto !important; }
802 #${ this.store.settings.wrapperId } * { transition: outline-color .12s ease, outline-offset .12s ease; }
803 .${ HOVER_CLASS } { outline: 1px dashed rgba(117,69,187,.35) !important; outline-offset: 2px !important; border-radius: 3px; }
804 .${ SELECTED_CLASS } { outline: 1.5px solid rgba(117,69,187,.5) !important; outline-offset: 2px !important; border-radius: 3px; }
805 #${ this.store.settings.wrapperId } * { cursor: default; }
806 @media (prefers-reduced-motion: reduce) {
807 #${ this.store.settings.wrapperId } * { transition: none !important; }
808 }`;
809 const style = doc.createElement( 'style' );
810 style.id = SELECT_STYLE_ID;
811 style.textContent = css;
812 doc.head.appendChild( style );
813 }
814
815 private setupSelection( doc: Document ) {
816 doc.addEventListener( 'click', this.onDocClick, true );
817 doc.addEventListener( 'mouseover', this.onDocOver, true );
818 doc.addEventListener( 'mouseout', this.onDocOut, true );
819 // Block real form submission / link navigation inside the style preview.
820 doc.addEventListener( 'submit', this.blockEvent, true );
821 doc.addEventListener( 'keydown', this.onDocKeyDown, true );
822 this.neutralizeMultiPartValidation( doc );
823 }
824
825 /** Ctrl/Cmd+Z and Ctrl+Shift+Z / Ctrl+Y — mirrors the panel's own shortcut so undo/redo works
826 * no matter which side (panel or preview) currently has focus. */
827 private onDocKeyDown = ( e: KeyboardEvent ) => {
828 const target = e.target as HTMLElement;
829 if ( target && /^(INPUT|TEXTAREA|SELECT)$/.test( target.tagName ) ) {
830 return;
831 }
832 if ( ( e.ctrlKey || e.metaKey ) && e.key.toLowerCase() === 'z' ) {
833 e.preventDefault();
834 if ( e.shiftKey ) {
835 this.onRedo?.();
836 } else {
837 this.onUndo?.();
838 }
839 return;
840 }
841 if ( e.ctrlKey && ! e.metaKey && e.key.toLowerCase() === 'y' ) {
842 e.preventDefault();
843 this.onRedo?.();
844 }
845 };
846
847 /** Patches jQuery-validate's `.valid()` to always pass, so Multi-Part's "Next" works without filling required fields. */
848 private neutralizeMultiPartValidation( doc: Document ) {
849 const $ = ( doc.defaultView as ( Window & { jQuery?: JQueryValidateLike } ) | null )?.jQuery;
850 if ( ! $ || typeof $.fn.valid !== 'function' || $.fn.valid.evfScv2Patched ) {
851 return;
852 }
853 const alwaysValid = () => true;
854 alwaysValid.evfScv2Patched = true;
855 $.fn.valid = alwaysValid;
856 }
857
858 private teardownSelection() {
859 let doc: Document | null = null;
860 try {
861 doc = this.iframe.contentDocument;
862 } catch ( e ) {
863 doc = null;
864 }
865 if ( ! doc ) {
866 return;
867 }
868 doc.removeEventListener( 'click', this.onDocClick, true );
869 doc.removeEventListener( 'mouseover', this.onDocOver, true );
870 doc.removeEventListener( 'mouseout', this.onDocOut, true );
871 doc.removeEventListener( 'submit', this.blockEvent, true );
872 doc.removeEventListener( 'keydown', this.onDocKeyDown, true );
873 }
874
875 private blockEvent = ( e: Event ) => {
876 e.preventDefault();
877 e.stopPropagation();
878 };
879
880 /** Resolve a clicked element to its style target by walking the ordered selector list. */
881 private resolveTarget( el: Element ): { info: SelectionInfo; el: HTMLElement } | null {
882 const wrapper = this.wrapper;
883 if ( ! wrapper ) {
884 return null;
885 }
886 const navBtn = el.closest( '.everest-forms-part-button' );
887 if ( navBtn && wrapper.contains( navBtn ) ) {
888 return null;
889 }
890 for ( const target of PREVIEW_TARGETS ) {
891 const match = el.closest( target.match ) as HTMLElement | null;
892 if ( match && wrapper.contains( match ) ) {
893 return { info: { section: target.section, variant: target.variant, label: target.label }, el: match };
894 }
895 }
896 if ( wrapper.contains( el ) ) {
897 return { info: { section: 'form', label: 'Form container' }, el: wrapper };
898 }
899 return null;
900 }
901
902 private onDocClick = ( e: MouseEvent ) => {
903 // Fires for every click, even ones that don't resolve to a style target.
904 if ( this.onIframeClick ) {
905 this.onIframeClick();
906 }
907 const target = e.target as Element | null;
908 if ( ! target || ! this.wrapper ) {
909 return;
910 }
911 const resolved = this.resolveTarget( target );
912 if ( ! resolved ) {
913 return;
914 }
915 // Never let the preview navigate away or submit.
916 e.preventDefault();
917 e.stopPropagation();
918 this.setSelected( resolved.el );
919 if ( this.onSelect ) {
920 this.onSelect( resolved.info );
921 }
922 };
923
924 private onDocOver = ( e: MouseEvent ) => {
925 const target = e.target as Element | null;
926 if ( ! target || ! this.wrapper ) {
927 return;
928 }
929 const resolved = this.resolveTarget( target );
930 if ( resolved && resolved.el !== this.selectedEl ) {
931 this.setHover( resolved.el );
932 } else {
933 this.setHover( null );
934 }
935 };
936
937 private onDocOut = () => {
938 this.setHover( null );
939 };
940
941 private setHover( el: HTMLElement | null ) {
942 if ( this.hoverEl === el ) {
943 return;
944 }
945 if ( this.hoverEl ) {
946 this.hoverEl.classList.remove( HOVER_CLASS );
947 }
948 this.hoverEl = el;
949 if ( el && el !== this.selectedEl ) {
950 el.classList.add( HOVER_CLASS );
951 }
952 }
953
954 private setSelected( el: HTMLElement | null ) {
955 if ( this.selectedEl ) {
956 this.selectedEl.classList.remove( SELECTED_CLASS );
957 }
958 this.selectedEl = el;
959 if ( el ) {
960 el.classList.remove( HOVER_CLASS );
961 el.classList.add( SELECTED_CLASS );
962 }
963 }
964
965 /** Clear any selection outline (e.g. when the panel navigates back to the list). */
966 clearSelection() {
967 this.setSelected( null );
968 this.setHover( null );
969 }
970
971 isReady(): boolean {
972 return this.ready;
973 }
974
975 getWrapper(): HTMLElement | null {
976 return this.wrapper;
977 }
978 }
979
980 let active: PreviewBridge | null = null;
981
982 export function setActiveBridge( bridge: PreviewBridge | null ) {
983 active = bridge;
984 }
985
986 export function getActiveBridge(): PreviewBridge | null {
987 return active;
988 }
989