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 / PreviewPane.tsx
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
PreviewPane.tsx
467 lines
1 /**
2 * Style Customizer v2 — live preview pane. Owns the `?evf_preview` iframe and its bridge,
3 * plus the device switcher, save-lifecycle state, skeleton loader, and error card.
4 */
5 import React from 'react';
6 import { DEVICE_ICONS, DEVICE_LABELS } from './constants';
7 import { HoverTip } from './HoverTip';
8 import { PreviewBridge, SelectionInfo, setActiveBridge } from './PreviewBridge';
9 import { BuilderSync, getActiveSync, setActiveSync } from './BuilderSync';
10 import { getStore, useStore } from './store';
11 import { Device } from './types';
12
13 const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s );
14
15 type PreviewStatus = 'loading' | 'ready' | 'error';
16
17 const MIGRATION_DISMISS_PREFIX = 'evf_scv2_migration_dismissed_';
18
19 /** Notice that this form's legacy styles were auto-migrated. Dismisses once per form. */
20 function MigrationNotice() {
21 const store = useStore();
22 const dismissKey = MIGRATION_DISMISS_PREFIX + store.settings.formId;
23 const [ dismissed, setDismissed ] = React.useState( () => {
24 try {
25 return window.localStorage.getItem( dismissKey ) === '1';
26 } catch ( e ) {
27 return false;
28 }
29 } );
30
31 if ( ! store.migration?.just_migrated || dismissed ) {
32 return null;
33 }
34
35 return (
36 <div className="pv-migration" role="status">
37 <span className="pv-migration-ic" aria-hidden="true">
38 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 }>
39 <circle cx="12" cy="12" r="9" />
40 <path d="M12 8h.01M11 12h1v4h1" />
41 </svg>
42 </span>
43 <span className="pv-migration-text">
44 <b>{ __( 'Styles upgraded from the legacy editor.', 'everest-forms' ) }</b>{ ' ' }
45 { __(
46 'Nothing should look different — review the preview below, then hit Save to keep it.',
47 'everest-forms'
48 ) }
49 </span>
50 <button
51 type="button"
52 aria-label={ __( 'Dismiss', 'everest-forms' ) }
53 onClick={ () => {
54 setDismissed( true );
55 try {
56 window.localStorage.setItem( dismissKey, '1' );
57 } catch ( e ) {
58 // Best-effort only — worst case it reappears next visit.
59 }
60 } }
61 >
62 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 } aria-hidden="true">
63 <path d="M18 6 6 18M6 6l12 12" />
64 </svg>
65 </button>
66 </div>
67 );
68 }
69
70 /** Notice that the Fields or Settings tab has changes not yet saved — shows/hides live as that changes. */
71 function UnsavedFieldsNotice() {
72 const store = useStore();
73 const [ dismissed, setDismissed ] = React.useState( false );
74
75 React.useEffect( () => {
76 if ( store.hasUnsavedFieldChanges ) {
77 setDismissed( false );
78 }
79 }, [ store.hasUnsavedFieldChanges ] );
80
81 if ( ! store.hasUnsavedFieldChanges || dismissed ) {
82 return null;
83 }
84
85 return (
86 <div className="pv-migration pv-unsaved-fields" role="status">
87 <span className="pv-migration-ic" aria-hidden="true">
88 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 }>
89 <circle cx="12" cy="12" r="9" />
90 <path d="M12 8h.01M11 12h1v4h1" />
91 </svg>
92 </span>
93 <span className="pv-migration-text">
94 <b>{ __( 'Previewing unsaved changes.', 'everest-forms' ) }</b>{ ' ' }
95 { __( 'From the Fields or Settings tab.', 'everest-forms' ) }
96 </span>
97 <button
98 type="button"
99 aria-label={ __( 'Dismiss', 'everest-forms' ) }
100 onClick={ () => setDismissed( true ) }
101 >
102 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 } aria-hidden="true">
103 <path d="M18 6 6 18M6 6l12 12" />
104 </svg>
105 </button>
106 </div>
107 );
108 }
109
110 /** Preview loader (skeleton form + spinner), shared with the panel bootstrap in index.tsx. */
111 export function PreviewSkeleton( { note }: { note?: string } ) {
112 return (
113 <div className="pv-skel" aria-hidden="true">
114 <div className="skel-card">
115 <div className="skel-bar" style={ { width: '38%' } } />
116 <div className="skel-bar" style={ { width: '100%', height: 38 } } />
117 <div className="skel-bar" style={ { width: '62%' } } />
118 <div className="skel-bar" style={ { width: '100%', height: 38 } } />
119 <div className="skel-bar" style={ { width: '30%', height: 34, marginBottom: 0 } } />
120 </div>
121 <span className="skel-note">
122 <span className="spin" /> { note || __( 'Loading your live preview…', 'everest-forms' ) }
123 </span>
124 </div>
125 );
126 }
127
128 const DEVICE_ORDER: Device[] = [ 'desktop', 'tablet', 'mobile' ];
129
130 /** Content width per device: desktop = full width (null), tablet/mobile = a fixed px below the breakpoint. */
131 function deviceContentWidth( device: Device, breakpoints: Record< string, number > ): number | null {
132 if ( device === 'desktop' ) {
133 return null;
134 }
135 const bp = breakpoints[ device ];
136 return device === 'mobile' ? Math.min( 400, bp || 480 ) : Math.min( 768, bp || 768 );
137 }
138
139 export function PreviewPane( {
140 forceClass,
141 saving,
142 dirty,
143 saveError,
144 saveErrorConflict,
145 onSelect,
146 onIframeClick,
147 onUndo,
148 onRedo,
149 toast,
150 onToastPause,
151 onToastResume,
152 }: {
153 forceClass: string | null;
154 saving: boolean;
155 dirty: boolean;
156 saveError: string;
157 saveErrorConflict: boolean;
158 onSelect: ( info: SelectionInfo ) => void;
159 onIframeClick: () => void;
160 onUndo: () => void;
161 onRedo: () => void;
162 toast: { msg: string; actLabel?: string; onAct?: () => void; kind?: 'success' | 'info' } | null;
163 onToastPause: () => void;
164 onToastResume: () => void;
165 } ) {
166 const store = useStore();
167 const iframeRef = React.useRef< HTMLIFrameElement >( null );
168 const bridgeRef = React.useRef< PreviewBridge | null >( null );
169 const [ status, setStatus ] = React.useState< PreviewStatus >( 'loading' );
170 const [ reloadKey, setReloadKey ] = React.useState( 0 );
171 // Whether the live-edit bridge is actually wired (click-to-edit, hover) — separate from
172 // `status`, which can be force-flipped to 'ready' even when the bridge never finishes.
173 const [ interactive, setInteractive ] = React.useState( false );
174 const [ interactiveStalled, setInteractiveStalled ] = React.useState( false );
175
176 // Always call the latest onSelect/onIframeClick without re-creating the bridge.
177 const onSelectRef = React.useRef( onSelect );
178 onSelectRef.current = onSelect;
179 const onIframeClickRef = React.useRef( onIframeClick );
180 onIframeClickRef.current = onIframeClick;
181 const onUndoRef = React.useRef( onUndo );
182 onUndoRef.current = onUndo;
183 const onRedoRef = React.useRef( onRedo );
184 onRedoRef.current = onRedo;
185
186 // Force the iframe to the current window's exact origin, so a host/scheme mismatch with the
187 // server-computed preview URL never makes the iframe cross-origin (contentDocument would throw).
188 const previewSrc = React.useMemo( () => {
189 try {
190 const url = new URL( store.settings.previewUrl, window.location.href );
191 url.protocol = window.location.protocol;
192 url.host = window.location.host;
193 return url.href;
194 } catch ( e ) {
195 return store.settings.previewUrl;
196 }
197 // eslint-disable-next-line react-hooks/exhaustive-deps
198 }, [] );
199
200 // Create the bridge once the iframe is mounted; subscribe the store to live edits.
201 React.useEffect( () => {
202 const iframe = iframeRef.current;
203 if ( ! iframe ) {
204 return;
205 }
206 setStatus( 'loading' );
207 setInteractive( false );
208 setInteractiveStalled( false );
209 const s = getStore();
210 const bridge = new PreviewBridge( iframe, s, {
211 onReady: () => {
212 setStatus( 'ready' );
213 setInteractive( true );
214 setInteractiveStalled( false );
215 getActiveSync()?.onBridgeReady();
216 },
217 onError: () => setStatus( ( prev ) => ( prev === 'ready' ? prev : 'error' ) ),
218 onSelect: ( info ) => onSelectRef.current( info ),
219 onIframeClick: () => onIframeClickRef.current(),
220 onUndo: () => onUndoRef.current(),
221 onRedo: () => onRedoRef.current(),
222 } );
223 bridgeRef.current = bridge;
224 setActiveBridge( bridge );
225 bridge.attach();
226
227 // Some Chrome setups fail to flip status via the load event/poll — force a reveal so the
228 // preview is always shown, with or without the live-edit bridge.
229 const revealTimer = window.setTimeout( () => {
230 setStatus( ( prev ) => ( prev === 'loading' ? 'ready' : prev ) );
231 }, 2500 );
232
233 const interactiveStallTimer = window.setTimeout( () => {
234 setInteractive( ( cur ) => {
235 if ( ! cur ) {
236 setInteractiveStalled( true );
237 }
238 return cur;
239 } );
240 }, 16000 );
241
242 const unsubscribe = s.subscribe( () => {
243 const affected = s.affected;
244 if ( affected === null ) {
245 bridge.applyAll();
246 } else if ( affected.length === 0 ) {
247 bridge.applyCustomCss(); // custom-css / saved marker — no token vars moved.
248 } else {
249 bridge.applyKeys( affected );
250 }
251 } );
252
253 return () => {
254 window.clearTimeout( revealTimer );
255 window.clearTimeout( interactiveStallTimer );
256 unsubscribe();
257 bridge.detach();
258 setActiveBridge( null );
259 bridgeRef.current = null;
260 };
261 // eslint-disable-next-line react-hooks/exhaustive-deps
262 }, [ reloadKey ] );
263
264 // Fields ↔ Style live sync: keeps the preview rendering the builder's CURRENT structure
265 // (unsaved edits included). Mounted once — it reads the active bridge on demand, so it
266 // survives bridge remounts (retry/device switch) without being torn down.
267 React.useEffect( () => {
268 const sync = new BuilderSync( getStore() );
269 setActiveSync( sync );
270 sync.start();
271 return () => {
272 sync.stop();
273 setActiveSync( null );
274 };
275 }, [] );
276
277 // Reflect the active force-state (focus/hover/message) onto the preview.
278 React.useEffect( () => {
279 if ( status === 'ready' && bridgeRef.current ) {
280 bridgeRef.current.setForceClass( forceClass );
281 }
282 }, [ forceClass, status ] );
283
284 // Constrain the iframe's form content to the active device width (outer pane stays full).
285 const contentWidth = deviceContentWidth( store.device, store.breakpoints );
286 React.useEffect( () => {
287 if ( status === 'ready' && bridgeRef.current ) {
288 bridgeRef.current.setDeviceWidth( contentWidth );
289 }
290 }, [ contentWidth, status ] );
291
292 const retry = () => {
293 setStatus( 'loading' );
294 setReloadKey( ( k ) => k + 1 ); // Remount the iframe → fresh load + bridge.
295 };
296
297 return (
298 <>
299 <MigrationNotice />
300 <UnsavedFieldsNotice />
301 <section className="preview" aria-label={ __( 'Live preview', 'everest-forms' ) }>
302 <div className="pv-bar">
303 <span className="ttl">
304 { __( 'Live preview', 'everest-forms' ) }
305 <HoverTip
306 className="info"
307 label={ __( 'About the preview', 'everest-forms' ) }
308 tip={ __(
309 'This renders your real form through your active theme (the front-end preview route), so it is the truth — click any element to jump to its styles.',
310 'everest-forms'
311 ) }
312 >
313 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
314 <path
315 fillRule="evenodd"
316 clipRule="evenodd"
317 d="M21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21C16.9706 21 21 16.9706 21 12ZM23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12Z"
318 />
319 <path d="M11 16V12C11 11.4477 11.4477 11 12 11C12.5523 11 13 11.4477 13 12V16C13 16.5523 12.5523 17 12 17C11.4477 17 11 16.5523 11 16Z" />
320 <path d="M12.0098 7C12.5621 7 13.0098 7.44772 13.0098 8C13.0098 8.55228 12.5621 9 12.0098 9H12C11.4477 9 11 8.55228 11 8C11 7.44772 11 7 12 7H12.0098Z" />
321 </svg>
322 </HoverTip>
323 </span>
324
325 <span className={ 'pv-savestate' + ( dirty || saving ? ' is-dirty' : '' ) } aria-live="polite">
326 { saving ? (
327 <>
328 <span className="spin" aria-hidden="true" /> { __( 'Saving…', 'everest-forms' ) }
329 </>
330 ) : dirty ? (
331 <>
332 <span className="save-dot" aria-hidden="true" /> { __( 'Unsaved — hit Save above', 'everest-forms' ) }
333 </>
334 ) : (
335 <>
336 <svg className="save-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2.5 } aria-hidden="true">
337 <path d="m5 13 4 4 10-10" />
338 </svg>
339 { __( 'All changes saved', 'everest-forms' ) }
340 </>
341 ) }
342 </span>
343
344 { /* Device switcher — pinned to the far right of the toolbar. */ }
345 <div className="devs" role="group" aria-label={ __( 'Preview device', 'everest-forms' ) }>
346 { DEVICE_ORDER.map( ( d ) => (
347 <button
348 key={ d }
349 type="button"
350 aria-pressed={ store.device === d }
351 aria-label={ DEVICE_LABELS[ d ] }
352 title={ DEVICE_LABELS[ d ] }
353 onClick={ () => store.setDevice( d ) }
354 >
355 <svg
356 viewBox="0 0 24 24"
357 stroke="currentColor"
358 strokeWidth={ 2 }
359 fill="none"
360 dangerouslySetInnerHTML={ { __html: DEVICE_ICONS[ d ] } }
361 />
362 </button>
363 ) ) }
364 </div>
365 </div>
366
367 { status === 'ready' && interactiveStalled && ! interactive && (
368 <div className="pv-noninteractive" role="status">
369 <span>
370 { __(
371 'Click-to-edit isn’t responding in this preview — the form itself still renders correctly.',
372 'everest-forms'
373 ) }
374 </span>
375 <button type="button" onClick={ retry }>
376 { __( 'Retry', 'everest-forms' ) }
377 </button>
378 </div>
379 ) }
380
381 <div className="pv-canvas">
382 <div className="pv-frame">
383 <iframe
384 key={ reloadKey }
385 ref={ iframeRef }
386 className={ 'pv-iframe' + ( status === 'ready' ? ' is-ready' : '' ) }
387 title={ __( 'Form preview', 'everest-forms' ) }
388 src={ previewSrc }
389 onLoad={ () => {
390 // Small delay lets the bridge hide the preview chrome first, so it never flashes.
391 window.setTimeout( () => setStatus( 'ready' ), 180 );
392 } }
393 />
394 </div>
395
396 { status === 'loading' && <PreviewSkeleton /> }
397
398 { status === 'error' && (
399 <div className="pv-error">
400 <div className="card">
401 <h4>{ __( 'Preview is taking a moment', 'everest-forms' ) }</h4>
402 <p>
403 { __(
404 'We couldn’t load the live preview — a security or caching plugin may be blocking it. Your edits are still saved.',
405 'everest-forms'
406 ) }
407 </p>
408 <div className="pv-error-actions">
409 <button type="button" className="btn-primary" onClick={ retry }>
410 { __( 'Retry', 'everest-forms' ) }
411 </button>
412 <a href={ previewSrc } target="_blank" rel="noreferrer">
413 { __( 'Open in a new tab ↗', 'everest-forms' ) }
414 </a>
415 </div>
416 </div>
417 </div>
418 ) }
419 </div>
420
421 { saveError && (
422 <div className="toast is-error" role="alert">
423 <span className="toast-ic" aria-hidden="true">
424 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 }>
425 <circle cx="12" cy="12" r="9" />
426 <path d="M12 8v5M12 16h.01" />
427 </svg>
428 </span>
429 <span>{ saveError }</span>
430 { saveErrorConflict && (
431 <button type="button" onClick={ () => window.location.reload() }>
432 { __( 'Reload', 'everest-forms' ) }
433 </button>
434 ) }
435 </div>
436 ) }
437
438 { toast && (
439 <div
440 className={ 'toast' + ( toast.kind ? ` is-${ toast.kind }` : '' ) }
441 role="status"
442 onMouseEnter={ onToastPause }
443 onMouseLeave={ onToastResume }
444 onFocus={ onToastPause }
445 onBlur={ onToastResume }
446 >
447 { toast.kind === 'success' && (
448 <span className="toast-ic" aria-hidden="true">
449 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={ 2 }>
450 <circle cx="12" cy="12" r="9" />
451 <path d="m8.5 12.5 2.5 2.5 4.5-5" />
452 </svg>
453 </span>
454 ) }
455 <span>{ toast.msg }</span>
456 { toast.actLabel && toast.onAct && (
457 <button type="button" onClick={ toast.onAct }>
458 { toast.actLabel }
459 </button>
460 ) }
461 </div>
462 ) }
463 </section>
464 </>
465 );
466 }
467