/**
* Style Customizer v2 — live preview pane. Owns the `?evf_preview` iframe and its bridge,
* plus the device switcher, save-lifecycle state, skeleton loader, and error card.
*/
import React from 'react';
import { DEVICE_ICONS, DEVICE_LABELS } from './constants';
import { HoverTip } from './HoverTip';
import { PreviewBridge, SelectionInfo, setActiveBridge } from './PreviewBridge';
import { BuilderSync, getActiveSync, setActiveSync } from './BuilderSync';
import { getStore, useStore } from './store';
import { Device } from './types';
const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s );
type PreviewStatus = 'loading' | 'ready' | 'error';
const MIGRATION_DISMISS_PREFIX = 'evf_scv2_migration_dismissed_';
/** Notice that this form's legacy styles were auto-migrated. Dismisses once per form. */
function MigrationNotice() {
const store = useStore();
const dismissKey = MIGRATION_DISMISS_PREFIX + store.settings.formId;
const [ dismissed, setDismissed ] = React.useState( () => {
try {
return window.localStorage.getItem( dismissKey ) === '1';
} catch ( e ) {
return false;
}
} );
if ( ! store.migration?.just_migrated || dismissed ) {
return null;
}
return (
{ __( 'Styles upgraded from the legacy editor.', 'everest-forms' ) } { ' ' }
{ __(
'Nothing should look different — review the preview below, then hit Save to keep it.',
'everest-forms'
) }
{
setDismissed( true );
try {
window.localStorage.setItem( dismissKey, '1' );
} catch ( e ) {
// Best-effort only — worst case it reappears next visit.
}
} }
>
);
}
/** Notice that the Fields or Settings tab has changes not yet saved — shows/hides live as that changes. */
function UnsavedFieldsNotice() {
const store = useStore();
const [ dismissed, setDismissed ] = React.useState( false );
React.useEffect( () => {
if ( store.hasUnsavedFieldChanges ) {
setDismissed( false );
}
}, [ store.hasUnsavedFieldChanges ] );
if ( ! store.hasUnsavedFieldChanges || dismissed ) {
return null;
}
return (
{ __( 'Previewing unsaved changes.', 'everest-forms' ) } { ' ' }
{ __( 'From the Fields or Settings tab.', 'everest-forms' ) }
setDismissed( true ) }
>
);
}
/** Preview loader (skeleton form + spinner), shared with the panel bootstrap in index.tsx. */
export function PreviewSkeleton( { note }: { note?: string } ) {
return (
{ note || __( 'Loading your live preview…', 'everest-forms' ) }
);
}
const DEVICE_ORDER: Device[] = [ 'desktop', 'tablet', 'mobile' ];
/** Content width per device: desktop = full width (null), tablet/mobile = a fixed px below the breakpoint. */
function deviceContentWidth( device: Device, breakpoints: Record< string, number > ): number | null {
if ( device === 'desktop' ) {
return null;
}
const bp = breakpoints[ device ];
return device === 'mobile' ? Math.min( 400, bp || 480 ) : Math.min( 768, bp || 768 );
}
export function PreviewPane( {
forceClass,
saving,
dirty,
saveError,
saveErrorConflict,
onSelect,
onIframeClick,
onUndo,
onRedo,
toast,
onToastPause,
onToastResume,
}: {
forceClass: string | null;
saving: boolean;
dirty: boolean;
saveError: string;
saveErrorConflict: boolean;
onSelect: ( info: SelectionInfo ) => void;
onIframeClick: () => void;
onUndo: () => void;
onRedo: () => void;
toast: { msg: string; actLabel?: string; onAct?: () => void; kind?: 'success' | 'info' } | null;
onToastPause: () => void;
onToastResume: () => void;
} ) {
const store = useStore();
const iframeRef = React.useRef< HTMLIFrameElement >( null );
const bridgeRef = React.useRef< PreviewBridge | null >( null );
const [ status, setStatus ] = React.useState< PreviewStatus >( 'loading' );
const [ reloadKey, setReloadKey ] = React.useState( 0 );
// Whether the live-edit bridge is actually wired (click-to-edit, hover) — separate from
// `status`, which can be force-flipped to 'ready' even when the bridge never finishes.
const [ interactive, setInteractive ] = React.useState( false );
const [ interactiveStalled, setInteractiveStalled ] = React.useState( false );
// Always call the latest onSelect/onIframeClick without re-creating the bridge.
const onSelectRef = React.useRef( onSelect );
onSelectRef.current = onSelect;
const onIframeClickRef = React.useRef( onIframeClick );
onIframeClickRef.current = onIframeClick;
const onUndoRef = React.useRef( onUndo );
onUndoRef.current = onUndo;
const onRedoRef = React.useRef( onRedo );
onRedoRef.current = onRedo;
// Force the iframe to the current window's exact origin, so a host/scheme mismatch with the
// server-computed preview URL never makes the iframe cross-origin (contentDocument would throw).
const previewSrc = React.useMemo( () => {
try {
const url = new URL( store.settings.previewUrl, window.location.href );
url.protocol = window.location.protocol;
url.host = window.location.host;
return url.href;
} catch ( e ) {
return store.settings.previewUrl;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [] );
// Create the bridge once the iframe is mounted; subscribe the store to live edits.
React.useEffect( () => {
const iframe = iframeRef.current;
if ( ! iframe ) {
return;
}
setStatus( 'loading' );
setInteractive( false );
setInteractiveStalled( false );
const s = getStore();
const bridge = new PreviewBridge( iframe, s, {
onReady: () => {
setStatus( 'ready' );
setInteractive( true );
setInteractiveStalled( false );
getActiveSync()?.onBridgeReady();
},
onError: () => setStatus( ( prev ) => ( prev === 'ready' ? prev : 'error' ) ),
onSelect: ( info ) => onSelectRef.current( info ),
onIframeClick: () => onIframeClickRef.current(),
onUndo: () => onUndoRef.current(),
onRedo: () => onRedoRef.current(),
} );
bridgeRef.current = bridge;
setActiveBridge( bridge );
bridge.attach();
// Some Chrome setups fail to flip status via the load event/poll — force a reveal so the
// preview is always shown, with or without the live-edit bridge.
const revealTimer = window.setTimeout( () => {
setStatus( ( prev ) => ( prev === 'loading' ? 'ready' : prev ) );
}, 2500 );
const interactiveStallTimer = window.setTimeout( () => {
setInteractive( ( cur ) => {
if ( ! cur ) {
setInteractiveStalled( true );
}
return cur;
} );
}, 16000 );
const unsubscribe = s.subscribe( () => {
const affected = s.affected;
if ( affected === null ) {
bridge.applyAll();
} else if ( affected.length === 0 ) {
bridge.applyCustomCss(); // custom-css / saved marker — no token vars moved.
} else {
bridge.applyKeys( affected );
}
} );
return () => {
window.clearTimeout( revealTimer );
window.clearTimeout( interactiveStallTimer );
unsubscribe();
bridge.detach();
setActiveBridge( null );
bridgeRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ reloadKey ] );
// Fields ↔ Style live sync: keeps the preview rendering the builder's CURRENT structure
// (unsaved edits included). Mounted once — it reads the active bridge on demand, so it
// survives bridge remounts (retry/device switch) without being torn down.
React.useEffect( () => {
const sync = new BuilderSync( getStore() );
setActiveSync( sync );
sync.start();
return () => {
sync.stop();
setActiveSync( null );
};
}, [] );
// Reflect the active force-state (focus/hover/message) onto the preview.
React.useEffect( () => {
if ( status === 'ready' && bridgeRef.current ) {
bridgeRef.current.setForceClass( forceClass );
}
}, [ forceClass, status ] );
// Constrain the iframe's form content to the active device width (outer pane stays full).
const contentWidth = deviceContentWidth( store.device, store.breakpoints );
React.useEffect( () => {
if ( status === 'ready' && bridgeRef.current ) {
bridgeRef.current.setDeviceWidth( contentWidth );
}
}, [ contentWidth, status ] );
const retry = () => {
setStatus( 'loading' );
setReloadKey( ( k ) => k + 1 ); // Remount the iframe → fresh load + bridge.
};
return (
<>
{ __( 'Live preview', 'everest-forms' ) }
{ saving ? (
<>
{ __( 'Saving…', 'everest-forms' ) }
>
) : dirty ? (
<>
{ __( 'Unsaved — hit Save above', 'everest-forms' ) }
>
) : (
<>
{ __( 'All changes saved', 'everest-forms' ) }
>
) }
{ /* Device switcher — pinned to the far right of the toolbar. */ }
{ DEVICE_ORDER.map( ( d ) => (
store.setDevice( d ) }
>
) ) }
{ status === 'ready' && interactiveStalled && ! interactive && (
{ __(
'Click-to-edit isn’t responding in this preview — the form itself still renders correctly.',
'everest-forms'
) }
{ __( 'Retry', 'everest-forms' ) }
) }
{ status === 'loading' &&
}
{ status === 'error' && (
{ __( 'Preview is taking a moment', 'everest-forms' ) }
{ __(
'We couldn’t load the live preview — a security or caching plugin may be blocking it. Your edits are still saved.',
'everest-forms'
) }
) }
{ saveError && (
{ saveError }
{ saveErrorConflict && (
window.location.reload() }>
{ __( 'Reload', 'everest-forms' ) }
) }
) }
{ toast && (
{ toast.kind === 'success' && (
) }
{ toast.msg }
{ toast.actLabel && toast.onAct && (
{ toast.actLabel }
) }
) }
>
);
}