PluginProbe
Booking Calendar / 11.0
Booking Calendar v11.0
11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 10.11 10.11.2 All 203 releases
booking / includes / page-form-builder / _src / bfb-builder.js

bfb-builder.js in Booking Calendar 11.0, at includes/page-form-builder/_src/bfb-builder.js

2,378 lines 78.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // ---------------------------------------------------------------------------------------------------------------------
2 // == File /includes/page-form-builder/_out/bfb-builder.js == Time point: 2025-09-06 14:08
3 // ---------------------------------------------------------------------------------------------------------------------
4
5 /**
6 * Dispatch a DOM event safely.
7 *
8 * @param {string} name
9 * @param {Object} detail
10 */
11 function wpbc_bfb__dispatch_event_safe(name, detail) {
12 try {
13 if ( typeof window.CustomEvent === 'function' ) {
14 document.dispatchEvent( new CustomEvent( name, { detail: detail || {} } ) );
15 return;
16 }
17 } catch ( _e ) {}
18
19 try {
20 const ev = document.createEvent( 'CustomEvent' );
21 ev.initCustomEvent( name, true, true, detail || {} );
22 document.dispatchEvent( ev );
23 } catch ( _e2 ) {}
24 }
25
26 /**
27 * Quick, copy-paste console snippets you can use on the Builder page to exercise every refresh path.
28
29 Simple - Full rebuild + reinit (DEFAULTS). Use this when markup/renderers change.
30 wpbc_bfb.refresh_canvas();
31
32
33 Get the builder
34 // Always do this first:
35 wpbc_bfb_api.ready.then(b => { window.__B = b; console.log('BFB ready', b); });
36
37
38 Now you can call __B.refresh_canvas(...) directly.
39
40 Hard refresh (default)
41 // Full rebuild + reinit (DEFAULTS). Use this when markup/renderers change.
42 __B.refresh_canvas();
43 // Same, but explicit:
44 __B.refresh_canvas({ hard:true, rebuild:true, reinit:true, source:'console' });
45
46 Hard refresh WITHOUT rebuild
47 // Re-render all fields in place, then hydrate packs. Faster when structure didn’t change.
48 __B.refresh_canvas({ hard:true, rebuild:false, source:'console' });
49
50 Hard refresh but skip field reinit
51 // If you know packs don’t need on_field_drop re-wiring:
52 __B.refresh_canvas({ hard:true, rebuild:true, reinit:false, source:'console' });
53
54 Soft refresh (only selected field)
55 // 1) Select a field (first found) so soft refresh has a target:
56 __B.select_field(document.querySelector('.wpbc_bfb__field'), { scrollIntoView:false });
57
58 // 2) Re-render just that field:
59 __B.refresh_canvas({ hard:false, source:'console' });
60
61 Restore behavior toggles
62 // Don’t restore selection/scroll after refresh:
63 __B.refresh_canvas({ restore_selection:false, restore_scroll:false, source:'console' });
64
65 Avoid inspector ↔ canvas “echo”
66 // Use when calling from Inspector-like code to prevent ping-pong:
67 __B.refresh_canvas({ hard:true, rebuild:true, reinit:true, silent_inspector:true, source:'console' });
68
69 Preview mode guard (just to verify behavior)
70 // Turn preview OFF and try a refresh (no re-rendering will happen):
71 __B.set_preview_mode(false, { rebuild:false });
72 __B.refresh_canvas({ hard:true, source:'console' });
73
74 // Turn preview ON again and rebuild:
75 __B.set_preview_mode(true, { rebuild:true, reinit:true, source:'console' });
76
77 Watch events (before/after)
78 wpbc_bfb_api.ready.then(b => {
79 const EV = (window.WPBC_BFB_Core && WPBC_BFB_Core.WPBC_BFB_Events) || {};
80 b.bus.on(EV.CANVAS_REFRESH || 'wpbc:bfb:canvas-refresh', p => console.log('BEFORE', p));
81 b.bus.on(EV.CANVAS_REFRESHED || 'wpbc:bfb:canvas-refreshed', p => console.log('AFTER ', p));
82 });
83
84 Sanity checks while testing
85 // Is a refresh already in progress? (reentrancy guard)
86 __B.__refreshing_canvas
87
88 // See what’s currently selected (for soft refresh):
89 __B.get_selected_field?.()
90
91 // Manually select by data-id (to test selection restore):
92 const id = document.querySelector('.wpbc_bfb__field')?.dataset?.id;
93 __B.select_by_data_id?.(id, { silent:true });
94 *
95 */
96 (function (w) {
97 'use strict';
98
99 const {
100 WPBC_BFB_Sanitize,
101 WPBC_BFB_IdService,
102 WPBC_BFB_LayoutService,
103 WPBC_BFB_UsageLimitService,
104 WPBC_BFB_Events,
105 WPBC_BFB_EventBus,
106 WPBC_BFB_SortableManager,
107 // WPBC_BFB_DOM,
108 WPBC_Form_Builder_Helper,
109 WPBC_BFB_Field_Renderer_Registry
110 } = w.WPBC_BFB_Core;
111
112 // NOTE: UI is now under WPBC_BFB_Core.UI.
113 const {
114 WPBC_BFB_Module,
115 WPBC_BFB_Overlay,
116 // WPBC_BFB_Layout_Chips,
117 WPBC_BFB_Selection_Controller,
118 WPBC_BFB_Inspector_Bridge,
119 WPBC_BFB_Keyboard_Controller,
120 WPBC_BFB_Resize_Controller,
121 WPBC_BFB_Pages_Sections,
122 WPBC_BFB_Structure_IO,
123 WPBC_BFB_Min_Width_Guard
124 } = w.WPBC_BFB_Core.UI;
125
126
127
128
129 function wpbc_bfb__post_ajax_promise(url, data) {
130 return new Promise( function (resolve) {
131 const xhr = new XMLHttpRequest();
132 xhr.open( 'POST', url, true );
133 xhr.setRequestHeader( 'Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8' );
134
135 xhr.onreadystatechange = function () {
136 if ( xhr.readyState !== 4 ) {
137 return;
138 }
139 resolve( {
140 status: xhr.status,
141 text : xhr.responseText
142 } );
143 };
144
145 const pairs = [];
146 for ( const k in data ) {
147 if ( ! Object.prototype.hasOwnProperty.call( data, k ) ) {
148 continue;
149 }
150 pairs.push( encodeURIComponent( k ) + '=' + encodeURIComponent( data[k] ) );
151 }
152 xhr.send( pairs.join( '&' ) );
153 } );
154 }
155
156 class WPBC_Form_Builder {
157
158 /**
159 * Constructor for Booking Form Builder class.
160 * Initializes UI elements, SortableJS, and event listeners.
161 */
162 constructor( opts = {} ) {
163 // Allow DI/overrides via opts while keeping defaults.
164 // Back-compat: accept either a single UL via opts.palette_ul or an array via opts.palette_uls.
165 const providedPalettes = Array.isArray( opts.palette_uls ) ? opts.palette_uls : (opts.palette_ul ? [ opts.palette_ul ] : []);
166 this.palette_uls = providedPalettes.length ? providedPalettes : Array.from( document.querySelectorAll( '.wpbc_bfb__panel_field_types__ul' ) );
167
168 this.pages_container = opts.pages_container || document.getElementById( 'wpbc_bfb__pages_container' );
169 if ( ! this.pages_container ) {
170 throw new Error( 'WPBC: pages container not found.' );
171 }
172 this.page_counter = 0;
173 this.section_counter = 0;
174 this.max_nested_value = Number.isFinite( +opts.max_nested_value ) ? +opts.max_nested_value : 5;
175 this.preview_mode = ( opts.preview_mode !== undefined ) ? !!opts.preview_mode : true;
176 this.col_gap_percent = Number.isFinite( +opts.col_gap_percent ) ? +opts.col_gap_percent : 3; // % gap between columns for layout math.
177 this._uid_counter = 0;
178
179 // Service instances.
180 this.id = new WPBC_BFB_IdService( this.pages_container );
181 this.layout = new WPBC_BFB_LayoutService( { col_gap_percent: this.col_gap_percent } );
182 this.usage = new WPBC_BFB_UsageLimitService( this.pages_container, this.palette_uls );
183 this.bus = new WPBC_BFB_EventBus( this.pages_container );
184 this._handlers = [];
185 this.sortable = new WPBC_BFB_SortableManager( this );
186
187 this._modules = []; /** @type {Array<WPBC_BFB_Module>} */
188
189 // Register modules.
190 this.use_module( WPBC_BFB_Selection_Controller );
191 this.use_module( WPBC_BFB_Inspector_Bridge );
192 this.use_module( WPBC_BFB_Resize_Controller );
193 this.use_module( WPBC_BFB_Pages_Sections );
194 this.use_module( WPBC_BFB_Structure_IO );
195 this.use_module( WPBC_BFB_Keyboard_Controller );
196 this.use_module( WPBC_BFB_Min_Width_Guard );
197
198 this._init();
199 this._bind_events();
200 }
201
202 /**
203 * Emit a namespaced builder event via the EventBus.
204 *
205 * @param {string} type - Event type (use WPBC_BFB_Events when possible).
206 * @param {Object} [detail={}] - Payload object.
207 * @returns {void}
208 */
209 _emit_const(type, detail = {}) {
210 this.bus.emit( type, detail );
211 }
212
213 /**
214 * Find a neighbor element that can be selected after removing a node.
215 *
216 * @param {HTMLElement} el - The element that is being removed.
217 * @returns {HTMLElement|null} Neighbor or null.
218 */
219 _find_neighbor_selectable(el) {
220
221 if ( ! el || ! el.parentElement ) {
222 return null;
223 }
224
225 const all = Array.from( el.parentElement.children ).filter( n => (n.classList?.contains( 'wpbc_bfb__field' ) || n.classList?.contains( 'wpbc_bfb__section' )) );
226
227 const i = all.indexOf( el );
228 if ( i > 0 ) {
229 return all[i - 1];
230 }
231 if ( i >= 0 && i + 1 < all.length ) {
232 return all[i + 1];
233 }
234
235 // Fallback: any other selectable on the current page, but NEVER inside `el` itself.
236 const page = el.closest( '.wpbc_bfb__panel--preview' );
237 if ( page ) {
238 // Prefer sections/fields that are siblings elsewhere on the page.
239 const candidate = page.querySelector( '.wpbc_bfb__section, .wpbc_bfb__field' );
240 if ( candidate && ! el.contains( candidate ) ) {
241 return candidate;
242 }
243 }
244 return null;
245 }
246
247
248 /**
249 * Initialize SortableJS on the field palette and load initial form structure.
250 *
251 * @returns {void}
252 */
253 _init() {
254
255 if ( typeof Sortable === 'undefined' ) {
256 console.error( 'SortableJS is not loaded (drag & drop disabled).' );
257 }
258
259 // === Init Sortable on the Field Palette. ===
260 if ( ! this.palette_uls.length ) {
261 console.warn( 'WPBC: No field palettes found (.wpbc_bfb__panel_field_types__ul).' );
262 } else if ( typeof Sortable === 'undefined' ) {
263 console.warn( 'WPBC: SortableJS not loaded (palette drag disabled).' );
264 } else {
265 this.palette_uls.forEach( (ul) => this.sortable.ensure( ul, 'palette' ) );
266 }
267
268 const waitForRenderers = () => new Promise( (resolve) => {
269 const hasRegistry = !!(w.WPBC_BFB_Core && w.WPBC_BFB_Core.WPBC_BFB_Field_Renderer_Registry && typeof w.WPBC_BFB_Core.WPBC_BFB_Field_Renderer_Registry.get === 'function');
270
271 if ( hasRegistry ) {
272 return resolve();
273 }
274 const started = Date.now();
275 const i = setInterval( () => {
276 const ok = !!(w.WPBC_BFB_Core && w.WPBC_BFB_Core.WPBC_BFB_Field_Renderer_Registry && typeof w.WPBC_BFB_Core.WPBC_BFB_Field_Renderer_Registry.get === 'function');
277 const timedOut = (Date.now() - started) > 3000;
278 if ( ok || timedOut ) {
279 clearInterval( i );
280 if ( ! ok ) {
281 console.warn( 'WPBC: Field renderers not found, using fallback preview.' );
282 }
283 resolve();
284 }
285 }, 50 );
286 } );
287
288 // 1. Auto Load form defined in wpbc_bfb_output_ajax_boot_config() -> 2. Load example wpbc_bfb__form_structure__get_example() -> 3. Blank page.
289 const startLoad = async () => {
290 await waitForRenderers();
291 await new Promise( (r) => setTimeout( r, 0 ) ); // next macrotask.
292
293 // 1) Try to auto-load "standard" from DB (published) via AJAX.
294 const loaded = await this._auto_load_initial_form_from_ajax();
295
296 // Auto-open Apply Template modal from URL (if requested).
297 await this._auto_open_apply_template_modal_from_url();
298
299 if ( loaded ) {
300 return;
301 }
302
303 // 2) Fallback behavior if AJAX did not load anything.
304 const cfg = window.WPBC_BFB_Ajax || {};
305 const fallback_mode = String( cfg.initial_load_fallback || 'example' ).toLowerCase();
306
307 if ( fallback_mode === 'blank' ) {
308 this.add_page();
309
310 // Auto-open Apply Template modal from URL (if requested).
311 await this._auto_open_apply_template_modal_from_url();
312
313 return;
314 }
315
316 // default fallback: example structure.
317 const example_structure = (typeof window.wpbc_bfb__form_structure__get_example === 'function')
318 ? window.wpbc_bfb__form_structure__get_example()
319 : null;
320
321 if ( Array.isArray( example_structure ) ) {
322 this.load_saved_structure( example_structure );
323 } else {
324 this.add_page();
325 }
326
327 // Auto-open Apply Template modal from URL (if requested).
328 await this._auto_open_apply_template_modal_from_url();
329 };
330
331
332
333 if ( document.readyState === 'loading' ) {
334 document.addEventListener( 'DOMContentLoaded', startLoad );
335 } else {
336 startLoad();
337 }
338
339 this._start_usage_observer();
340 this._start_pages_numbering_observer();
341
342 // this.add_page(); return; // Standard initializing one page.
343 }
344
345 _getRenderer(type) {
346 // return w.WPBC_BFB_Core?.WPBC_BFB_Field_Renderer_Registry?.get?.( type );
347 return WPBC_BFB_Field_Renderer_Registry?.get?.(type);
348 }
349
350
351 /**
352 * Observe DOM mutations that may change usage counts and refresh palette state.
353 *
354 * @returns {void}
355 */
356 _start_usage_observer() {
357 if ( this._usage_observer ) {
358 return;
359 }
360
361 const refresh = WPBC_Form_Builder_Helper.debounce( () => {
362 try {
363 this.usage.update_palette_ui();
364 document.querySelectorAll( '.wpbc_bfb__panel_field_types__ul' ).forEach( (ul) => {
365 try {
366 this._usage_observer.observe( ul, { childList: true, subtree: true } );
367 } catch( e ){ _wpbc?.dev?.error( '_start_usage_observer', e ); }
368 } );
369 } catch (e) {
370 console.warn( 'Usage UI update failed.', e );
371 }
372 }, 100 );
373
374 const config = { childList: true, subtree: true, attributes: true, attributeFilter: [ 'class', 'data-usage_key' ] };
375
376 this._usage_observer = new MutationObserver( refresh );
377 this._usage_observer.observe( this.pages_container, config );
378
379 // Observe all known palettes; also do a broad query on each refresh so late-added palettes are handled.
380 (this.palette_uls || []).forEach( (ul) => {
381 try {
382 this._usage_observer.observe( ul, { childList: true, subtree: true } );
383 } catch( e ){ _wpbc?.dev?.error( '_start_usage_observer', e ); }
384 } );
385
386
387 // Initial sync.
388 refresh();
389 }
390
391 /**
392 * Add dragging visual feedback on all columns.
393 *
394 * @returns {void}
395 */
396 _add_dragging_class() {
397 this.pages_container.querySelectorAll( '.wpbc_bfb__column' ).forEach( ( col ) => col.classList.add( 'wpbc_bfb__dragging' ) );
398 }
399
400 /**
401 * Remove dragging visual feedback on all columns.
402 *
403 * @returns {void}
404 */
405 _remove_dragging_class() {
406 this.pages_container.querySelectorAll( '.wpbc_bfb__column' ).forEach( ( col ) => col.classList.remove( 'wpbc_bfb__dragging' ) );
407 }
408
409 /**
410 * Bind event handlers for save, add-page, and preview toggle buttons.
411 *
412 * @returns {void}
413 */
414 _bind_events() {
415 // Save button click.
416 // const save_btn = document.getElementById( 'wpbc_bfb__save_btn' );
417 // if ( save_btn ) {
418 // if ( ! save_btn.hasAttribute( 'type' ) ) {
419 // save_btn.setAttribute( 'type', 'button' );
420 // }
421 // this._on( save_btn, 'click', ( e ) => {
422 // e.preventDefault();
423 // const structure = this.get_structure();
424 // console.log( JSON.stringify( structure, null, 2 ) ); // Developer aid.
425 // this._emit_const( WPBC_BFB_Events.STRUCTURE_CHANGE, { structure } );
426 // this.load_saved_structure( structure, { deferIfTyping: false } );
427 // } );
428 // }
429
430
431 // Keyboard handling moved to WPBC_BFB_Keyboard_Controller.
432
433 // Add page button click.
434 const add_page_btn = document.getElementById( 'wpbc_bfb__add_page_btn' );
435 if ( add_page_btn ) {
436 this._on( add_page_btn, 'click', ( e ) => {
437 e.preventDefault();
438 this.add_page();
439 this._announce?.( 'Page added.' );
440 } );
441 }
442
443 // Prevent accidental drag while editing inputs.
444 this._on( this.pages_container, 'focusin', (e) => {
445 const f = e.target.closest( '.wpbc_bfb__field' );
446 if ( f ) {
447 f.setAttribute( 'data-draggable', 'false' );
448 }
449 } );
450 this._on( this.pages_container, 'focusout', (e) => {
451 const f = e.target.closest( '.wpbc_bfb__field' );
452 if ( f ) {
453 f.removeAttribute( 'data-draggable' );
454 }
455 } );
456
457 }
458
459 /**
460 * Re-run field initializers for every field in the canvas.
461 * Many renderers (e.g., Calendar) wire themselves inside on_field_drop().
462 *
463 * @param {"drop"|"load"|"preview"|"save"} context
464 */
465 _reinit_all_fields(context = 'preview') {
466 this.pages_container
467 .querySelectorAll( '.wpbc_bfb__panel--preview .wpbc_bfb__field' )
468 .forEach( (field_el) => this.trigger_field_drop_callback( field_el, context ) );
469 }
470
471 /**
472 * Return only the column elements (skip resizers).
473 *
474 * @param {HTMLElement} row_el - Row element.
475 * @returns {HTMLElement[]} Column elements.
476 */
477 _get_row_cols( row_el ) {
478 return Array.from( row_el.querySelectorAll( ':scope > .wpbc_bfb__column' ) );
479 }
480
481 // -- Page Numbers Care --
482
483 /**
484 * Get page panels in DOM order (direct children of pages_container).
485 *
486 * @returns {HTMLElement[]}
487 */
488 _get_pages_in_dom_order() {
489 if ( ! this.pages_container ) {
490 return [];
491 }
492 return Array.from(
493 this.pages_container.querySelectorAll( ':scope > .wpbc_bfb__panel--preview' )
494 );
495 }
496
497 /**
498 * Get the page number heading element inside a page panel.
499 *
500 * @param {HTMLElement} page_el
501 * @returns {HTMLElement|null}
502 */
503 _get_page_number_heading_el(page_el) {
504 if ( ! page_el || ! page_el.querySelector ) {
505 return null;
506 }
507 // In markup this is: <h3 class="wpbc_bfb__page_number">Page 1 <button>...</button></h3>
508 return page_el.querySelector( '.wpbc_bfb__page_number' );
509 }
510
511 /**
512 * Update only TEXT inside <h3 class="wpbc_bfb__page_number">, preserving the delete button.
513 *
514 * @param {HTMLElement} heading_el
515 * @param {number} page_number
516 * @returns {void}
517 */
518 _set_page_number_heading_text(heading_el, page_number) {
519
520 if ( ! heading_el ) {
521 return;
522 }
523
524 // Collect current visible text from TEXT nodes only (ignore button text).
525 const text_nodes = Array.from( heading_el.childNodes || [] ).filter( (n) => n && n.nodeType === 3 );
526
527 let raw = '';
528 for ( let i = 0; i < text_nodes.length; i++ ) {
529 raw += String( text_nodes[i].nodeValue || '' );
530 }
531 raw = raw.replace( /\s+/g, ' ' ).trim(); // "Page 1"
532
533 const n = String( page_number );
534
535 // Preserve prefix/translation, just replace the last number group.
536 let next = '';
537 if ( raw && /\d+/.test( raw ) ) {
538 next = raw.replace( /(\d+)(?!.*\d)/, n );
539 } else if ( raw ) {
540 next = raw + ' ' + n;
541 } else {
542 next = 'Page ' + n;
543 }
544
545 // Apply into the first text node; clear any extra text nodes.
546 if ( text_nodes.length > 0 ) {
547 text_nodes[0].nodeValue = next + ' ';
548 for ( let k = 1; k < text_nodes.length; k++ ) {
549 text_nodes[k].nodeValue = '';
550 }
551 } else {
552 // No text node exists (rare) -> insert before first child (keeps button).
553 heading_el.insertBefore( document.createTextNode( next + ' ' ), heading_el.firstChild );
554 }
555 }
556
557 /**
558 * Renumber all pages in the canvas by DOM order.
559 * - Updates data-page_number (display order)
560 * - Updates data-page (legacy/page number for compatibility)
561 * - Updates heading text "Page X" while keeping delete button
562 * - Syncs this.page_counter so next added page is correct
563 *
564 * @param {Object} [opts={}]
565 * @param {string} [opts.source='system']
566 * @returns {void}
567 */
568 renumber_pages_in_canvas(opts = {}) {
569
570 const source = String( opts.source || 'system' );
571 const pages = this._get_pages_in_dom_order();
572
573 for ( let i = 0; i < pages.length; i++ ) {
574
575 const page_el = pages[i];
576 const page_number = i + 1;
577
578 // Keep BOTH attributes consistent (some code may still read data-page).
579 page_el.setAttribute( 'data-page_number', String( page_number ) );
580 page_el.setAttribute( 'data-page', String( page_number ) );
581
582 const heading_el = this._get_page_number_heading_el( page_el );
583 if ( heading_el ) {
584 this._set_page_number_heading_text( heading_el, page_number );
585 }
586 }
587
588 // IMPORTANT:
589 // Keep the counter aligned with the current amount of pages,
590 // so add_page() creates the next correct number.
591 this.page_counter = pages.length;
592
593 // Optional: notify other UI (tabs, etc.).
594 try {
595 const ev = (window.WPBC_BFB_Core?.WPBC_BFB_Events?.PAGES_RENUMBERED) || 'wpbc:bfb:pages-renumbered';
596 this.bus?.emit?.( ev, { source: source, pages: pages.length } );
597 } catch ( _e ) {}
598 }
599
600 /**
601 * Start observer that renumbers pages after add/delete/reorder/load.
602 * Observes only direct children changes to avoid firing on every field change.
603 *
604 * @returns {void}
605 */
606 _start_pages_numbering_observer() {
607
608 if ( this._pages_numbering_observer || ! this.pages_container ) {
609 return;
610 }
611
612 const do_renumber = WPBC_Form_Builder_Helper.debounce( () => {
613 this.renumber_pages_in_canvas( { source: 'observer' } );
614 }, 50 );
615
616 this._pages_numbering_observer = new MutationObserver( (mutations) => {
617
618 let touched_pages = false;
619
620 for ( let i = 0; i < mutations.length; i++ ) {
621 const m = mutations[i];
622 if ( ! m || m.type !== 'childList' ) {
623 continue;
624 }
625
626 const nodes = []
627 .concat( Array.from( m.addedNodes || [] ) )
628 .concat( Array.from( m.removedNodes || [] ) );
629
630 for ( let k = 0; k < nodes.length; k++ ) {
631 const n = nodes[k];
632 if ( n && n.nodeType === 1 && n.classList && n.classList.contains( 'wpbc_bfb__panel--preview' ) ) {
633 touched_pages = true;
634 break;
635 }
636 }
637
638 if ( touched_pages ) {
639 break;
640 }
641 }
642
643 if ( touched_pages ) {
644 do_renumber();
645 }
646 } );
647
648 // IMPORTANT: childList only (no subtree), so fields dragging won’t trigger this.
649 this._pages_numbering_observer.observe( this.pages_container, { childList: true } );
650
651 // Initial pass.
652 do_renumber();
653 }
654
655 // -- Resizer --
656
657 /**
658 * Bind the resize mousedown handler with a balanced assert.
659 * - If handler is missing: log a clear error and gracefully skip,
660 * then attempt a one-tick retry (covers late module init).
661 * - Optional hard-fail in dev if window.WPBC_DEV_STRICT === true.
662 *
663 * @private
664 * @param {HTMLElement} resizer
665 * @returns {boolean} true if bound immediately, false otherwise
666 */
667 _bind_resizer(resizer) {
668 const handler = this.init_resize_handler;
669 if ( typeof handler === 'function' ) {
670 resizer.addEventListener( 'mousedown', handler );
671 return true;
672 }
673
674 const msg = 'WPBC: init_resize_handler missing. Check that WPBC_BFB_Resize_Controller is loaded/initialized before the builder.';
675
676 // Loud but non-fatal by default.
677 console.error( msg );
678
679 // Optional strict dev mode: throw to surface load-order problems early
680 if ( window.WPBC_DEV_STRICT === true ) {
681 setTimeout( () => {
682 throw new Error( msg );
683 }, 0 );
684 }
685
686 // One deferred retry in case the resize controller attaches slightly later
687 setTimeout( () => {
688 const late = this.init_resize_handler;
689 if ( typeof late === 'function' && resizer.isConnected ) {
690 resizer.addEventListener( 'mousedown', late );
691 }
692 }, 0 );
693
694 return false;
695 }
696
697 /**
698 * Factory for a column resizer element with binding handled.
699 *
700 * @private
701 * @returns {HTMLDivElement}
702 */
703 _create_resizer() {
704 const resizer = WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__column-resizer' );
705 this._bind_resizer( resizer );
706 return resizer;
707 }
708
709 /**
710 * Remove any existing resizers inside a row and rebuild them between columns.
711 *
712 * @private
713 * @param {HTMLElement} row_el - The section row (.wpbc_bfb__row)
714 * @returns {void}
715 */
716 _rebuild_resizers_for_row(row_el) {
717 if ( !row_el ) return;
718
719 // Remove all existing resizers
720 row_el.querySelectorAll( ':scope > .wpbc_bfb__column-resizer' ).forEach( r => r.remove() );
721
722 // Reinsert resizers between current columns
723 const cols = this._get_row_cols( row_el );
724 for ( let i = 0; i < cols.length - 1; i++ ) {
725 const resizer = this._create_resizer();
726 cols[i].insertAdjacentElement( 'afterend', resizer );
727 }
728 }
729
730 // -- End Resizer --
731
732 /**
733 * Set field's INTERNAL id (data-id). Does not rebind inspector.
734 *
735 * @param {HTMLElement} field_el - Target field element.
736 * @param {string} newIdRaw - New desired internal id.
737 * @returns {string} Applied id.
738 */
739 _set_field_id( field_el, newIdRaw ) {
740 const unique = this.id.set_field_id( field_el, newIdRaw, /*renderPreview*/ false );
741 if ( this.preview_mode ) {
742 this.render_preview( field_el );
743 }
744 return unique;
745 }
746
747 /**
748 * Set field's REQUIRED HTML name (data-name).
749 *
750 * @param {HTMLElement} field_el - Target field element.
751 * @param {string} newNameRaw - Desired HTML name.
752 * @returns {string} Applied unique name.
753 */
754 _set_field_name( field_el, newNameRaw ) {
755 const unique = this.id.set_field_name( field_el, newNameRaw, /*renderPreview*/ false );
756 if ( this.preview_mode ) {
757 this.render_preview( field_el );
758 }
759 return unique;
760 }
761
762 /**
763 * Set field's OPTIONAL HTML id (data-html_id). Empty removes it. Ensures sanitization and uniqueness among
764 * other fields that declared HTML ids.
765 *
766 * @param {HTMLElement} field_el - Target field element.
767 * @param {string} newHtmlIdRaw - Desired HTML id (optional).
768 * @returns {string} Applied html_id or empty string.
769 */
770 _set_field_html_id( field_el, newHtmlIdRaw ) {
771 const applied = this.id.set_field_html_id( field_el, newHtmlIdRaw, /*renderPreview*/ false );
772 if ( this.preview_mode ) {
773 this.render_preview( field_el );
774 }
775 return applied;
776 }
777
778 // == Accessibility ==
779
780 /**
781 * Lightweight ARIA-live announcer for accessibility/status messages.
782 * Kept local to the builder so callers can safely use it.
783 * @param {string} msg
784 */
785 _announce(msg) {
786 try {
787 let live = document.getElementById( 'wpbc_bfb__aria_live' );
788 if ( !live ) {
789 live = document.createElement( 'div' );
790 live.id = 'wpbc_bfb__aria_live';
791 live.setAttribute( 'aria-live', 'polite' );
792 live.setAttribute( 'aria-atomic', 'true' );
793 live.style.position = 'absolute';
794 live.style.left = '-9999px';
795 live.style.top = 'auto';
796 document.body.appendChild( live );
797 }
798 live.textContent = '';
799 setTimeout( () => {
800 live.textContent = String( msg || '' );
801 }, 10 );
802 } catch ( e ) {
803 // no-op: non-fatal UX helper.
804 }
805 }
806
807 /**
808 * Central place to register DOM listeners for later teardown.
809 *
810 * @private
811 * @param {EventTarget} target - Target to bind on.
812 * @param {string} type - Event type.
813 * @param {EventListener} handler - Handler function.
814 * @param {boolean|AddEventListenerOptions} [opts=false] - Listener options.
815 * @returns {void}
816 */
817 _on( target, type, handler, opts = false ) {
818 if ( ! this._handlers ) {
819 this._handlers = [];
820 }
821 target.addEventListener( type, handler, opts );
822 this._handlers.push( { target, type, handler, opts } );
823 }
824
825 // -- Check Usage Limits Helpers --
826
827 /**
828 * Return the usage key for a field node (palette uses data-usage_key; fallback to type).
829 *
830 * @param field_el
831 * @returns {string|*}
832 * @private
833 */
834 _get_usage_key(field_el) {
835 return field_el?.dataset?.usage_key || field_el?.dataset?.type || 'field';
836 }
837
838 /**
839 * Count how many of a given key are already present in the canvas.
840 *
841 * @param key
842 * @returns {*|number}
843 * @private
844 */
845 _count_used_in_canvas(key) {
846 if ( ! this.pages_container ) {
847 return 0;
848 }
849 const esc = window.WPBC_BFB_Core?.WPBC_BFB_Sanitize?.esc_attr_value_for_selector?.( key ) || key.replace( /"/g, '\\"' );
850 // match by usage_key first, then by type as a fallback (older fields).
851 return this.pages_container.querySelectorAll( `.wpbc_bfb__field[data-usage_key="${esc}"], .wpbc_bfb__field[data-type="${esc}"]` ).length;
852 }
853
854 /**
855 * Read the numeric limit for a usage key from any palette item; Infinity if not specified.
856 *
857 * @param key
858 * @returns {number}
859 * @private
860 */
861 _get_palette_limit_for_key(key) {
862 // prefer scanning builder-known palettes (supports multiple palettes).
863 const candidates = (this.palette_uls || [])
864 .map( ul => ul.querySelector( `[data-id="${key}"], [data-usage_key="${key}"]` ) )
865 .filter( Boolean );
866
867 const pel = candidates[0] || document.querySelector( `.wpbc_bfb__panel_field_types__ul [data-id="${key}"], .wpbc_bfb__panel_field_types__ul [data-usage_key="${key}"]` );
868
869 const n = Number( pel?.dataset?.usagenumber );
870 return Number.isFinite( n ) ? n : Infinity;
871 }
872
873 /**
874 * Tally how many of each usage key exist inside a subtree (fields only).
875 *
876 * @param root_el
877 * @returns {{}}
878 * @private
879 */
880 _tally_usage_in_subtree(root_el) {
881 const tally = {};
882 if ( ! root_el ) {
883 return tally;
884 }
885 root_el.querySelectorAll( '.wpbc_bfb__field' ).forEach( f => {
886 const k = this._get_usage_key( f );
887 tally[k] = (tally[k] || 0) + 1;
888 } );
889 return tally;
890 }
891
892 /**
893 * Preflight usage for a not-yet-inserted clone.
894 * strategy:
895 * - 'block' (default) -> return offenders if any limit would be exceeded
896 * - 'strip' -> mutate clone to remove over-limit fields and proceed
897 *
898 * @param clone
899 * @param strategy
900 * @returns {{ok:true}|{ok:false, offenders:Array<{key:string, limit:number, used:number, add:number}>}}
901 * @private
902 */
903 _preflight_usage_for_clone(clone, strategy = 'block') {
904 const offenders = [];
905 const tally = this._tally_usage_in_subtree( clone );
906
907 Object.entries( tally ).forEach( ([ key, addCount ]) => {
908 const limit = this._get_palette_limit_for_key( key );
909 if ( !Number.isFinite( limit ) ) return; // no limit declared -> ignore
910
911 const used = this._count_used_in_canvas( key );
912 const remaining = limit - used;
913
914 if ( remaining >= addCount ) return; // safe
915
916 if ( strategy === 'strip' ) {
917 // Keep only the remaining capacity; remove extras from the clone
918 const nodes = Array.from( clone.querySelectorAll(
919 `.wpbc_bfb__field[data-usage_key="${key}"], .wpbc_bfb__field[data-type="${key}"]`
920 ) );
921 const toRemove = nodes.slice( Math.max( 0, remaining ) );
922 toRemove.forEach( n => n.remove() );
923 } else {
924 offenders.push( { key, limit, used, add: addCount } );
925 }
926 } );
927
928 if ( strategy === 'strip' ) {
929 // After stripping, re-check; if still over (e.g. remaining < 0 for multiple keys), treat as offenders.
930 const re = this._tally_usage_in_subtree( clone );
931 const stillBad = Object.entries( re ).some( ([ key, add ]) => {
932 const limit = this._get_palette_limit_for_key( key );
933 return Number.isFinite( limit ) && (this._count_used_in_canvas( key ) + add) > limit;
934 } );
935 return stillBad ? { ok: false, offenders } : { ok: true };
936 }
937
938 return offenders.length ? { ok: false, offenders } : { ok: true };
939 }
940
941 // == Ajax =====================================================================================================
942
943 // == Auto Load Form on Start ==
944 /**
945 * Auto-load current form config from DB/legacy via admin-ajax.
946 *
947 * - If BFB structure exists -> loads it.
948 * - If legacy engine returns empty structure -> treats as loaded and creates a blank page.
949 * - Returns true if AJAX succeeded (even if structure is empty), false otherwise.
950 *
951 * @returns {Promise<boolean>}
952 */
953 async _auto_load_initial_form_from_ajax() {
954
955 // Initial Parameters for form loading on page refresh / load. // info: INIT_FORM_LOAD.
956 const cfg = window.WPBC_BFB_Ajax || {};
957
958 if ( ! cfg.url || ! cfg.nonce_load ) {
959 return false;
960 }
961
962 const payload = {
963 action : cfg.load_action || 'WPBC_AJX_BFB_LOAD_FORM_CONFIG',
964 nonce : cfg.nonce_load || '',
965 form_name: cfg.form_name || 'standard'
966 };
967
968 const r = await wpbc_bfb__post_ajax_promise( cfg.url, payload );
969
970 if ( r.status !== 200 ) {
971 return false;
972 }
973
974 let resp = null;
975 try {
976 resp = JSON.parse( r.text );
977 } catch ( _e ) {
978 return false;
979 }
980
981 if ( ! resp || ! resp.success || ! resp.data ) {
982 return false;
983 }
984
985 const data = resp.data || {};
986 const engine = String( data.engine || '' ).toLowerCase();
987
988 // Apply Advanced Mode texts (always useful for legacy).
989 if ( typeof data.advanced_form !== 'undefined' || typeof data.content_form !== 'undefined' ) {
990
991 const af = String( data.advanced_form || '' );
992 const cf = String( data.content_form || '' );
993
994 const ta_form = document.getElementById( 'wpbc_bfb__advanced_form_editor' );
995 const ta_content = document.getElementById( 'wpbc_bfb__content_form_editor' );
996
997 if ( ta_form ) {
998 ta_form.value = af;
999 }
1000 if ( ta_content ) {
1001 ta_content.value = cf;
1002 }
1003
1004 // ONLY supported: settings.bfb_options.advanced_mode_source (fallback to cfg.save_source or 'builder').
1005 let adv_mode_src = '';
1006 if ( typeof data.settings !== 'undefined' ) {
1007 try {
1008 const s = (typeof data.settings === 'string') ? JSON.parse( data.settings ) : data.settings;
1009 adv_mode_src = (s && s.bfb_options && s.bfb_options.advanced_mode_source) ? String( s.bfb_options.advanced_mode_source ) : '';
1010 } catch ( _e ) {}
1011 }
1012 if ( ! adv_mode_src ) {
1013 adv_mode_src = (window.WPBC_BFB_Ajax && window.WPBC_BFB_Ajax.save_source) ? String( window.WPBC_BFB_Ajax.save_source ) : 'builder';
1014 }
1015
1016 wpbc_bfb__dispatch_event_safe( 'wpbc:bfb:advanced_text:apply', {
1017 advanced_form : af,
1018 content_form : cf,
1019 advanced_mode_source: adv_mode_src
1020 } );
1021 }
1022
1023 // Apply local form settings to UI (if provided).
1024 if ( data.settings ) {
1025 wpbc_bfb__dispatch_event_safe( 'wpbc:bfb:form_settings:apply', {
1026 settings : data.settings,
1027 form_name: cfg.form_name || 'standard'
1028 } );
1029 }
1030
1031 wpbc_bfb__dispatch_event_safe( 'wpbc:bfb:form:ajax_loaded', {
1032 loaded_data: data,
1033 form_name : cfg.form_name || 'standard'
1034 } );
1035
1036 // Structure may be [] for legacy engines.
1037 const structure = Array.isArray( data.structure ) ? data.structure : [];
1038
1039 if ( structure.length > 0 ) {
1040 this.load_saved_structure( structure );
1041
1042 // Re-apply effects AFTER structure rebuild created the final DOM.
1043 try {
1044 if ( w.WPBC_BFB_Settings_Effects && typeof w.WPBC_BFB_Settings_Effects.reapply_after_canvas === 'function' ) {
1045 w.WPBC_BFB_Settings_Effects.reapply_after_canvas( data.settings, {
1046 source : 'ajax_load',
1047 form_name: cfg.form_name || 'standard'
1048 } );
1049 }
1050 } catch ( _e_fx ) {}
1051
1052 return true;
1053 }
1054
1055 // IMPORTANT: Legacy load is still a valid "loaded" state.
1056 // Create a blank page (so the canvas isn't empty), and DO NOT fallback to example.
1057 this.add_page();
1058
1059 try {
1060 if ( w.WPBC_BFB_Settings_Effects && typeof w.WPBC_BFB_Settings_Effects.reapply_after_canvas === 'function' ) {
1061 w.WPBC_BFB_Settings_Effects.reapply_after_canvas( data.settings, {
1062 source : 'ajax_load_legacy',
1063 form_name: cfg.form_name || 'standard'
1064 } );
1065 }
1066 } catch ( _e_fx2 ) {}
1067
1068 jQuery( '.wpbc_bfb__top_tab_section__builder_tab .wpbc_spins_loading_container' ).parents( '.wpbc_bfb__panel--preview' ).remove();
1069 // Optional: one-time notice for legacy.
1070 try {
1071 if ( engine && engine.indexOf( 'legacy_' ) === 0 && typeof window.wpbc_admin_show_message === 'function' ) {
1072 window.wpbc_admin_show_message( 'Loaded legacy form. Use “Import from Simple Form” to convert to Builder.', 'warning', 6000 );
1073 }
1074 } catch ( _e2 ) {}
1075
1076 return true;
1077 }
1078
1079
1080 // == Auto-open "Apply Template" modal ==
1081 /**
1082 * Auto-open "Apply Template" modal if URL has:
1083 * &auto_open_template=Service+Duration
1084 *
1085 * This will prefill the modal search input and trigger server-side search
1086 * (title / slug / description) via your templates listing endpoint.
1087 *
1088 * Security:
1089 * - decodes safely
1090 * - strips control chars
1091 * - clamps length
1092 * - never injects HTML (only sets input.value)
1093 *
1094 * @returns {Promise<boolean>} True if auto-open was triggered.
1095 */
1096 async _auto_open_apply_template_modal_from_url() {
1097
1098 try {
1099 // One-time per page load (avoid double open when called from multiple paths).
1100 if ( window.__wpbc_bfb_auto_open_template_done ) {
1101 return false;
1102 }
1103
1104 // Read raw query param.
1105 const href = String( window.location && window.location.href ? window.location.href : '' );
1106 if ( ! href ) {
1107 return false;
1108 }
1109
1110 let raw_value = '';
1111
1112 try {
1113 const u = new URL( href );
1114 raw_value = u.searchParams.get( 'auto_open_template' ) || '';
1115 } catch ( _e0 ) {
1116 // Fallback minimal parser (should rarely happen).
1117 const m = href.match( /[?&]auto_open_template=([^&#]*)/i );
1118 raw_value = m && m[1] ? m[1] : '';
1119 }
1120
1121 raw_value = String( raw_value || '' );
1122
1123 // URLSearchParams usually decodes, but we also normalize "+" -> " " for safety.
1124 // If already decoded, this is harmless.
1125 raw_value = raw_value.replace( /\+/g, ' ' );
1126
1127 // Try decodeURIComponent (in case fallback parser captured encoded string).
1128 try {
1129 raw_value = decodeURIComponent( raw_value );
1130 } catch ( _e1 ) {}
1131
1132 // Sanitize: remove control chars, trim, collapse spaces.
1133 let search_key = raw_value
1134 .replace( /[\u0000-\u001F\u007F]/g, ' ' )
1135 .replace( /\s+/g, ' ' )
1136 .trim();
1137
1138 // Normalize OR separator so URL separator "^" becomes UI separator "|".
1139 try {
1140 const sep = (cfg && cfg.template_search_or_sep) ? String( cfg.template_search_or_sep ) : '|';
1141 const urlSep = (cfg && cfg.template_search_or_sep_url) ? String( cfg.template_search_or_sep_url ) : '^';
1142
1143 const escSep = String( sep ).replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
1144 const escUrlSep = String( urlSep ).replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
1145
1146 // Convert URL separator into UI separator.
1147 if ( urlSep && urlSep !== sep ) {
1148 search_key = search_key.replace( new RegExp( '\\s*' + escUrlSep + '\\s*', 'g' ), sep );
1149 }
1150
1151 // Normalize UI separator.
1152 search_key = search_key.replace( new RegExp( '\\s*' + escSep + '\\s*', 'g' ), sep );
1153 search_key = search_key.replace( new RegExp( escSep + '{2,}', 'g' ), sep );
1154 search_key = search_key.replace( new RegExp( '^' + escSep + '+|' + escSep + '+$', 'g' ), '' ).trim();
1155 } catch ( _e_sep ) {}
1156
1157 // Clamp length (avoid silly URLs).
1158 if ( search_key.length > 80 ) {
1159 search_key = search_key.slice( 0, 80 ).trim();
1160 }
1161
1162 if ( ! search_key ) {
1163 return false;
1164 }
1165
1166 // Mark as handled (we have a real value).
1167 window.__wpbc_bfb_auto_open_template_done = true;
1168
1169 // Wait for the modal helper to exist (script load-order safe).
1170 const ready = await this._wait_for_apply_template_search_fn( 3500 );
1171 if ( ! ready ) {
1172 // Silent fail (or log in dev).
1173 try {
1174 console.warn( 'WPBC BFB: apply template modal helper not ready (auto_open_template skipped).' );
1175 } catch ( _e2 ) {}
1176 return false;
1177 }
1178
1179 // Open modal with prefilled search.
1180 window.wpbc_bfb__menu_forms__apply_template_search( search_key, null );
1181
1182 return true;
1183
1184 } catch ( _e3 ) {
1185 return false;
1186 }
1187 }
1188
1189 /**
1190 * Wait until wpbc_bfb__menu_forms__apply_template_search() exists.
1191 *
1192 * @param {number} timeout_ms
1193 * @returns {Promise<boolean>}
1194 */
1195 _wait_for_apply_template_search_fn(timeout_ms) {
1196
1197 timeout_ms = parseInt( timeout_ms || 0, 10 );
1198 if ( ! timeout_ms || timeout_ms < 200 ) {
1199 timeout_ms = 200;
1200 }
1201
1202 return new Promise( (resolve) => {
1203
1204 const started = Date.now();
1205
1206 const is_ready = () => {
1207 return (typeof window.wpbc_bfb__menu_forms__apply_template_search === 'function');
1208 };
1209
1210 if ( is_ready() ) {
1211 return resolve( true );
1212 }
1213
1214 const t = setInterval( () => {
1215
1216 if ( is_ready() ) {
1217 clearInterval( t );
1218 return resolve( true );
1219 }
1220
1221 if ( (Date.now() - started) > timeout_ms ) {
1222 clearInterval( t );
1223 return resolve( false );
1224 }
1225
1226 }, 50 );
1227 } );
1228 }
1229 // =============================================================================================================
1230
1231 /**
1232 * Load a module and initialize it.
1233 *
1234 * @param {Function} Module_Class - Module class reference.
1235 * @param {Object} [options = {}] - Optional module options.
1236 * @returns {WPBC_BFB_Module}
1237 */
1238 use_module(Module_Class, options = {}) {
1239 const mod = new Module_Class( this, options );
1240 if ( typeof mod.init === 'function' ) {
1241 mod.init();
1242 }
1243 this._modules.push( mod );
1244 return mod;
1245 }
1246
1247 /**
1248 * Dispose all listeners, observers, and Sortable instances created by the builder.
1249 *
1250 * @returns {void}
1251 */
1252 destroy() {
1253 // Mutation observer.
1254 if ( this._usage_observer ) {
1255 try {
1256 this._usage_observer.disconnect();
1257 } catch (e) {}
1258 this._usage_observer = null;
1259 }
1260
1261 // Pages numbering observer.
1262 if ( this._pages_numbering_observer ) {
1263 try {
1264 this._pages_numbering_observer.disconnect();
1265 } catch ( e ) {}
1266 this._pages_numbering_observer = null;
1267 }
1268
1269 // Registered DOM listeners.
1270 if ( Array.isArray( this._handlers ) ) {
1271 this._handlers.forEach( ({ target, type, handler, opts }) => {
1272 try {
1273 target.removeEventListener( type, handler, opts );
1274 } catch (e) {
1275 // No-op.
1276 }
1277 } );
1278 this._handlers = [];
1279 }
1280
1281 // Sortable instances.
1282 if ( this.sortable && typeof this.sortable.destroyAll === 'function' ) {
1283 this.sortable.destroyAll();
1284 }
1285
1286 // Destroy registered modules.
1287 if ( Array.isArray( this._modules ) ) {
1288 for ( const mod of this._modules ) {
1289 try {
1290 if ( typeof mod.destroy === 'function' ) {
1291 mod.destroy();
1292 }
1293 } catch( e ){ _wpbc?.dev?.error( 'WPBC_Form_Builder - Destroy registered modules', e ); }
1294 }
1295 this._modules = [];
1296 }
1297
1298 // Live region can stay for the page lifetime; remove if you want full cleanup.
1299 // if ( this._aria_live && this._aria_live.parentNode ) {
1300 // this._aria_live.parentNode.removeChild( this._aria_live );
1301 // this._aria_live = null;
1302 // }
1303
1304 // Clear globals to help GC.
1305 this.inspector = null;
1306 this.pages_container = null;
1307 }
1308
1309 /**
1310 * Initialize SortableJS on a container for fields or sections.
1311 *
1312 * @param {HTMLElement} container - Target DOM element.
1313 * @param {Function} [on_add_callback] - Optional custom handler for onAdd.
1314 * @returns {void}
1315 */
1316 init_sortable( container, on_add_callback = this.handle_on_add.bind( this ) ) {
1317 if ( ! container ) return;
1318 if ( typeof Sortable === 'undefined' ) return;
1319 // If container is not attached yet (e.g., freshly cloned), defer to next tick.
1320 if ( ! container.isConnected ) {
1321 setTimeout( () => {
1322 if ( container.isConnected ) {
1323 this.sortable.ensure( container, 'canvas', { onAdd: on_add_callback } );
1324 }
1325 }, 0 );
1326 return;
1327 }
1328 this.sortable.ensure( container, 'canvas', { onAdd: on_add_callback } );
1329 }
1330
1331 /**
1332 * Handler when an item is added via drag-and-drop.
1333 * Applies usage limits, nesting checks, and builds new field if needed.
1334 *
1335 * @param {Object} evt - SortableJS event object.
1336 * @returns {void}
1337 */
1338 handle_on_add( evt ) {
1339 if ( ! evt || ! evt.item || ! evt.to ) {
1340 return;
1341 }
1342
1343 let el = evt.item;
1344
1345 // --- Section path. ------------------------------------------------------
1346 if ( el.classList.contains( 'wpbc_bfb__section' ) ) {
1347 const nesting_level = this.get_nesting_level( el );
1348 if ( nesting_level >= this.max_nested_value ) {
1349 alert( 'Too many nested sections.' );
1350 el.remove();
1351 return;
1352 }
1353 this.init_all_nested_sortables( el );
1354
1355 // Ensure UI is fully initialized for newly placed/moved sections.
1356 this.add_overlay_toolbar( el );
1357 const row = el.querySelector( ':scope > .wpbc_bfb__row' );
1358 if ( row ) {
1359 this._rebuild_resizers_for_row( row );
1360 this.layout.set_equal_bases( row, this.col_gap_percent );
1361 }
1362
1363 this.usage.update_palette_ui();
1364
1365 this.select_field( el, { scrollIntoView: true } );
1366
1367 return;
1368 }
1369
1370 // --- Field path. --------------------------------------------------------
1371 const is_from_palette = this.palette_uls?.includes?.(evt.from);
1372 const paletteId = el?.dataset?.id;
1373
1374 if ( ! paletteId ) {
1375 console.warn( 'Dropped element missing data-id.', el );
1376 return;
1377 }
1378
1379 if ( is_from_palette ) {
1380 // Read data before removing the temporary clone.
1381 const field_data = WPBC_Form_Builder_Helper.get_all_data_attributes( el );
1382 const usage_key = field_data.usage_key || paletteId;
1383 field_data.usage_key = usage_key;
1384 if ( 'uid' in field_data ) {
1385 delete field_data.uid; // Guard: never carry a UID from palette/DOM clones.
1386 }
1387
1388 // Remove Sortable's temporary clone so counts are accurate.
1389 el.remove();
1390
1391 // Centralized usage gate.
1392 if ( ! this.usage.gate_or_alert( usage_key, { label: field_data.label || usage_key } ) ) {
1393 return;
1394 }
1395
1396 // Build and insert the real field node at the intended index.
1397 const rebuilt = this.build_field( field_data );
1398 if ( ! rebuilt ) {
1399 return;
1400 }
1401
1402 const selector = Sortable.get( evt.to )?.options?.draggable || '.wpbc_bfb__field, .wpbc_bfb__section';
1403 const scopedSelector = selector.split( ',' ).map( s => `:scope > ${s.trim()}` ).join( ', ' );
1404 const draggables = Array.from( evt.to.querySelectorAll( scopedSelector ) );
1405 const before = Number.isInteger( evt.newIndex ) ? (draggables[evt.newIndex] ?? null) : null;
1406
1407 evt.to.insertBefore( rebuilt, before );
1408 el = rebuilt; // Continue with the unified path below.
1409 } else {
1410 // Moving an existing field within the canvas. No usage delta here.
1411 }
1412
1413 // Finalize: decorate, emit, hook, and select.
1414 this.decorate_field( el );
1415 this._emit_const( WPBC_BFB_Events.FIELD_ADD, { el, data: WPBC_Form_Builder_Helper.get_all_data_attributes( el ) } );
1416 this.usage.update_palette_ui();
1417 this.trigger_field_drop_callback( el, 'drop' );
1418 this.select_field( el, { scrollIntoView: true } );
1419 }
1420
1421 /**
1422 * Call static on_field_drop method for supported field types.
1423 *
1424 * @param {HTMLElement} field_el - Field element to handle.
1425 * @param {string} context - Context of the event: 'drop' | 'load' | 'preview'.
1426 * @returns {void}
1427 */
1428 trigger_field_drop_callback( field_el, context = 'drop' ) {
1429 if ( ! field_el || ! field_el.classList.contains( 'wpbc_bfb__field' ) ) {
1430 return;
1431 }
1432
1433 const field_data = WPBC_Form_Builder_Helper.get_all_data_attributes( field_el );
1434
1435 const type = field_data.type;
1436
1437 try {
1438 const FieldClass = this._getRenderer(type);
1439 if ( FieldClass && typeof FieldClass.on_field_drop === 'function' ) {
1440 FieldClass.on_field_drop( field_data, field_el, { context } );
1441 }
1442 } catch ( err ) {
1443 console.warn( `on_field_drop failed for type "${type}".`, err );
1444 }
1445 }
1446
1447 /**
1448 * Calculate nesting depth of a section based on parent hierarchy.
1449 *
1450 * @param {HTMLElement} section_el - Target section element.
1451 * @returns {number} Nesting depth (0 = top-level).
1452 */
1453 get_nesting_level( section_el ) {
1454 let level = 0;
1455 let parent = section_el.closest( '.wpbc_bfb__column' );
1456
1457 while ( parent ) {
1458 const outer = parent.closest( '.wpbc_bfb__section' );
1459 if ( ! outer ) {
1460 break;
1461 }
1462 level++;
1463 parent = outer.closest( '.wpbc_bfb__column' );
1464 }
1465 return level;
1466 }
1467
1468 /**
1469 * Create a field DOM element from structured data.
1470 * Applies label, type, drag handle, and visual mode.
1471 *
1472 * @param {Object} field_data - Field properties (id, type, label, etc.).
1473 * @returns {HTMLElement|null} Built field element, or null on error/limit.
1474 */
1475 build_field( field_data ) {
1476 if ( ! field_data || typeof field_data !== 'object' ) {
1477 console.warn( 'Invalid field data:', field_data );
1478 return WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__field is-invalid', 'Invalid field' );
1479 }
1480
1481 // Decide a desired id first (may come from user/palette).
1482 let desiredIdRaw;
1483 if ( ! field_data.id || '' === String( field_data.id ).trim() ) {
1484 const base = (field_data.label ? String( field_data.label ) : (field_data.type || 'field'))
1485 .toLowerCase()
1486 .replace( /[^a-z0-9]+/g, '-' )
1487 .replace( /^-+|-+$/g, '' );
1488 desiredIdRaw = `${base || 'field'}-${Math.random().toString( 36 ).slice( 2, 7 )}`;
1489 } else {
1490 desiredIdRaw = String( field_data.id );
1491 }
1492
1493 // Sanitize the id the user provided.
1494 const desiredId = WPBC_BFB_Sanitize.sanitize_html_id( desiredIdRaw );
1495
1496 // Usage key remains stable (palette sets usage_key; otherwise use *raw* user intent).
1497 let usageKey = field_data.usage_key || field_data.type || desiredIdRaw;
1498 // Normalize common aliases to palette ids (extend as needed).
1499 if ( usageKey === 'input-text' ) {
1500 usageKey = 'text';
1501 }
1502
1503 // Ensure the DOM/data-id we actually use is unique (post-sanitization).
1504 field_data.id = this.id.ensure_unique_field_id( desiredId );
1505
1506 // Ensure name exists, sanitized, and unique.
1507 let desiredName = (field_data.name != null) ? field_data.name : field_data.id;
1508 desiredName = WPBC_BFB_Sanitize.sanitize_html_name( desiredName );
1509 field_data.name = this.id.ensure_unique_field_name( desiredName );
1510
1511 // Check usage count.
1512 if ( ! this.usage.is_usage_ok( usageKey ) ) {
1513 console.warn( `Field "${usageKey}" skipped – exceeds usage limit.` );
1514 return null;
1515 }
1516
1517 const el = WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__field' );
1518 // Only this builder UID (do NOT allow overrides from incoming data).
1519 const uid = this._generate_uid( 'f' );
1520 el.setAttribute( 'data-uid', uid );
1521 // Drop any upstream uid so set_data_attributes can’t clobber ours.
1522 const { uid: _discardUid, ...safeData } = (field_data || {});
1523 WPBC_Form_Builder_Helper.set_data_attributes( el, { ...safeData, usage_key: usageKey } );
1524
1525 // reflect min width (purely visual; resizing enforcement happens in the resizer).
1526 const min_raw = String( field_data.min_width || '' ).trim();
1527 if ( min_raw ) {
1528 // let CSS do the parsing: supports px, %, rem, etc.
1529 el.style.minWidth = min_raw;
1530 }
1531
1532 el.innerHTML = WPBC_Form_Builder_Helper.render_field_inner_html( field_data );
1533 this.decorate_field( el );
1534
1535 return el;
1536 }
1537
1538 /**
1539 * Enhance a field element with drag handle, delete, move buttons, or preview.
1540 *
1541 * @param {HTMLElement} field_el - Target field element.
1542 * @returns {void}
1543 */
1544 decorate_field( field_el ) {
1545 if ( ! field_el || field_el.classList.contains( 'wpbc_bfb__section' ) ) {
1546 return;
1547 }
1548
1549 field_el.classList.add( 'wpbc_bfb__field' );
1550 field_el.classList.add( 'wpbc_bfb__drag-anywhere' ); // Lets grab the field card itself to drag (outside of overlay / inputs).
1551
1552 // Render.
1553 if ( this.preview_mode ) {
1554 this.render_preview( field_el );
1555 } else {
1556 this.add_overlay_toolbar( field_el );
1557 }
1558 }
1559
1560 /**
1561 * Add overlay toolbar to a field/section.
1562 *
1563 * @param {HTMLElement} field_el - Field or section element.
1564 * @returns {void}
1565 */
1566 add_overlay_toolbar(field_el) {
1567 WPBC_BFB_Overlay.ensure( this, field_el );
1568
1569 }
1570
1571 /**
1572 * Render a simplified visual representation of a field (Preview Mode).
1573 *
1574 * @param {HTMLElement} field_el - Target field element.
1575 * @returns {void}
1576 */
1577 render_preview( field_el ) {
1578 if ( ! field_el || ! this.preview_mode ) {
1579 return;
1580 }
1581
1582 const data = WPBC_Form_Builder_Helper.get_all_data_attributes( field_el );
1583 const type = data.type;
1584 const id = data.id || '';
1585 const hasExplicitLabel = Object.prototype.hasOwnProperty.call( data, 'label' );
1586 // const label = hasExplicitLabel ? data.label : id; //.
1587
1588 try {
1589 const R = this._getRenderer( type );
1590 if ( R && typeof R.render === 'function' ) {
1591 const ctx = {
1592 mode : 'preview',
1593 builder: this,
1594 tpl : (id) => (window.wp && wp.template ? wp.template( id ) : null),
1595 sanit : WPBC_BFB_Sanitize
1596 };
1597 // Renderer is responsible for writing to field_el.innerHTML.
1598 R.render( field_el, data, ctx );
1599
1600 field_el.classList.add( 'wpbc_bfb__preview-rendered' );
1601 } else {
1602 if ( type ) {
1603 // console.warn( `No renderer found for field type: ${type}.` );
1604 w._wpbc?.dev?.once( 'render_preview', `No renderer found for field type: ${type}.`, R );
1605 }
1606 field_el.innerHTML = WPBC_Form_Builder_Helper.render_field_inner_html( data );
1607 }
1608 } catch ( err ) {
1609 console.error( 'Renderer error.', err );
1610
1611 field_el.innerHTML = WPBC_Form_Builder_Helper.render_field_inner_html( data );
1612 }
1613
1614 this.add_overlay_toolbar( field_el );
1615
1616
1617 // Optional hook after DOM is in place.
1618 try {
1619 const R = this._getRenderer( type );
1620 // New contract: prefer hydrate(); fall back to legacy after_render if present.
1621 if ( R && typeof R.hydrate === 'function' ) {
1622 R.hydrate( field_el, data, {
1623 mode : 'preview',
1624 builder: this,
1625 tpl : (id) => (window.wp && wp.template ? wp.template( id ) : null),
1626 sanit : WPBC_BFB_Sanitize
1627 } );
1628 } else if ( R && typeof R.after_render === 'function' ) {
1629 R.after_render( data, field_el ); // legacy compatibility.
1630 }
1631 } catch ( err2 ) {
1632 console.warn( 'after_render hook failed.', err2 );
1633 }
1634 }
1635
1636 /**
1637 * Move an element (field/section) up or down in its parent container.
1638 *
1639 * @param {HTMLElement} el - Target element to move.
1640 * @param {string} direction - 'up' or 'down'.
1641 * @returns {void}
1642 */
1643 move_item( el, direction ) {
1644 const container = el?.parentElement;
1645 if ( ! container ) {
1646 return;
1647 }
1648
1649 const siblings = Array.from( container.children ).filter( ( child ) =>
1650 child.classList.contains( 'wpbc_bfb__field' ) || child.classList.contains( 'wpbc_bfb__section' )
1651 );
1652
1653 const current_index = siblings.indexOf( el );
1654 if ( current_index === -1 ) {
1655 return;
1656 }
1657
1658 const new_index = direction === 'up' ? current_index - 1 : current_index + 1;
1659 if ( new_index < 0 || new_index >= siblings.length ) {
1660 return;
1661 }
1662
1663 const reference_node = siblings[new_index];
1664 if ( direction === 'up' ) {
1665 container.insertBefore( el, reference_node );
1666 }
1667 if ( direction === 'down' ) {
1668 container.insertBefore( el, reference_node.nextSibling );
1669 }
1670 }
1671
1672 /**
1673 * Set the number of columns for a given section element.
1674 *
1675 * - Increasing: appends new empty columns and resizers, (re)inits Sortable, and equalizes widths.
1676 * - Decreasing: moves children of removed columns into the previous column, removes columns/resizers,
1677 * refreshes Sortable, and equalizes widths.
1678 *
1679 * @param {HTMLElement} section_el - The .wpbc_bfb__section element to mutate.
1680 * @param {number} new_count_raw - Desired column count.
1681 * @returns {void}
1682 */
1683 set_section_columns( section_el, new_count_raw ) {
1684 if ( ! section_el || ! section_el.classList.contains( 'wpbc_bfb__section' ) ) {
1685 return;
1686 }
1687
1688 const row = section_el.querySelector( ':scope > .wpbc_bfb__row' );
1689 if ( ! row ) {
1690 return;
1691 }
1692
1693 // Normalize and clamp count (supports 1..4; extend if needed).
1694 const old_cols = this._get_row_cols( row );
1695 const current = old_cols.length || 1;
1696 const min_c = 1;
1697 const max_c = 4;
1698 const target = Math.max( min_c, Math.min( max_c, parseInt( new_count_raw, 10 ) || current ) );
1699
1700 if ( target === current ) {
1701 return;
1702 }
1703
1704
1705
1706 // Increasing columns -> append new columns at the end.
1707 if ( target > current ) {
1708 for ( let i = current; i < target; i++ ) {
1709
1710 // TODO FIX: remove stray "wpbc__field" class; keep canonical column class only. For now it is required.
1711 const col = WPBC_Form_Builder_Helper.create_element( 'div', 'wpbc_bfb__column wpbc__field' );
1712 // Give it some initial basis; will be normalized after.
1713 col.style.flexBasis = ( 100 / target ) + '%';
1714 // Make this column a drop target.
1715 this.init_sortable?.( col );
1716 row.appendChild( col );
1717 }
1718 this._rebuild_resizers_for_row(row);
1719 // Equalize widths considering gap.
1720 this.layout.set_equal_bases( row, this.col_gap_percent );
1721
1722 // Overlay: ensure the layout preset chips are present for >1 columns.
1723 this.add_overlay_toolbar( section_el );
1724
1725 // Notify listeners (e.g., Min-Width Guard) that structure changed.
1726 this.bus.emit( WPBC_BFB_Events.STRUCTURE_CHANGE, { source : 'columns-change', section: section_el, count : target } );
1727
1728 return;
1729 }
1730
1731 // Decreasing columns -> merge contents of trailing columns into the previous one, then remove.
1732 if ( target < current ) {
1733 // We’ll always remove from the end down to the target count,
1734 // moving all children of the last column into the previous column.
1735 for ( let i = current; i > target; i-- ) {
1736 // Recompute current list each iteration.
1737 const cols_now = this._get_row_cols( row );
1738 const last = cols_now[ cols_now.length - 1 ];
1739 const prev = cols_now[ cols_now.length - 2 ] || null;
1740
1741 if ( last && prev ) {
1742 // Move children (sections or fields) to previous column.
1743 while ( last.firstChild ) {
1744 prev.appendChild( last.firstChild );
1745 }
1746 // Remove last column.
1747 last.remove();
1748 }
1749 }
1750
1751 // Rebuild resizers and refresh Sortable on the surviving columns.
1752 this._rebuild_resizers_for_row(row);
1753
1754 this._get_row_cols( row ).forEach( col => {
1755 // If Sortable missing, init; if present, do nothing (Sortable.get returns instance).
1756 if ( typeof Sortable !== 'undefined' && !Sortable.get?.( col ) ) {
1757 this.init_sortable?.( col );
1758 }
1759 } );
1760
1761 // Normalize widths.
1762 const computed = this.layout.compute_effective_bases_from_row( row, this.col_gap_percent );
1763 this.layout.apply_bases_to_row( row, computed.bases );
1764
1765 // Overlay: hide layout presets if single-column now; ensure toolbar re-checks.
1766 this.add_overlay_toolbar( section_el );
1767
1768 // Notify listeners (e.g., Min-Width Guard) that structure changed.
1769 this.bus.emit( WPBC_BFB_Events.STRUCTURE_CHANGE, { source : 'columns-change', section: section_el, count : target } );
1770 }
1771 }
1772
1773
1774 /**
1775 * Public API: set preview mode and (optionally) rebuild the canvas.
1776 *
1777 * @param {boolean} enabled
1778 * @param {Object} [opts]
1779 * @param {boolean} [opts.rebuild=true]
1780 * @param {boolean} [opts.reinit=true]
1781 * @param {string} [opts.source='settings']
1782 */
1783 set_preview_mode(enabled, opts = {}) {
1784
1785 const next = !!enabled;
1786 const rebuild = (opts.rebuild !== false);
1787 const reinit = (opts.reinit !== false);
1788
1789 if ( next === this.preview_mode ) {
1790 return;
1791 }
1792
1793 this.preview_mode = next;
1794
1795 // Rebuild DOM so fields/sections render according to the new mode.
1796 if ( rebuild ) {
1797 this.load_saved_structure( this.get_structure(), { deferIfTyping: true } );
1798
1799 // Some renderers rely on on_field_drop hooks to (re)wire themselves.
1800 if ( reinit ) {
1801 this._reinit_all_fields( 'preview' );
1802 }
1803 }
1804
1805 // Optional event (safe fallback string if constant doesn't exist).
1806 try {
1807 const ev = (window.WPBC_BFB_Core?.WPBC_BFB_Events?.PREVIEW_MODE_CHANGE) || 'wpbc:bfb:preview-mode-change';
1808 this.bus?.emit?.( ev, { enabled: next, source: opts.source || 'builder' } );
1809 } catch ( _ ) {}
1810 }
1811
1812 /**
1813 * Public API: refresh canvas previews without changing preview_mode.
1814 *
1815 * @param {Object} [opts]
1816 * @param {boolean} [opts.hard=true] Re-render all fields; false => only selected.
1817 * @param {boolean} [opts.rebuild=true] If hard: rebuild via load_saved_structure().
1818 * @param {boolean} [opts.reinit=true] If hard+rebuild: call _reinit_all_fields('preview').
1819 * @param {boolean} [opts.restore_selection=true] Restore previously selected field.
1820 * @param {boolean} [opts.restore_scroll=true] Restore canvas scroll.
1821 * @param {boolean} [opts.silent_inspector=false] Skip Inspector sync to avoid loops.
1822 * @param {string} [opts.source='settings'] Caller tag for logs/events.
1823 */
1824 refresh_canvas(opts = {}) {
1825
1826 if ( this.__refreshing_canvas ) {
1827 return;
1828 }
1829 this.__refreshing_canvas = true;
1830
1831 const hard = (opts.hard !== false);
1832 const rebuild = (opts.rebuild !== false);
1833 const reinit = (opts.reinit !== false);
1834 const restore_selection = (opts.restore_selection !== false);
1835 const restore_scroll = (opts.restore_scroll !== false);
1836 const source = opts.source || 'builder';
1837 const silent_inspector = (opts.silent_inspector === true);
1838
1839 const evs = (window.WPBC_BFB_Core && window.WPBC_BFB_Core.WPBC_BFB_Events) || {};
1840 const EV_BEFORE = evs.CANVAS_REFRESH || 'wpbc:bfb:canvas-refresh';
1841 const EV_AFTER = evs.CANVAS_REFRESHED || 'wpbc:bfb:canvas-refreshed';
1842
1843 try {
1844 // Snapshot UI state.
1845 const in_preview = !!this.preview_mode;
1846 const canvas = this._canvas_root || document.querySelector( '.wpbc_bfb__canvas' ) || document.body;
1847 const sc_top = restore_scroll ? (canvas ? canvas.scrollTop : 0) : 0;
1848
1849 let sel_el = null, sel_id = null;
1850 if ( restore_selection && typeof this.get_selected_field === 'function' ) {
1851 sel_el = this.get_selected_field();
1852 if ( sel_el && sel_el.getAttribute ) {
1853 sel_id = sel_el.getAttribute( 'data-id' );
1854 }
1855 }
1856
1857 // Signal "before" for packs that want to teardown overlays.
1858 try {
1859 this.bus && this.bus.emit && this.bus.emit( EV_BEFORE, {
1860 mode : hard ? 'hard' : 'soft',
1861 source : source,
1862 preview: in_preview
1863 } );
1864 } catch ( _ ) {}
1865
1866 // Do the work.
1867 if ( !in_preview ) {
1868 // Not in preview: nothing to render, but still emit AFTER later.
1869 } else if ( hard ) {
1870 if ( rebuild ) {
1871 this.load_saved_structure( this.get_structure(), { deferIfTyping: true } );
1872 if ( reinit ) {
1873 this._reinit_all_fields( 'preview' );
1874 }
1875 } else if ( typeof this.render_preview_all === 'function' ) {
1876 this.render_preview_all();
1877 // Some packs initialize in on_field_drop(); hydrate them for soft hard-refresh.
1878 this._reinit_all_fields( 'preview' );
1879 } else {
1880 const nodes = document.querySelectorAll( '.wpbc_bfb__field' );
1881 for ( let i = 0; i < nodes.length; i++ ) {
1882 this.render_preview( nodes[i], { force: true } );
1883 }
1884 }
1885 } else {
1886 // soft ????
1887 if ( sel_el ) {
1888 this.render_preview( sel_el, { force: true } );
1889 }
1890 }
1891
1892 // Restore selection + scroll.
1893 if ( restore_selection && sel_id && typeof this.select_by_data_id === 'function' ) {
1894 this.select_by_data_id( sel_id, { silent: true } );
1895 }
1896 if ( restore_scroll && canvas ) {
1897 canvas.scrollTop = sc_top;
1898 }
1899
1900 // Optional bridge: ask Inspector to sync to the selected element (unless silenced).
1901 if ( !silent_inspector ) {
1902 try {
1903 this._inspector_bridge && this._inspector_bridge.sync_from_selected && this._inspector_bridge.sync_from_selected();
1904 } catch ( _ ) {}
1905 }
1906
1907 // Signal "after" so packs can re-init widgets (time selector, masks, etc.).
1908 try {
1909 this.bus && this.bus.emit && this.bus.emit( EV_AFTER, {
1910 mode : hard ? 'hard' : 'soft',
1911 source : source,
1912 preview: in_preview
1913 } );
1914 } catch ( _ ) {
1915 }
1916
1917 } finally {
1918 this.__refreshing_canvas = false;
1919 }
1920 }
1921
1922 /**
1923 * Optional convenience: render all previews (no rebuild).
1924 * Useful if you want a fast "hard" refresh without structure reload.
1925 */
1926 render_preview_all() {
1927 const root = this.pages_container || document;
1928 const nodes = root.querySelectorAll( '.wpbc_bfb__panel--preview .wpbc_bfb__field' );
1929 for ( let i = 0; i < nodes.length; i++ ) {
1930 this.render_preview( nodes[i], { force: true } );
1931 }
1932 }
1933
1934
1935 /**
1936 * Duplicate a field or section and insert the copy right after the original.
1937 * - Fields: respects usage limits; generates new unique id/name/html_id + uid; re-renders preview/overlay.
1938 * - Sections: deep-clones; makes all contained fields unique; re-inits resizers/sortables; re-renders.
1939 *
1940 * @param {HTMLElement} el - The .wpbc_bfb__field or .wpbc_bfb__section to duplicate.
1941 * @returns {HTMLElement|null} The newly inserted copy, or null if blocked (e.g., usage limits).
1942 */
1943 duplicate_item(el) {
1944 if ( !el || !(el.classList?.contains( 'wpbc_bfb__field' ) || el.classList?.contains( 'wpbc_bfb__section' )) ) {
1945 return null;
1946 }
1947 if ( el.classList.contains( 'wpbc_bfb__field' ) ) {
1948 return this._duplicate_field( el );
1949 }
1950 if ( el.classList.contains( 'wpbc_bfb__section' ) ) {
1951 return this._duplicate_section( el );
1952 }
1953 return null;
1954 }
1955
1956 /**
1957 * Duplicate a single field node.
1958 * Gate by usage limit, rebuild via build_field() so all invariants stay consistent.
1959 *
1960 * @private
1961 * @param {HTMLElement} field_el
1962 * @returns {HTMLElement|null}
1963 */
1964 _duplicate_field(field_el) {
1965 const data = WPBC_Form_Builder_Helper.get_all_data_attributes( field_el );
1966 const usageKey = field_el.dataset.usage_key || data.usage_key || data.type || 'field';
1967
1968 // Respect usage limits.
1969 if ( !this.usage.gate_or_alert( usageKey, { label: data.label || usageKey } ) ) {
1970 return null;
1971 }
1972
1973 // Build a fresh field; let the builder assign unique id/name/html_id and uid.
1974 const toBuild = { ...data };
1975 // Clear identifiers to force uniqueness on the duplicate.
1976 delete toBuild.id;
1977 delete toBuild.name;
1978 if ( 'html_id' in toBuild ) delete toBuild.html_id;
1979 // VERY IMPORTANT!: drop the original UID so build_field creates a new one.
1980 if ( 'uid' in toBuild ) {
1981 delete toBuild.uid;
1982 }
1983
1984 const copy = this.build_field( toBuild );
1985 if ( !copy ) return null;
1986
1987 if ( copy.hasAttribute( 'data-draggable' ) ) {
1988 copy.removeAttribute( 'data-draggable' );
1989 }
1990 copy.classList.add( 'wpbc_bfb__drag-anywhere' );
1991
1992 // Insert right after original.
1993 field_el.parentNode.insertBefore( copy, field_el.nextSibling );
1994
1995 // Announce & hooks.
1996 this._emit_const( WPBC_BFB_Events.FIELD_ADD, {
1997 el : copy,
1998 data: WPBC_Form_Builder_Helper.get_all_data_attributes( copy )
1999 } );
2000 this.usage.update_palette_ui();
2001 this.trigger_field_drop_callback( copy, 'drop' );
2002 this.select_field( copy, { scrollIntoView: true } );
2003
2004 return copy;
2005 }
2006
2007 /**
2008 * Duplicate a section (with all nested fields/sections).
2009 * Ensures every contained field has unique id/name/html_id and a new uid; re-inits resizers & sortables.
2010 *
2011 * @private
2012 * @param {HTMLElement} section_el - .wpbc_bfb__section
2013 * @returns {HTMLElement|null}
2014 */
2015 _duplicate_section(section_el) {
2016 if ( !section_el || !section_el.classList?.contains( 'wpbc_bfb__section' ) ) return null;
2017
2018 // 1) Deep clone + scrub UI artifacts
2019 const clone = section_el.cloneNode( true );
2020 clone.querySelectorAll( '.wpbc_bfb__overlay-controls,.sortable-ghost,.sortable-chosen,.sortable-fallback' )
2021 .forEach( n => n.remove() );
2022
2023 // Clear flags copied while typing/dragging that can disable DnD.
2024 const clearDragFlags = (n) => {
2025 n.removeAttribute( 'data-draggable' );
2026 n.removeAttribute( 'draggable' );
2027 };
2028 clearDragFlags( clone );
2029 clone.querySelectorAll( '.wpbc_bfb__section, .wpbc_bfb__field' ).forEach( n => {
2030 clearDragFlags( n );
2031 if ( n.classList.contains( 'wpbc_bfb__field' ) ) {
2032 n.classList.add( 'wpbc_bfb__drag-anywhere' );
2033 }
2034 } );
2035
2036 // 1.5) USAGE-LIMIT PREFLIGHT (BLOCK if limits would be exceeded)
2037 const pre = this._preflight_usage_for_clone( clone, /* strategy */ 'block' ); // Strateg 'block' - show warning and do not allow to make duplication!
2038 // const pre = this._preflight_usage_for_clone( clone, /* strategy */ 'strip' ); // Strateg 'strip' - auto-trim elements, from section, ifthey out of limits.
2039 if ( ! pre.ok ) {
2040 const msg = pre.offenders.map( o => `- “${o.key}” — limit ${o.limit}; have ${o.used}, would add ${o.add}` ).join( '\n' );
2041 alert( `Cannot duplicate section; usage limits would be exceeded:\n${msg}` );
2042 this._announce?.( 'Section duplication blocked by limits.' );
2043 return null;
2044 }
2045
2046 // 2) Insert after source
2047 section_el.insertAdjacentElement( 'afterend', clone );
2048
2049 // 3) Make ids/names/uids unique via existing helpers
2050 this.pages_sections._retag_uids_in_subtree?.( clone );
2051 this.pages_sections._dedupe_subtree_strict?.( clone );
2052
2053 // 4) Overlays (outer + ALL nested sections/fields)
2054 this.add_overlay_toolbar?.( clone );
2055 clone.querySelectorAll( '.wpbc_bfb__section, .wpbc_bfb__field' ).forEach( el => this.add_overlay_toolbar?.( el ) );
2056
2057 // 5) Sortable wiring using helpers
2058 // - Sections sortable among siblings (outer + nested)
2059 this.pages_sections.init_section_sortable?.( clone );
2060 clone.querySelectorAll( '.wpbc_bfb__section' ).forEach( s => this.pages_sections.init_section_sortable?.( s ) );
2061
2062 // - Field drop zones inside columns/containers.
2063 this.pages_sections.init_all_nested_sortables?.( clone );
2064
2065 // - Defensive pass: ensure every column actually has a Sortable instance
2066 clone.querySelectorAll( '.wpbc_bfb__column' ).forEach( col => {
2067 if ( typeof Sortable !== 'undefined' && !Sortable.get?.( col ) ) {
2068 this.init_sortable?.( col );
2069 }
2070 } );
2071
2072 // 6) Resizers (outer + nested) and normalize bases.
2073 this._init_resizers_for_section?.( clone );
2074 clone.querySelectorAll( '.wpbc_bfb__section' ).forEach( s => this._init_resizers_for_section?.( s ) );
2075 clone.querySelectorAll( '.wpbc_bfb__row' ).forEach( row => {
2076 const eff = this.layout.compute_effective_bases_from_row( row, this.col_gap_percent );
2077 this.layout.apply_bases_to_row( row, eff.bases );
2078 } );
2079
2080 // 7) Rehydrate field renderers (so widgets bind).
2081 clone.querySelectorAll( '.wpbc_bfb__field' ).forEach( f => this.trigger_field_drop_callback?.( f, 'load' ) );
2082
2083 // 8) Housekeeping/UI.
2084 this.usage?.update_palette_ui?.();
2085 this.select_field?.( clone, { scrollIntoView: true } );
2086 this.bus?.emit?.( WPBC_BFB_Events.FIELD_ADD, {
2087 el : clone,
2088 id : clone.dataset.id,
2089 uid: clone.dataset.uid
2090 } );
2091
2092 return clone;
2093 }
2094
2095 /**
2096 * Create & return a unique-ish uid similar to build_field() semantics.
2097 *
2098 * @private
2099 * @param {string} prefix
2100 * @returns {string}
2101 */
2102 _generate_uid(prefix = 'f') {
2103 return `${prefix}-${++this._uid_counter}-${Date.now()}-${Math.random().toString( 36 ).slice( 2, 7 )}`;
2104 }
2105
2106 /**
2107 * Remove all resizers in a section row and recreate them with working handlers.
2108 * (Needed because event listeners do not copy on cloneNode(true).)
2109 *
2110 * @private
2111 * @param {HTMLElement} section_el - The cloned .wpbc_bfb__section
2112 * @returns {void}
2113 */
2114 _init_resizers_for_section(section_el) {
2115 const row = section_el?.querySelector( ':scope > .wpbc_bfb__row' );
2116 if ( !row ) return;
2117 this._rebuild_resizers_for_row( row );
2118 }
2119
2120 }
2121
2122
2123 // Bootstrap facility + auto-init on DOM ready.
2124 w.WPBC_BFB = w.WPBC_BFB || {};
2125
2126 w.WPBC_BFB.bootstrap = function bootstrap(options = {}) {
2127 let b = null;
2128 try {
2129 b = new WPBC_Form_Builder( options );
2130 } catch ( e ) {
2131 console.error( 'WPBC_BFB bootstrap failed:', e );
2132 return null;
2133 }
2134 window.wpbc_bfb = b;
2135 // Resolve API 'ready' if it exists already; otherwise the API will resolve itself when created.
2136 if ( window.wpbc_bfb_api && typeof window.wpbc_bfb_api._resolveReady === 'function' ) {
2137 window.wpbc_bfb_api._resolveReady( b );
2138 }
2139 return b;
2140 };
2141
2142 /**
2143 * == Public, stable API of Booking Form Builder (BFB).
2144 *
2145 * Consumers should prefer: wpbc_bfb_api.on(WPBC_BFB_Events.FIELD_ADD, handler)
2146 */
2147 w.wpbc_bfb_api = (function () {
2148 // 'ready' promise. Resolves once the builder instance exists.
2149 let _resolveReady;
2150 const ready = new Promise( r => {
2151 _resolveReady = r;
2152 } );
2153 // Eject/resolve after a timeout so callers aren’t stuck forever:.
2154 setTimeout( () => {
2155 _resolveReady( window.wpbc_bfb || null );
2156 }, 3000 );
2157
2158 // If builder already exists (e.g., bootstrap ran earlier), resolve immediately.
2159 if ( window.wpbc_bfb ) {
2160 _resolveReady( window.wpbc_bfb );
2161 }
2162
2163 return {
2164 ready,
2165 // internal hook used by bootstrap to resolve if API was created first.
2166 _resolveReady,
2167
2168 /** @returns {HTMLElement|null} */
2169 get_selection_el() {
2170 const b = window.wpbc_bfb;
2171 return b?.get_selected_field?.() ?? null;
2172 },
2173 /** @returns {string|null} */
2174 get_selection_uid() {
2175 const b = window.wpbc_bfb;
2176 const el = b?.get_selected_field?.();
2177 return el?.dataset?.uid ?? null;
2178 },
2179 clear() {
2180 window.wpbc_bfb?.select_field?.( null );
2181 },
2182 /**
2183 * @param {string} uid
2184 * @param {Object} [opts={}]
2185 * @returns {boolean}
2186 */
2187 select_by_uid(uid, opts = {}) {
2188 const b = window.wpbc_bfb;
2189
2190 const esc = WPBC_BFB_Sanitize.esc_attr_value_for_selector( uid );
2191 const el = b?.pages_container?.querySelector?.(
2192 `.wpbc_bfb__field[data-uid="${esc}"], .wpbc_bfb__section[data-uid="${esc}"]`
2193 );
2194
2195 if ( el ) {
2196 b.select_field( el, opts );
2197 }
2198 return !!el;
2199 },
2200 /** @returns {Array} */
2201 get_structure() {
2202 return window.wpbc_bfb?.get_structure?.() ?? [];
2203 },
2204 /** @param {Array} s */
2205 load_structure(s) {
2206 window.wpbc_bfb?.load_saved_structure?.( s );
2207 },
2208 /** @returns {HTMLElement|undefined} */
2209 add_page() {
2210 return window.wpbc_bfb?.add_page?.();
2211 },
2212 on(event_name, handler) {
2213 window.wpbc_bfb?.bus?.on?.( event_name, handler );
2214 },
2215 off(event_name, handler) {
2216 window.wpbc_bfb?.bus?.off?.( event_name, handler );
2217 },
2218 /**
2219 * Dispose the active builder instance.
2220 *
2221 * @returns {void}
2222 */
2223 destroy() {
2224 window.wpbc_bfb?.destroy?.();
2225 },
2226
2227 };
2228 })();
2229
2230 // Convenience helpers (idempotent)
2231 if ( window.wpbc_bfb_api ) {
2232
2233 // Sync: returns instance immediately or null
2234 window.wpbc_bfb_api.get_builder = window.wpbc_bfb_api.get_builder || function () {
2235 return window.wpbc_bfb || null;
2236 };
2237
2238 // Async: always waits for readiness
2239 window.wpbc_bfb_api.get_builder_async = window.wpbc_bfb_api.get_builder_async || function () {
2240 return window.wpbc_bfb_api.ready.then( function (b) { return b || null; } );
2241 };
2242
2243 // Optional: run callback when ready (no repeated .then everywhere)
2244 window.wpbc_bfb_api.with_builder = window.wpbc_bfb_api.with_builder || function (fn) {
2245 return window.wpbc_bfb_api.ready.then( function (b) {
2246 if ( b && typeof fn === 'function' ) { fn( b ); }
2247 return b || null;
2248 } );
2249 };
2250 }
2251
2252
2253 // Auto‑bootstrap on DOM ready.
2254 (function initBuilderWhenReady() {
2255 const start = () => {
2256 // Allow PHP to pass initial options to avoid settings flicker.
2257 // Example: window.wpbc_bfb_bootstrap_opts = { preview_mode: true, col_gap_percent: 3 };.
2258 const boot_opts = (window.wpbc_bfb_bootstrap_opts && typeof window.wpbc_bfb_bootstrap_opts === 'object') ? window.wpbc_bfb_bootstrap_opts : {};
2259 window.WPBC_BFB.bootstrap( boot_opts );
2260 };
2261 if ( document.readyState === 'loading' ) {
2262 document.addEventListener( 'DOMContentLoaded', start, { once: true } );
2263 } else {
2264 start();
2265 }
2266 })();
2267
2268 // One-time cleanup: ensure sections don’t have the field class. (old markup hygiene).
2269 document.querySelectorAll( '.wpbc_bfb__section.wpbc_bfb__field' ).forEach( (el) => el.classList.remove( 'wpbc_bfb__field' ) );
2270
2271
2272 /**
2273 * Empty-space clicks -> dispatch a single event; central listener does the clearing.
2274 * One central listener reacts to that event and does the clearing + inspector reset.
2275 */
2276 if ( window.jQuery ) { jQuery( function ( $ ) {
2277 // Elements where clicks should NOT clear selection.
2278 const KEEP_CLICK_SEL = [
2279 '.wpbc_bfb__field',
2280 '.wpbc_bfb__section',
2281 '.wpbc_bfb__overlay-controls',
2282 '.wpbc_bfb__layout_picker',
2283 '.wpbc_bfb__drag-handle',
2284 // Inspector / palette surfaces.
2285 '#wpbc_bfb__inspector', '.wpbc_bfb__inspector',
2286 '.wpbc_bfb__panel_field_types__ul', '.wpbc_bfb__palette',
2287 // Generic interactive.
2288 'input', 'textarea', 'select', 'button', 'label', 'a,[role=button],[contenteditable]',
2289 // Common popups/widgets.
2290 '.tippy-box', '.datepick', '.simplebar-scrollbar'
2291 ].join( ',' );
2292
2293 /**
2294 * Reset the inspector/palette empty state UI.
2295 *
2296 * @returns {void}
2297 */
2298 function resetInspectorUI() {
2299 const $all = $( '#wpbc_bfb__inspector, .wpbc_bfb__inspector, .wpbc_bfb__palette, .wpbc_bfb__options_panel' );
2300 if ( ! $all.length ) return;
2301 $all.removeClass( 'has-selection is-active' );
2302 $all.each( function () {
2303 const $pal = jQuery( this );
2304 $pal.find( '[data-for-uid],[data-for-field],[data-panel="field"],[role="tabpanel"]' ).attr( 'hidden', true ).addClass( 'is-hidden' );
2305 $pal.find( '[role="tab"]' ).attr( { 'aria-selected': 'false', 'tabindex': '-1' } ).removeClass( 'is-active' );
2306 $pal.find( '.wpbc_bfb__inspector-empty, .wpbc_bfb__empty_state, [data-empty-state="true"]' ).removeAttr( 'hidden' ).removeClass( 'is-hidden' );
2307 } );
2308 }
2309
2310 const root = document.querySelector( '.wpbc_settings_page_content' );
2311 if ( ! root ) {
2312 return;
2313 }
2314
2315 /**
2316 * Handle clear-selection requests from ESC/empty-space and sync with builder.
2317 *
2318 * @param {CustomEvent} evt - The event carrying optional `detail.source`.
2319 * @returns {void}
2320 */
2321 function handleClearSelection( evt ) {
2322 const src = evt?.detail?.source;
2323
2324 // If this is the builder telling us it already cleared selection,
2325 // just sync the surrounding UI and exit.
2326 if ( src === 'builder' ) {
2327 resetInspectorUI();
2328 return;
2329 }
2330
2331 // Otherwise it's a request to clear (ESC, empty space, etc.).
2332 if ( window.wpbc_bfb_api && typeof window.wpbc_bfb_api.clear === 'function' ) {
2333 window.wpbc_bfb_api.clear(); // This will emit the 'builder' notification next.
2334 } else {
2335 // Fallback if the API isn't available.
2336 jQuery( '.is-selected, .wpbc_bfb__field--active, .wpbc_bfb__section--active' )
2337 .removeClass( 'is-selected wpbc_bfb__field--active wpbc_bfb__section--active' );
2338 resetInspectorUI();
2339 }
2340 }
2341
2342 // Listen globally for clear-selection notifications.
2343 const EV = WPBC_BFB_Events || {};
2344 document.addEventListener( EV.CLEAR_SELECTION || 'wpbc:bfb:clear-selection', handleClearSelection );
2345
2346 // Capture clicks; only dispatch the event (no direct clearing here).
2347 root.addEventListener( 'click', function ( e ) {
2348 const $t = $( e.target );
2349
2350 // Ignore clicks inside interactive / builder controls.
2351 if ( $t.closest( KEEP_CLICK_SEL ).length ) {
2352 return;
2353 }
2354
2355 // Ignore mouseup after selecting text.
2356 if ( window.getSelection && String( window.getSelection() ).trim() !== '' ) {
2357 return;
2358 }
2359
2360 // Dispatch the single event; let the listener do the work.
2361 const evt = new CustomEvent( 'wpbc:bfb:clear-selection', {
2362 detail: { source: 'empty-space-click', originalEvent: e }
2363 } );
2364 document.dispatchEvent( evt );
2365 }, true );
2366 } ); } // end jQuery guard
2367
2368 })( window );
2369
2370 /**
2371 * Usage examples:
2372 *
2373 window.wpbc_bfb_api.with_builder(function (B) {
2374 B.set_preview_mode(enabled, { rebuild: true, reinit: true, source: 'settings-effects' });
2375 });
2376
2377 */
2378