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 |