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 |