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
BuilderSync.ts
347 lines
| 1 | /** |
| 2 | * Style Customizer v2 — Fields/Settings ↔ Style live synchronisation. Serialises the builder's |
| 3 | * current form (fields, layout, settings) to the `preview-draft` endpoint, applies any section/ |
| 4 | * schema visibility the response recomputed, and reloads the preview iframe when it changes. |
| 5 | */ |
| 6 | import { getActiveBridge } from './PreviewBridge'; |
| 7 | import { StyleStore } from './store'; |
| 8 | |
| 9 | const apiFetch = ( window as any ).wp?.apiFetch; |
| 10 | |
| 11 | const BUILDER_FORM_ID = 'everest-forms-builder-form'; |
| 12 | const FIELD_WRAPPER_SELECTOR = '.evf-admin-field-wrapper'; |
| 13 | const STYLE_PANEL_ID = 'everest-forms-panel-style'; |
| 14 | |
| 15 | /** Debounce for builder-change bursts (drag, typing) before a single preview refresh (ms). */ |
| 16 | const CHANGE_DEBOUNCE = 500; |
| 17 | |
| 18 | interface SerializedItem { |
| 19 | name: string; |
| 20 | value: string; |
| 21 | } |
| 22 | |
| 23 | export class BuilderSync { |
| 24 | private store: StyleStore; |
| 25 | private stylePanel: HTMLElement | null = null; |
| 26 | private active = false; |
| 27 | private started = false; |
| 28 | |
| 29 | /** Signature of the structure currently rendered in the preview (baseline = the saved form). */ |
| 30 | private lastSignature: string | null = null; |
| 31 | /** A sync was requested before the bridge was ready — run it once the frame loads. */ |
| 32 | private pendingSync = false; |
| 33 | /** Coalesce a burst of builder changes into one refresh. */ |
| 34 | private changeTimer: ReturnType< typeof setTimeout > | null = null; |
| 35 | /** Guards against overlapping draft POST + reload cycles. */ |
| 36 | private syncing = false; |
| 37 | /** Structural style-token overrides (see syncStyleToken()) waiting to go out on the next POST. */ |
| 38 | private pendingStyleTokens: Record< string, unknown > = {}; |
| 39 | |
| 40 | private fieldObserver: MutationObserver | null = null; |
| 41 | private panelObserver: MutationObserver | null = null; |
| 42 | |
| 43 | constructor( store: StyleStore ) { |
| 44 | this.store = store; |
| 45 | } |
| 46 | |
| 47 | /* --------------------------------------------------------------------- * |
| 48 | * Lifecycle |
| 49 | * --------------------------------------------------------------------- */ |
| 50 | |
| 51 | start() { |
| 52 | if ( this.started ) { |
| 53 | return; |
| 54 | } |
| 55 | this.started = true; |
| 56 | |
| 57 | this.stylePanel = document.getElementById( STYLE_PANEL_ID ); |
| 58 | this.lastSignature = this.serialize().signature; |
| 59 | this.active = this.isStyleTabActive(); |
| 60 | |
| 61 | if ( this.stylePanel ) { |
| 62 | this.panelObserver = new MutationObserver( () => this.onPanelToggle() ); |
| 63 | this.panelObserver.observe( this.stylePanel, { attributes: true, attributeFilter: [ 'class' ] } ); |
| 64 | } |
| 65 | |
| 66 | document.addEventListener( 'input', this.onBuilderInput, true ); |
| 67 | document.addEventListener( 'change', this.onBuilderInput, true ); |
| 68 | |
| 69 | const wrapper = document.querySelector( FIELD_WRAPPER_SELECTOR ); |
| 70 | if ( wrapper ) { |
| 71 | this.fieldObserver = new MutationObserver( () => this.onFieldMutation() ); |
| 72 | this.fieldObserver.observe( wrapper, { childList: true, subtree: true } ); |
| 73 | } |
| 74 | |
| 75 | const jq = ( window as any ).jQuery; |
| 76 | if ( jq ) { |
| 77 | jq( document ).on( 'everest_forms_save_data.evfscv2', this.onSave ); |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | stop() { |
| 82 | this.started = false; |
| 83 | if ( this.changeTimer ) { |
| 84 | clearTimeout( this.changeTimer ); |
| 85 | this.changeTimer = null; |
| 86 | } |
| 87 | if ( this.panelObserver ) { |
| 88 | this.panelObserver.disconnect(); |
| 89 | this.panelObserver = null; |
| 90 | } |
| 91 | if ( this.fieldObserver ) { |
| 92 | this.fieldObserver.disconnect(); |
| 93 | this.fieldObserver = null; |
| 94 | } |
| 95 | document.removeEventListener( 'input', this.onBuilderInput, true ); |
| 96 | document.removeEventListener( 'change', this.onBuilderInput, true ); |
| 97 | const jq = ( window as any ).jQuery; |
| 98 | if ( jq ) { |
| 99 | jq( document ).off( 'everest_forms_save_data.evfscv2', this.onSave ); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | /** Called by the preview bridge once a (re)load has fully bridged — flush any pending sync. */ |
| 104 | onBridgeReady() { |
| 105 | if ( this.pendingSync ) { |
| 106 | this.pendingSync = false; |
| 107 | this.syncPreview( false ); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /* --------------------------------------------------------------------- * |
| 112 | * Triggers |
| 113 | * --------------------------------------------------------------------- */ |
| 114 | |
| 115 | private isStyleTabActive(): boolean { |
| 116 | return !! this.stylePanel && this.stylePanel.classList.contains( 'active' ); |
| 117 | } |
| 118 | |
| 119 | private onPanelToggle() { |
| 120 | const nowActive = this.isStyleTabActive(); |
| 121 | if ( nowActive === this.active ) { |
| 122 | return; |
| 123 | } |
| 124 | this.active = nowActive; |
| 125 | if ( nowActive ) { |
| 126 | this.syncPreview( false ); |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | private onBuilderInput = ( e: Event ) => { |
| 131 | if ( ! this.active ) { |
| 132 | return; |
| 133 | } |
| 134 | const target = e.target as HTMLElement | null; |
| 135 | if ( ! target || ! target.closest ) { |
| 136 | return; |
| 137 | } |
| 138 | // The Style panel's own controls live inside the builder form and fire input/change too; |
| 139 | // ignore those, they change styles, not field structure. |
| 140 | if ( target.closest( '#' + STYLE_PANEL_ID ) ) { |
| 141 | return; |
| 142 | } |
| 143 | if ( target.closest( '#' + BUILDER_FORM_ID ) ) { |
| 144 | this.scheduleSync(); |
| 145 | } |
| 146 | }; |
| 147 | |
| 148 | private onFieldMutation() { |
| 149 | if ( ! this.active ) { |
| 150 | return; |
| 151 | } |
| 152 | this.scheduleSync(); |
| 153 | } |
| 154 | |
| 155 | private onSave = () => { |
| 156 | if ( this.active ) { |
| 157 | this.syncPreview( true ); |
| 158 | } else { |
| 159 | this.lastSignature = null; // force a refresh on the next Style-tab open. |
| 160 | } |
| 161 | }; |
| 162 | |
| 163 | /** |
| 164 | * Push a "structural" style-token value (one with no CSS-variable fast path — e.g. |
| 165 | * Pagination's indicator TYPE, whose themes render genuinely different markup, not just a |
| 166 | * class/variable — see PreviewBridge.ts's applyKeys()) to the server and reload the preview |
| 167 | * once it's re-rendered with it. Queued on the instance so it survives a busy/not-ready sync |
| 168 | * being retried, and rides along with whatever POST goes out next. |
| 169 | */ |
| 170 | syncStyleToken( key: string, value: unknown ) { |
| 171 | this.pendingStyleTokens[ key ] = value; |
| 172 | this.syncPreview( false ); |
| 173 | } |
| 174 | |
| 175 | private scheduleSync() { |
| 176 | if ( this.changeTimer ) { |
| 177 | clearTimeout( this.changeTimer ); |
| 178 | } |
| 179 | this.changeTimer = setTimeout( () => { |
| 180 | this.changeTimer = null; |
| 181 | this.syncPreview( false ); |
| 182 | }, CHANGE_DEBOUNCE ); |
| 183 | } |
| 184 | |
| 185 | /* --------------------------------------------------------------------- * |
| 186 | * The sync itself |
| 187 | * --------------------------------------------------------------------- */ |
| 188 | |
| 189 | /** |
| 190 | * Push the builder's current structure to the preview and reload the iframe. |
| 191 | * |
| 192 | * @param force Bypass the signature short-circuit (e.g. after save). |
| 193 | */ |
| 194 | private async syncPreview( force: boolean ) { |
| 195 | if ( this.syncing ) { |
| 196 | this.pendingSync = true; |
| 197 | return; |
| 198 | } |
| 199 | |
| 200 | const bridge = getActiveBridge(); |
| 201 | if ( ! bridge || ! bridge.isReady() ) { |
| 202 | this.pendingSync = true; |
| 203 | return; |
| 204 | } |
| 205 | |
| 206 | const { json, signature } = this.serialize(); |
| 207 | if ( ! json ) { |
| 208 | return; |
| 209 | } |
| 210 | const styleTokens = this.pendingStyleTokens; |
| 211 | const hasStyleTokens = Object.keys( styleTokens ).length > 0; |
| 212 | if ( ! force && ! hasStyleTokens && signature === this.lastSignature ) { |
| 213 | return; |
| 214 | } |
| 215 | |
| 216 | if ( ! apiFetch ) { |
| 217 | return; |
| 218 | } |
| 219 | |
| 220 | this.syncing = true; |
| 221 | try { |
| 222 | const data: Record< string, unknown > = { form_data: json, session: this.store.settings.previewSession }; |
| 223 | if ( hasStyleTokens ) { |
| 224 | data.style_tokens = styleTokens; |
| 225 | } |
| 226 | const res = await apiFetch( { |
| 227 | path: `${ this.store.settings.restBase }/${ this.store.settings.formId }/preview-draft`, |
| 228 | method: 'POST', |
| 229 | data, |
| 230 | } ); |
| 231 | this.lastSignature = signature; |
| 232 | this.pendingStyleTokens = {}; |
| 233 | // The server recomputes section/token visibility against this same draft (e.g. a |
| 234 | // conditional section a Pro addon just became eligible/ineligible for) — apply it |
| 235 | // before the reload so the panel's sidebar is in sync with what the new iframe renders. |
| 236 | if ( res && res.sections && Array.isArray( res.schema ) ) { |
| 237 | this.store.setVisibility( res.sections, res.schema ); |
| 238 | } |
| 239 | getActiveBridge()?.reload(); |
| 240 | } catch ( err ) { |
| 241 | // eslint-disable-next-line no-console |
| 242 | if ( ( window as any ).console ) { |
| 243 | // eslint-disable-next-line no-console |
| 244 | console.warn( 'EVF Style: preview sync failed', err ); |
| 245 | } |
| 246 | } finally { |
| 247 | this.syncing = false; |
| 248 | if ( this.pendingSync ) { |
| 249 | this.pendingSync = false; |
| 250 | this.scheduleSync(); |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | /* --------------------------------------------------------------------- * |
| 256 | * Serialisation (mirrors the builder's own save payload) |
| 257 | * --------------------------------------------------------------------- */ |
| 258 | |
| 259 | /** Serialises the builder form the same way the real save AJAX does. */ |
| 260 | private serialize(): { json: string; signature: string } { |
| 261 | const jq = ( window as any ).jQuery; |
| 262 | if ( ! jq ) { |
| 263 | return { json: '', signature: '' }; |
| 264 | } |
| 265 | const form = jq( '#' + BUILDER_FORM_ID ); |
| 266 | if ( ! form.length ) { |
| 267 | return { json: '', signature: '' }; |
| 268 | } |
| 269 | |
| 270 | let formData: SerializedItem[] = form.serializeArray(); |
| 271 | |
| 272 | // Let addons rewrite/append to the payload the same way the real save AJAX does (see |
| 273 | // form-builder.js's own save handler) — Multi-Part, for one, computes its row→part |
| 274 | // mapping (`multi_part[part_N][rows][…]`) ENTIRELY inside this handler; it's never a |
| 275 | // static <input>. Skipping it left the draft with a part's `id` but no `rows`, which |
| 276 | // made the frontend renderer never close the first part's wrapper — every field from |
| 277 | // every part rendered flattened onto "Step 1" in the live preview until an actual save |
| 278 | // (which does fire this event) replaced the draft with a correctly-shaped saved record. |
| 279 | if ( form.triggerHandler( 'everest_forms_process_ajax_data', [ form, formData ] ) ) { |
| 280 | formData = form.triggerHandler( 'everest_forms_process_ajax_data', [ form, formData ] ); |
| 281 | } |
| 282 | |
| 283 | const structure = this.getStructure( jq ); |
| 284 | const all = formData.concat( structure ); |
| 285 | |
| 286 | return { json: JSON.stringify( all ), signature: this.signatureFrom( all ) }; |
| 287 | } |
| 288 | |
| 289 | /** Replicate the builder's `getStructure()` (field layout: rows → grids → ordered field ids). */ |
| 290 | private getStructure( jq: any ): SerializedItem[] { |
| 291 | const structure: SerializedItem[] = []; |
| 292 | const wrapper = jq( FIELD_WRAPPER_SELECTOR ); |
| 293 | |
| 294 | wrapper.find( '.evf-admin-row' ).each( function ( this: HTMLElement ) { |
| 295 | const $row = jq( this ); |
| 296 | const rowId = $row.attr( 'data-row-id' ); |
| 297 | |
| 298 | $row.find( '.evf-admin-grid' ).each( function ( this: HTMLElement ) { |
| 299 | const $grid = jq( this ); |
| 300 | const gridId = $grid.attr( 'data-grid-id' ); |
| 301 | const $fields = $grid.find( '.everest-forms-field' ); |
| 302 | |
| 303 | let index = 0; |
| 304 | $fields.each( function ( this: HTMLElement ) { |
| 305 | structure.push( { |
| 306 | name: `structure[row_${ rowId }][grid_${ gridId }][${ index }]`, |
| 307 | value: jq( this ).attr( 'data-field-id' ), |
| 308 | } ); |
| 309 | index++; |
| 310 | } ); |
| 311 | |
| 312 | if ( $fields.length < 1 ) { |
| 313 | structure.push( { name: `structure[row_${ rowId }][grid_${ gridId }]`, value: '' } ); |
| 314 | } |
| 315 | } ); |
| 316 | } ); |
| 317 | |
| 318 | return structure; |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * Signature of only the render-relevant inputs: fields, layout, AND settings — settings are |
| 323 | * included because a Pro addon's conditional section/group (e.g. Multi-Part's "Enable |
| 324 | * Multi-Part form" toggle gating the Pagination section) can depend on one, not just on |
| 325 | * fields/structure. |
| 326 | */ |
| 327 | private signatureFrom( all: SerializedItem[] ): string { |
| 328 | const relevant = all.filter( |
| 329 | ( i ) => |
| 330 | i.name.indexOf( 'form_fields[' ) === 0 || |
| 331 | i.name.indexOf( 'structure[' ) === 0 || |
| 332 | i.name.indexOf( 'settings[' ) === 0 |
| 333 | ); |
| 334 | return JSON.stringify( relevant ); |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | let active: BuilderSync | null = null; |
| 339 | |
| 340 | export function setActiveSync( sync: BuilderSync | null ) { |
| 341 | active = sync; |
| 342 | } |
| 343 | |
| 344 | export function getActiveSync(): BuilderSync | null { |
| 345 | return active; |
| 346 | } |
| 347 |