"use strict";// --------------------------------------------------------------------------------------------------------------------- // == File /includes/page-form-builder/_out/core/bfb-core.js == | 2025-09-10 15:47 // --------------------------------------------------------------------------------------------------------------------- (function(w){'use strict';// Single global namespace (idempotent & load-order safe). const Core=w.WPBC_BFB_Core=w.WPBC_BFB_Core||{};const UI=Core.UI=Core.UI||{};/** * Core sanitize/escape/normalize helpers. * All methods use snake_case; camelCase aliases are provided for backwards compatibility. */Core.WPBC_BFB_Sanitize=class{/** * Escape text for safe use in CSS selectors. * @param {string} s - raw selector fragment * @returns {string} */static esc_css(s){return w.CSS&&w.CSS.escape?w.CSS.escape(String(s)):String(s).replace(/([^\w-])/g,'\\$1');}/** * Escape a value for attribute selectors, e.g. [data-id=""]. * @param {string} v * @returns {string} */static esc_attr_value_for_selector(v){return String(v).replace(/\\/g,'\\\\').replace(/"/g,'\\"').replace(/\n/g,'\\A ').replace(/\]/g,'\\]');}/** * Sanitize into a broadly compatible HTML id: letters, digits, - _ : . ; must start with a letter. * @param {string} v * @returns {string} */static sanitize_html_id(v){let s=(v==null?'':String(v)).trim();s=s.replace(/\s+/g,'-').replace(/[^A-Za-z0-9\-_\:.]/g,'-').replace(/-+/g,'-').replace(/^[-_.:]+|[-_.:]+$/g,'');if(!s)return'field';if(!/^[A-Za-z]/.test(s))s='f-'+s;return s;}/** * Sanitize into a safe HTML name token: letters, digits, _ - * Must start with a letter; no dots/brackets/spaces. * @param {string} v * @returns {string} */static sanitize_html_name(v){let s=(v==null?'':String(v)).trim();s=s.replace(/\s+/g,'_').replace(/[^A-Za-z0-9_-]/g,'_').replace(/_+/g,'_');if(!s){s='field';}if(!/^[A-Za-z]/.test(s)){s='f_'+s;}return s;}/** * Escape for HTML text/attributes (not URLs). * @param {any} v * @returns {string} */static escape_html(v){if(v==null){return'';}return String(v).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(//g,'>');}/** * Escape minimal set for attribute-safety without slugging. * Keeps original human text; escapes &, <, >, " and ' only. * @param {string} s * @returns {string} */static escape_value_for_attr(s){return String(s==null?'':s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');}/** * Sanitize a space-separated CSS class list. * @param {any} v * @returns {string} */static sanitize_css_classlist(v){if(v==null)return'';return String(v).replace(/[^\w\- ]+/g,' ').replace(/\s+/g,' ').trim();}// == NEW == /** * Turn an arbitrary value into a conservative "token" (underscores, hyphens allowed). * Useful for shortcode tokens, ids in plain text, etc. * @param {any} v * @returns {string} */static to_token(v){return String(v??'').trim().replace(/\s+/g,'_').replace(/[^A-Za-z0-9_\-]/g,'');}/** * Convert to kebab-case (letters, digits, hyphens). * @param {any} v * @returns {string} */static to_kebab(v){return String(v??'').trim().replace(/[_\s]+/g,'-').replace(/[^A-Za-z0-9-]/g,'').replace(/-+/g,'-').toLowerCase();}/** * Truthy normalization for form-like inputs: true, 'true', 1, '1', 'yes', 'on'. * @param {any} v * @returns {boolean} */static is_truthy(v){if(typeof v==='boolean')return v;const s=String(v??'').trim().toLowerCase();return s==='true'||s==='1'||s==='yes'||s==='on';}/** * Coerce to boolean with an optional default for empty values. * @param {any} v * @param {boolean} [def=false] * @returns {boolean} */static coerce_boolean(v,def=false){if(v==null||v==='')return def;return this.is_truthy(v);}/** * Parse a "percent-like" value ('33'|'33%'|33) with fallback. * @param {string|number|null|undefined} v * @param {number} fallback_value * @returns {number} */static parse_percent(v,fallback_value){if(v==null){return fallback_value;}const s=String(v).trim();const n=parseFloat(s.replace(/%/g,''));return Number.isFinite(n)?n:fallback_value;}/** * Clamp a number to the [min, max] range. * @param {number} n * @param {number} min * @param {number} max * @returns {number} */static clamp(n,min,max){return Math.max(min,Math.min(max,n));}/** * Escape a value for inclusion inside a quoted HTML attribute (double quotes). * Replaces newlines with spaces and double quotes with single quotes. * @param {any} v * @returns {string} */static escape_for_attr_quoted(v){if(v==null)return'';return String(v).replace(/\r?\n/g,' ').replace(/"/g,'\'');}/** * Escape for shortcode-like tokens where double quotes and newlines should be neutralized. * @param {any} v * @returns {string} */static escape_for_shortcode(v){return String(v??'').replace(/"/g,'\\"').replace(/\r?\n/g,' ');}/** * JSON.parse with fallback (no throw). * @param {string} s * @param {any} [fallback=null] * @returns {any} */static safe_json_parse(s,fallback=null){try{return JSON.parse(s);}catch(_){return fallback;}}/** * Stringify data-* attribute value safely (objects -> JSON, others -> String). * @param {any} v * @returns {string} */static stringify_data_value(v){if(typeof v==='object'&&v!==null){try{return JSON.stringify(v);}catch{console.error('WPBC: stringify_data_value');return'';}}return String(v);}// ------------------------------------------------------------------------------------------------------------- // Strict value guards for CSS lengths and hex colors (defense-in-depth). // ------------------------------------------------------------------------------------------------------------- /** * Sanitize a CSS length. Allows: px, %, rem, em (lower/upper). * Returns fallback if invalid. * @param {any} v * @param {string} [fallback='100%'] * @returns {string} */static sanitize_css_len(v,fallback='100%'){const s=String(v??'').trim();const m=s.match(/^(-?\d+(?:\.\d+)?)(px|%|rem|em)$/i);return m?m[0]:String(fallback);}/** * Sanitize a hex color. Allows #rgb or #rrggbb (case-insensitive). * Returns fallback if invalid. * @param {any} v * @param {string} [fallback='#e0e0e0'] * @returns {string} */static sanitize_hex_color(v,fallback='#e0e0e0'){const s=String(v??'').trim();return /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)?s:String(fallback);}};/** * WPBC ID / Name service. Generates, sanitizes, and ensures uniqueness for field ids/names/html_ids within the * canvas. */Core.WPBC_BFB_IdService=class{/** * Constructor. Set root container of the form pages. * * @param {HTMLElement} pages_container - Root container of the form pages. */constructor(pages_container){this.pages_container=pages_container;}/** * Ensure a unique **internal** field id (stored in data-id) within the canvas. * Starts from a desired id (already sanitized or not) and appends suffixes if needed. * * @param {string} baseId - Desired id. * @returns {string} Unique id. */ensure_unique_field_id(baseId,currentEl=null){const base=Core.WPBC_BFB_Sanitize.sanitize_html_id(baseId);let id=base||'field';const esc=v=>Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector(v);const escUid=v=>Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector(v);const notSelf=currentEl?.dataset?.uid?`:not([data-uid="${escUid(currentEl.dataset.uid)}"])`:'';while(this.pages_container?.querySelector(`.wpbc_bfb__panel--preview .wpbc_bfb__field${notSelf}[data-id="${esc(id)}"], .wpbc_bfb__panel--preview .wpbc_bfb__section${notSelf}[data-id="${esc(id)}"]`)){// Excludes self by data-uid . const found=this.pages_container.querySelector(`.wpbc_bfb__panel--preview .wpbc_bfb__field[data-id="${esc(id)}"], .wpbc_bfb__panel--preview .wpbc_bfb__section[data-id="${esc(id)}"]`);if(found&¤tEl&&found===currentEl){break;}id=`${base||'field'}-${Math.random().toString(36).slice(2,5)}`;}return id;}/** * Ensure a unique HTML name across the form. * * @param {string} base - Desired base name (un/sanitized). * @param {HTMLElement|null} currentEl - If provided, ignore conflicts with this element. * @returns {string} Unique name. */ensure_unique_field_name(base,currentEl=null){let name=base||'field';const esc=v=>Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector(v);const escUid=v=>Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector(v);// Exclude the current field *and any DOM mirrors of it* (same data-uid) const uid=currentEl?.dataset?.uid;const notSelf=uid?`:not([data-uid="${escUid(uid)}"])`:'';while(true){const selector=`.wpbc_bfb__panel--preview .wpbc_bfb__field${notSelf}[data-name="${esc(name)}"]`;const clashes=this.pages_container?.querySelectorAll(selector)||[];if(clashes.length===0)break;// nobody else uses this name const m=name.match(/-(\d+)$/);name=m?name.replace(/-\d+$/,'-'+(Number(m[1])+1)):`${base}-2`;}return name;}/** * Set field's INTERNAL id (data-id) on an element. Ensures uniqueness and optionally asks caller to refresh * preview. * * @param {HTMLElement} field_el - Field element in the canvas. * @param {string} newIdRaw - Desired id (un/sanitized). * @param {boolean} [renderPreview=false] - Caller can decide to re-render preview. * @returns {string} Applied unique id. */set_field_id(field_el,newIdRaw,renderPreview=false){const desired=Core.WPBC_BFB_Sanitize.sanitize_html_id(newIdRaw);const unique=this.ensure_unique_field_id(desired,field_el);field_el.setAttribute('data-id',unique);if(renderPreview){// Caller decides if / when to render. }return unique;}/** * Set field's REQUIRED HTML name (data-name). Ensures sanitized + unique per form. * Falls back to sanitized internal id if user provides empty value. * * @param {HTMLElement} field_el - Field element in the canvas. * @param {string} newNameRaw - Desired name (un/sanitized). * @param {boolean} [renderPreview=false] - Caller can decide to re-render preview. * @returns {string} Applied unique name. */set_field_name(field_el,newNameRaw,renderPreview=false){const raw=(newNameRaw==null?'':String(newNameRaw)).trim();const base=raw?Core.WPBC_BFB_Sanitize.sanitize_html_name(raw):Core.WPBC_BFB_Sanitize.sanitize_html_name(field_el.getAttribute('data-id')||'field');const unique=this.ensure_unique_field_name(base,field_el);field_el.setAttribute('data-name',unique);if(renderPreview){// Caller decides if / when to render. }return unique;}/** * Set field's OPTIONAL public HTML id (data-html_id). Empty value removes the attribute. * Ensures sanitization + uniqueness among other declared HTML ids. * * @param {HTMLElement} field_el - Field element in the canvas. * @param {string} newHtmlIdRaw - Desired html_id (optional). * @param {boolean} [renderPreview=false] - Caller can decide to re-render preview. * @returns {string} The applied html_id or empty string if removed. */set_field_html_id(field_el,newHtmlIdRaw,renderPreview=false){const raw=(newHtmlIdRaw==null?'':String(newHtmlIdRaw)).trim();if(raw===''){field_el.removeAttribute('data-html_id');if(renderPreview){// Caller decides if / when to render. }return'';}const desired=Core.WPBC_BFB_Sanitize.sanitize_html_id(raw);let htmlId=desired;const esc=v=>Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector(v);const escUid=v=>Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector(v);while(true){const uid=field_el?.dataset?.uid;const notSelf=uid?`:not([data-uid="${escUid(uid)}"])`:'';const clashInCanvas=this.pages_container?.querySelector(`.wpbc_bfb__panel--preview .wpbc_bfb__field${notSelf}[data-html_id="${esc(htmlId)}"],`+`.wpbc_bfb__panel--preview .wpbc_bfb__section${notSelf}[data-html_id="${esc(htmlId)}"]`);const domClash=document.getElementById(htmlId);// Allow when the only "clash" is inside this same field (e.g., the input you just rendered) const domClashIsSelf=domClash===field_el||domClash&&field_el.contains(domClash);if(!clashInCanvas&&(!domClash||domClashIsSelf)){break;}const m=htmlId.match(/-(\d+)$/);htmlId=m?htmlId.replace(/-\d+$/,'-'+(Number(m[1])+1)):`${desired}-2`;}field_el.setAttribute('data-html_id',htmlId);if(renderPreview){// Caller decides if / when to render. }return htmlId;}};/** * WPBC Layout service. Encapsulates column width math with gap handling, presets, and utilities. */Core.WPBC_BFB_LayoutService=class{/** * Constructor. Set options with gap between columns (%). * * @param {{ col_gap_percent?: number }} [opts] - Options with gap between columns (%). */constructor(opts={}){this.col_gap_percent=Number.isFinite(+opts.col_gap_percent)?+opts.col_gap_percent:3;}/** * Compute normalized flex-basis values for a row, respecting column gaps. * Returns bases that sum to available = 100 - (n-1)*gap. * * @param {HTMLElement} row_el - Row element containing .wpbc_bfb__column children. * @param {number} [gap_percent=this.col_gap_percent] - Gap percent between columns. * @returns {{available:number,bases:number[]}} Available space and basis values. */compute_effective_bases_from_row(row_el,gap_percent=this.col_gap_percent){const cols=Array.from(row_el?.querySelectorAll(':scope > .wpbc_bfb__column')||[]);const n=cols.length||1;const raw=cols.map(col=>{const w=col.style.flexBasis||'';const p=Core.WPBC_BFB_Sanitize.parse_percent(w,NaN);return Number.isFinite(p)?p:100/n;});const sum_raw=raw.reduce((a,b)=>a+b,0)||100;const gp=Number.isFinite(+gap_percent)?+gap_percent:3;const total_gaps=Math.max(0,n-1)*gp;const available=Math.max(0,100-total_gaps);const scale=available/sum_raw;return{available,bases:raw.map(p=>Math.max(0,p*scale))};}/** * Apply computed bases to the row's columns (sets flex-basis %). * * @param {HTMLElement} row_el - Row element. * @param {number[]} bases - Array of basis values (percent of full 100). * @returns {void} */apply_bases_to_row(row_el,bases){const cols=Array.from(row_el?.querySelectorAll(':scope > .wpbc_bfb__column')||[]);cols.forEach((col,i)=>{const p=bases[i]??0;col.style.flexBasis=`${p}%`;});}/** * Distribute columns evenly, respecting gap. * * @param {HTMLElement} row_el - Row element. * @param {number} [gap_percent=this.col_gap_percent] - Gap percent. * @returns {void} */set_equal_bases(row_el,gap_percent=this.col_gap_percent){const cols=Array.from(row_el?.querySelectorAll(':scope > .wpbc_bfb__column')||[]);const n=cols.length||1;const gp=Number.isFinite(+gap_percent)?+gap_percent:3;const total_gaps=Math.max(0,n-1)*gp;const available=Math.max(0,100-total_gaps);const each=available/n;this.apply_bases_to_row(row_el,Array(n).fill(each));}/** * Apply a preset of relative weights to a row/section. * * @param {HTMLElement} sectionOrRow - .wpbc_bfb__section or its child .wpbc_bfb__row. * @param {number[]} weights - Relative weights (e.g., [1,3,1]). * @param {number} [gap_percent=this.col_gap_percent] - Gap percent. * @returns {void} */apply_layout_preset(sectionOrRow,weights,gap_percent=this.col_gap_percent){const row=sectionOrRow?.classList?.contains('wpbc_bfb__row')?sectionOrRow:sectionOrRow?.querySelector(':scope > .wpbc_bfb__row');if(!row){return;}const cols=Array.from(row.querySelectorAll(':scope > .wpbc_bfb__column')||[]);const n=cols.length||1;if(!Array.isArray(weights)||weights.length!==n){this.set_equal_bases(row,gap_percent);return;}const sum=weights.reduce((a,b)=>a+Math.max(0,Number(b)||0),0)||1;const gp=Number.isFinite(+gap_percent)?+gap_percent:3;const available=Math.max(0,100-Math.max(0,n-1)*gp);const bases=weights.map(w=>Math.max(0,(Number(w)||0)/sum*available));this.apply_bases_to_row(row,bases);}/** * Build preset weight lists for a given column count. * * @param {number} n - Column count. * @returns {number[][]} List of weight arrays. */build_presets_for_columns(n){switch(n){case 1:return[[1]];case 2:return[[1,2],[2,1],[1,3],[3,1]];case 3:return[[1,3,1],[1,2,1],[2,1,1],[1,1,2]];case 4:return[[1,2,2,1],[2,1,1,1],[1,1,1,2]];default:return[Array(n).fill(1)];}}/** * Format a human-readable label like "50%/25%/25%" from weights. * * @param {number[]} weights - Weight list. * @returns {string} Label string. */format_preset_label(weights){const sum=weights.reduce((a,b)=>a+(Number(b)||0),0)||1;return weights.map(w=>Math.round((Number(w)||0)/sum*100)).join('%/')+'%';}/** * Parse comma/space separated weights into numbers. * * @param {string} input - User input like "20,60,20". * @returns {number[]} Parsed weights. */parse_weights(input){if(!input){return[];}return String(input).replace(/[^\d,.\s]/g,'').split(/[\s,]+/).map(s=>parseFloat(s)).filter(n=>Number.isFinite(n)&&n>=0);}};/** * WPBC Usage Limit service. * Counts field usage by key, compares to palette limits, and updates palette UI. */Core.WPBC_BFB_UsageLimitService=class{/** * Constructor. Set pages_container and palette_ul. * * @param {HTMLElement} pages_container - Canvas root that holds placed fields. * @param {HTMLElement[]|null} palette_uls?: Palettes UL with .wpbc_bfb__field items (may be null). */constructor(pages_container,palette_uls){this.pages_container=pages_container;// Normalize to an array; we’ll still be robust if none provided. this.palette_uls=Array.isArray(palette_uls)?palette_uls:palette_uls?[palette_uls]:[];}/** * Parse usage limit from raw dataset value. Missing/invalid -> Infinity. * * @param {string|number|null|undefined} raw - Raw attribute value. * @returns {number} Limit number or Infinity. */static parse_usage_limit(raw){if(raw==null){return Infinity;}const n=parseInt(raw,10);return Number.isFinite(n)?n:Infinity;}/** * Count how many instances exist per usage_key in the canvas. * * @returns {Record} Map of usage_key -> count. */count_usage_by_key(){const used={};const all=this.pages_container?.querySelectorAll('.wpbc_bfb__panel--preview .wpbc_bfb__field:not(.is-invalid)')||[];all.forEach(el=>{const key=el.dataset.usage_key||el.dataset.type||el.dataset.id;if(!key){return;}used[key]=(used[key]||0)+1;});return used;}/** * Return palette limit for a given usage key (id of the palette item). * * @param {string} key - Usage key. * @returns {number} Limit value or Infinity. */get_limit_for_key(key){if(!key){return Infinity;}// Query across all palettes present now (stored + any newly added in DOM). const roots=this.palette_uls?.length?this.palette_uls:document.querySelectorAll('.wpbc_bfb__panel_field_types__ul');const allPaletteFields=Array.from(roots).flatMap(r=>Array.from(r.querySelectorAll('.wpbc_bfb__field')));let limit=Infinity;allPaletteFields.forEach(el=>{const usage_key=el.dataset.usage_key||el.dataset.id;if(el.dataset.id===key||usage_key===key){const n=Core.WPBC_BFB_UsageLimitService.parse_usage_limit(el.dataset.usagenumber);// Choose the smallest finite limit (safest if palettes disagree). if(n{pal.querySelectorAll('.wpbc_bfb__field').forEach(panel_field=>{const paletteId=panel_field.dataset.id;const usageKey=panel_field.dataset.usage_key||paletteId;const raw_limit=panel_field.dataset.usagenumber;const perElLimit=Core.WPBC_BFB_UsageLimitService.parse_usage_limit(raw_limit);// Effective limit across all palettes is the global limit for this key. const globalLimit=this.get_limit_for_key(usageKey);const limit=Number.isFinite(globalLimit)?globalLimit:perElLimit;// prefer global min const current=usage[usageKey]||0;const disable=Number.isFinite(limit)&¤t>=limit;panel_field.style.pointerEvents=disable?'none':'';panel_field.style.opacity=disable?'0.4':'';panel_field.setAttribute('aria-disabled',disable?'true':'false');if(disable){panel_field.setAttribute('tabindex','-1');}else{panel_field.removeAttribute('tabindex');}});});}/** * Return how many valid instances with this usage key exist in the canvas. * * @param {string} key - Usage key of a palette item. * @returns {number} Count of existing non-invalid instances. */count_for_key(key){if(!key){return 0;}return(this.pages_container?.querySelectorAll(`.wpbc_bfb__panel--preview .wpbc_bfb__field[data-usage_key="${Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector(key)}"]:not(.is-invalid), .wpbc_bfb__panel--preview .wpbc_bfb__field[data-type="${Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector(key)}"]:not(.is-invalid)`)||[]).length;}/** * Alias for limit lookup (readability). * * @param {string} key - Usage key of a palette item. * @returns {number} Limit value or Infinity. */limit_for_key(key){return this.get_limit_for_key(key);}/** * Remaining slots for this key (Infinity if unlimited). * * @param {string} key - Usage key of a palette item. * @returns {number} Remaining count (>= 0) or Infinity. */remaining_for_key(key){const limit=this.limit_for_key(key);if(limit===Infinity){return Infinity;}const used=this.count_for_key(key);return Math.max(0,limit-used);}/** * True if you can add `delta` more items for this key. * * @param {string} key - Usage key of a palette item. * @param {number} [delta=1] - How many items you intend to add. * @returns {boolean} Whether adding is allowed. */can_add(key,delta=1){const rem=this.remaining_for_key(key);return rem===Infinity?true:rem>=delta;}/** * UI-facing gate: alert when exceeded. Returns boolean allowed/blocked. * * @param {string} key - Usage key of a palette item. * @param {{label?: string, delta?: number}} [opts={}] - Optional UI info. * @returns {boolean} True if allowed, false if blocked. */gate_or_alert(key,{label=key,delta=1}={}){if(this.can_add(key,delta)){return true;}const limit=this.limit_for_key(key);alert(`Only ${limit} instance${limit>1?'s':''} of "${label}" allowed.`);return false;}/** * Backward-compatible alias used elsewhere in the codebase. - Check whether another instance with the given * usage key can be added. * * @param {string} key - Usage key of a palette item. * @returns {boolean} Whether adding one more is allowed. */is_usage_ok(key){return this.can_add(key,1);}};/** * Constant event names for the builder. */Core.WPBC_BFB_Events=Object.freeze({SELECT:'wpbc:bfb:select',CLEAR_SELECTION:'wpbc:bfb:clear-selection',FIELD_ADD:'wpbc:bfb:field:add',FIELD_REMOVE:'wpbc:bfb:field:remove',STRUCTURE_CHANGE:'wpbc:bfb:structure:change',STRUCTURE_LOADED:'wpbc:bfb:structure:loaded'});/** * Lightweight event bus that emits to both the pages container and document. */Core.WPBC_BFB_EventBus=class{/** * @param {HTMLElement} scope_el - Element to dispatch bubbled events from. */constructor(scope_el){this.scope_el=scope_el;}/** * Emit a DOM CustomEvent with payload. * * @param {string} type - Event type (use Core.WPBC_BFB_Events. when possible). * @param {Object} [detail={}] - Arbitrary serializable payload. * @returns {void} */emit(type,detail={}){if(!this.scope_el){return;}this.scope_el.dispatchEvent(new CustomEvent(type,{detail:{...detail},bubbles:true}));}/** * Subscribe to an event on document. * * @param {string} type - Event type. * @param {(ev:CustomEvent)=>void} handler - Handler function. * @returns {void} */on(type,handler){document.addEventListener(type,handler);}/** * Unsubscribe from an event on document. * * @param {string} type - Event type. * @param {(ev:CustomEvent)=>void} handler - Handler function. * @returns {void} */off(type,handler){document.removeEventListener(type,handler);}};/** * SortableJS manager: single point for consistent DnD config. */Core.WPBC_BFB_SortableManager=class{/** * @param {WPBC_Form_Builder} builder - The active builder instance. * @param {{ groupName?: string, animation?: number, ghostClass?: string, chosenClass?: string, dragClass?: * string }} [opts={}] - Visual/behavior options. */constructor(builder,opts={}){this.builder=builder;const gid=this.builder?.instance_id||Math.random().toString(36).slice(2,8);this.opts={// groupName : 'form', groupName:`form-${gid}`,animation:150,ghostClass:'wpbc_bfb__drag-ghost',chosenClass:'wpbc_bfb__highlight',dragClass:'wpbc_bfb__drag-active',...opts};/** @type {Set} */this._containers=new Set();/** * Guard against lost mouseup / pointerup events. * * @type {boolean} */this._drag_fail_safe_bound=false;this._bind_drag_fail_safe();}/** * Cleanup drag UI state. * * This is a defensive cleanup for cases when Chrome or a browser extension * loses the final mouseup / pointerup event during fallback dragging. * * @returns {void} */_cleanup_drag_ui(){this._dragState=null;this._toggle_dnd_root_flags(false);this.builder?._remove_dragging_class?.();// Remove only fallback mirrors. Do not touch real dragged elements. document.querySelectorAll('.sortable-fallback[data-drag-role], .wpbc_bfb__simple_list_fallback').forEach(el=>{if(el.parentNode){el.parentNode.removeChild(el);}});}/** * Bind global fail-safe listeners for drag cleanup. * * @returns {void} */_bind_drag_fail_safe(){if(this._drag_fail_safe_bound){return;}this._drag_fail_safe_bound=true;const finish_drag=()=>{window.requestAnimationFrame(()=>{this._cleanup_drag_ui();});};['mouseup','pointerup','touchend','dragend'].forEach(evt_name=>{document.addEventListener(evt_name,finish_drag,true);});window.addEventListener('blur',finish_drag,true);document.addEventListener('visibilitychange',()=>{if(document.hidden){finish_drag();}},true);}/** * Tag the drag mirror (element under cursor) with role: 'palette' | 'canvas'. * Works with Sortable's fallback mirror (.sortable-fallback / .sortable-drag) and with your dragClass * (.wpbc_bfb__drag-active). */_tag_drag_mirror(evt){const fromPalette=this.builder?.palette_uls?.includes?.(evt.from);const role=fromPalette?'palette':'canvas';// Wait a tick so the mirror exists. - The window.requestAnimationFrame() method tells the browser you wish to perform an animation. requestAnimationFrame(()=>{const mirror=document.querySelector('.sortable-fallback, .sortable-drag, .'+this.opts.dragClass);if(mirror){mirror.setAttribute('data-drag-role',role);}});}_toggle_dnd_root_flags(active,from_palette=false){// set to root element of an HTML document, which is the . const root=document.documentElement;if(active){root.classList.add('wpbc_bfb__dnd-active');if(from_palette){root.classList.add('wpbc_bfb__drag-from-palette');}}else{root.classList.remove('wpbc_bfb__dnd-active','wpbc_bfb__drag-from-palette');}}/** * Ensure a simple vertical sortable list. * * This configuration is intended for inspector/sidebar lists such as: * - dropdown choices * - radio options * - checkbox options * * It is intentionally much simpler than the canvas DnD config and does not * use the column edge-fence / sticky-target logic. * * @param {HTMLElement} container - Sortable list container. * @param {{ handle_selector?: string, draggable_selector?: string, onUpdate?: Function }} [handlers={}] - * Optional handlers/selectors. * @returns {void} */ensure_simple_list(container,handlers={}){if(!container||typeof Sortable==='undefined'){return;}if(Sortable.get?.(container)){return;}const common={animation:this.opts.animation,ghostClass:this.opts.ghostClass,chosenClass:this.opts.chosenClass,dragClass:this.opts.dragClass,forceFallback:true,// For a single scrollable sidebar list this is usually more stable. fallbackOnBody:false,fallbackTolerance:8,removeCloneOnHide:true,onStart:()=>{this.builder?._add_dragging_class?.();this._toggle_dnd_root_flags(true,false);},onEnd:()=>{setTimeout(()=>{this.builder?._remove_dragging_class?.();},50);this._toggle_dnd_root_flags(false);this._dragState=null;this._cleanup_drag_ui();}};Sortable.create(container,{...common,group:{name:this.opts.groupName,pull:false,put:false},sort:true,direction:'vertical',handle:handlers.handle_selector||'.wpbc_bfb__drag-handle',draggable:handlers.draggable_selector||'.wpbc_bfb__sortable-row',fallbackClass:'wpbc_bfb__simple_list_fallback',filter:['input','textarea','select','button','a','.wpbc_bfb__no-drag-zone','.wpbc_bfb__no-drag-zone *'].join(','),preventOnFilter:false,invertSwap:false,swapThreshold:0.30,invertedSwapThreshold:0.60,emptyInsertThreshold:8,dragoverBubble:false,scroll:true,scrollSensitivity:60,scrollSpeed:14,onUpdate:handlers.onUpdate||function(){}});this._containers.add(container);}/** * Ensure Sortable is attached to a container with role 'palette' or 'canvas'. * * -- Handle selectors: handle: '.section-drag-handle, .wpbc_bfb__drag-handle, .wpbc_bfb__drag-anywhere, * [data-draggable="true"]' * -- Draggable gate: draggable: '.wpbc_bfb__field:not([data-draggable="false"]), .wpbc_bfb__section' * -- Filter (overlay-safe): ignore everything in overlay except the handle - * '.wpbc_bfb__overlay-controls * *:not(.wpbc_bfb__drag-handle):not(.section-drag-handle):not(.wpbc_icn_drag_indicator)' * -- No-drag wrapper: use .wpbc_bfb__no-drag-zone inside renderers for inputs/widgets. * -- Focus guard (optional): flip [data-draggable] on focusin/focusout to prevent accidental drags while * typing. * * @param {HTMLElement} container - The element to enhance with Sortable. * @param {'palette'|'canvas'} role - Behavior profile to apply. * @param {{ onAdd?: Function }} [handlers={}] - Optional handlers. * @returns {void} */ensure(container,role,handlers={}){if(!container||typeof Sortable==='undefined'){return;}if(Sortable.get?.(container)){return;}const sortable_kind=handlers.sortable_kind||'';if(sortable_kind==='simple_list'){this.ensure_simple_list(container,handlers);return;}const common={animation:this.opts.animation,ghostClass:this.opts.ghostClass,chosenClass:this.opts.chosenClass,dragClass:this.opts.dragClass,// == Element under the cursor == Ensure we drag a real DOM mirror you can style via CSS (cross-browser). forceFallback:true,fallbackOnBody:true,fallbackTolerance:8,removeCloneOnHide:true,// Add body/html flags so you can style differently when dragging from palette. onStart:evt=>{this.builder?._add_dragging_class?.();const fromPalette=this.builder?.palette_uls?.includes?.(evt.from);this._toggle_dnd_root_flags(true,fromPalette);// set to root HTML document: html.wpbc_bfb__dnd-active.wpbc_bfb__drag-from-palette . this._tag_drag_mirror(evt);// Add 'data-drag-role' attribute to element under cursor. },onEnd:()=>{setTimeout(()=>{this.builder._remove_dragging_class();},50);this._toggle_dnd_root_flags(false);}};if(role==='palette'){Sortable.create(container,{...common,group:{name:this.opts.groupName,pull:'clone',put:false},sort:false});this._containers.add(container);return;}// role === 'canvas'. Sortable.create(container,{...common,group:{name:this.opts.groupName,pull:true,put:(to,from,draggedEl)=>{return draggedEl.classList.contains('wpbc_bfb__field')||draggedEl.classList.contains('wpbc_bfb__section');}},// ---------- DnD Handlers -------------- // Grab anywhere on fields that opt-in with the class or attribute. - Sections still require their dedicated handle. handle:'.section-drag-handle, .wpbc_bfb__drag-handle, .wpbc_bfb__drag-anywhere, [data-draggable="true"]',draggable:'.wpbc_bfb__field:not([data-draggable="false"]), .wpbc_bfb__section',// Per-field opt-out with [data-draggable="false"] (e.g., while editing). // ---------- Filters - No DnD ---------- // Declarative “no-drag zones”: anything inside these wrappers won’t start a drag. filter:['.wpbc_bfb__no-drag-zone','.wpbc_bfb__no-drag-zone *','.wpbc_bfb__column-resizer',// Ignore the resizer rails during DnD (prevents edge “snap”). // In the overlay toolbar, block everything EXCEPT the drag handle (and its icon). '.wpbc_bfb__overlay-controls *:not(.wpbc_bfb__drag-handle):not(.section-drag-handle):not(.wpbc_icn_drag_indicator)'].join(','),preventOnFilter:false,// ---------- anti-jitter tuning ---------- direction:'vertical',// columns are vertical lists. invertSwap:true,// use swap on inverted overlap. swapThreshold:0.65,// be less eager to swap. invertedSwapThreshold:0.85,// require deeper overlap when inverted. emptyInsertThreshold:24,// don’t jump into empty containers too early. dragoverBubble:false,// keep dragover local. scroll:true,scrollSensitivity:40,scrollSpeed:10,/** * Enter/leave hysteresis for cross-column moves. Only allow dropping into `to` when the pointer is * well inside it. */onMove:(evt,originalEvent)=>{const{to,from}=evt;if(!to||!from){return true;}const in_preview_canvas=!!to.closest('.wpbc_bfb__panel--preview');if(!in_preview_canvas){return true;}// Only gate columns (not page containers), and only for cross-column moves in the same row const isColumn=to.classList?.contains('wpbc_bfb__column');if(!isColumn)return true;const fromRow=from.closest('.wpbc_bfb__row');const toRow=to.closest('.wpbc_bfb__row');if(fromRow&&toRow&&fromRow!==toRow)return true;const rect=to.getBoundingClientRect();const evtX=originalEvent.touches?.[0]?.clientX??originalEvent.clientX;const evtY=originalEvent.touches?.[0]?.clientY??originalEvent.clientY;// --- Edge fence (like you had), but clamped for tiny columns const paddingX=Core.WPBC_BFB_Sanitize.clamp(rect.width*0.20,12,36);const paddingY=Core.WPBC_BFB_Sanitize.clamp(rect.height*0.10,6,16);// Looser Y if the column is visually tiny/empty const isVisuallyEmpty=to.childElementCount===0||rect.height<64;const innerTop=rect.top+(isVisuallyEmpty?4:paddingY);const innerBottom=rect.bottom-(isVisuallyEmpty?4:paddingY);const innerLeft=rect.left+paddingX;const innerRight=rect.right-paddingX;const insideX=evtX>innerLeft&&evtXinnerTop&&evtYrect.left+commitX&&evtXrect.top+commitY&&evtY{this.builder?._add_dragging_class?.();// Match the flags we set in common so CSS stays consistent on canvas drags too. const fromPalette=this.builder?.palette_uls?.includes?.(evt.from);this._toggle_dnd_root_flags(true,fromPalette);// set to root HTML document: html.wpbc_bfb__dnd-active.wpbc_bfb__drag-from-palette . this._tag_drag_mirror(evt);// Tag the mirror under cursor. this._dragState={stickyTo:null,lastSwitchTs:0};// per-drag state. },onEnd:()=>{setTimeout(()=>{this.builder._remove_dragging_class();},50);this._toggle_dnd_root_flags(false);// set to root HTML document without these classes: html.wpbc_bfb__dnd-active.wpbc_bfb__drag-from-palette . this._dragState=null;},// ---------------------------------------- // onAdd: handlers.onAdd || this.builder.handle_on_add.bind( this.builder ) onAdd:evt=>{if(this._on_add_section(evt)){return;}// Fallback: original handler for normal fields. (handlers.onAdd||this.builder.handle_on_add.bind(this.builder))(evt);},onUpdate:()=>{this.builder.bus?.emit?.(Core.WPBC_BFB_Events.STRUCTURE_CHANGE,{reason:'sort-update'});}});this._containers.add(container);}/** * Handle adding/moving sections via Sortable onAdd. * Returns true if handled (i.e., it was a section), false to let the default field handler run. * * - Palette -> canvas: remove the placeholder clone and build a fresh section via add_section() * - Canvas -> canvas: keep the moved DOM (and its children), just re-wire overlays/sortables/metadata * * @param {Sortable.SortableEvent} evt * @returns {boolean} */_on_add_section(evt){const item=evt.item;if(!item){return false;}// Identify sections both from palette items (li clones) and real canvas nodes. const data=Core.WPBC_Form_Builder_Helper.get_all_data_attributes(item);const isSection=item.classList.contains('wpbc_bfb__section')||(data?.type||item.dataset?.type)==='section';if(!isSection){return false;}const fromPalette=this.builder?.palette_uls?.includes?.(evt.from)===true;if(!fromPalette){// Canvas -> canvas move: DO NOT rebuild/remove; preserve children. this.builder.add_overlay_toolbar?.(item);// ensure overlay exists this.builder.pages_sections?.init_all_nested_sortables?.(item);// ensure inner sortables // Ensure metadata present/updated item.dataset.type='section';const cols=item.querySelectorAll(':scope > .wpbc_bfb__row > .wpbc_bfb__column').length||1;item.dataset.columns=String(cols);// Select & notify subscribers (layout/min guards, etc.) this.builder.select_field?.(item);this.builder.bus?.emit?.(Core.WPBC_BFB_Events.STRUCTURE_CHANGE,{el:item,reason:'section-move'});this.builder.usage?.update_palette_ui?.();return true;// handled. }// Palette -> canvas: build a brand-new section using the same path as the dropdown/menu const to=evt.to?.closest?.('.wpbc_bfb__column, .wpbc_bfb__form_preview_section_container')||evt.to;const cols=parseInt(data?.columns||item.dataset.columns||1,10)||1;// Remove the palette clone placeholder. item.parentNode&&item.parentNode.removeChild(item);// Create the real section. this.builder.pages_sections.add_section(to,cols);// Insert at the precise drop index. const section=to.lastElementChild;// add_section appends to end. if(evt.newIndex!=null&&evt.newIndex{const inst=Sortable.get?.(el);if(inst){inst.destroy();}});this._containers.clear();}};/** * Small DOM contract and renderer helper * * @type {Readonly<{ * SELECTORS: {pagePanel: string, field: string, validField: string, section: string, column: * string, row: string, overlay: string}, CLASSES: {selected: string}, ATTR: {id: string, name: string, htmlId: * string, usageKey: string, uid: string}} * >} */Core.WPBC_BFB_DOM=Object.freeze({SELECTORS:{pagePanel:'.wpbc_bfb__panel--preview',field:'.wpbc_bfb__field',validField:'.wpbc_bfb__field:not(.is-invalid)',section:'.wpbc_bfb__section',column:'.wpbc_bfb__column',row:'.wpbc_bfb__row',overlay:'.wpbc_bfb__overlay-controls'},CLASSES:{selected:'is-selected'},ATTR:{id:'data-id',name:'data-name',htmlId:'data-html_id',usageKey:'data-usage_key',uid:'data-uid'}});Core.WPBC_Form_Builder_Helper=class{/** * Create an HTML element. * * @param {string} tag - HTML tag name. * @param {string} [class_name=''] - Optional CSS class name. * @param {string} [inner_html=''] - Optional innerHTML. * @returns {HTMLElement} Created element. */static create_element(tag,class_name='',inner_html=''){const el=document.createElement(tag);if(class_name){el.className=class_name;}if(inner_html){el.innerHTML=inner_html;}return el;}/** * Set multiple `data-*` attributes on a given element. * * @param {HTMLElement} el - Target element. * @param {Object} data_obj - Key-value pairs for data attributes. * @returns {void} */static set_data_attributes(el,data_obj){Object.entries(data_obj).forEach(([key,val])=>{// Previously: 2025-09-01 17:09: // const value = (typeof val === 'object') ? JSON.stringify( val ) : val; //New: let value;if(typeof val==='object'&&val!==null){try{value=JSON.stringify(val);}catch{value='';}}else{value=val;}el.setAttribute('data-'+key,value);});}/** * Get all `data-*` attributes from an element and parse JSON where possible. * * @param {HTMLElement} el - Element to extract data from. * @returns {Object} Parsed key-value map of data attributes. */static get_all_data_attributes(el){const data={};if(!el||!el.attributes){return data;}Array.from(el.attributes).forEach(attr=>{if(attr.name.startsWith('data-')){const key=attr.name.replace(/^data-/,'');try{data[key]=JSON.parse(attr.value);}catch(e){data[key]=attr.value;}}});// Only default the label if it's truly absent (undefined/null), not when it's an empty string. const hasExplicitLabel=Object.prototype.hasOwnProperty.call(data,'label');if(!hasExplicitLabel&&data.id){data.label=data.id.charAt(0).toUpperCase()+data.id.slice(1);}return data;}/** * Render a simple label + type preview (used for unknown or fallback fields). * * @param {Object} field_data - Field data object. * @returns {string} HTML content. */static render_field_inner_html(field_data){// Make the fallback preview respect an empty label. const hasLabel=Object.prototype.hasOwnProperty.call(field_data,'label');const label=hasLabel?String(field_data.label):String(field_data.id||'(no label)');const type=String(field_data.type||'unknown');const is_required=field_data.required===true||field_data.required==='true'||field_data.required===1||field_data.required==='1';const wrapper=document.createElement('div');const spanLabel=document.createElement('span');spanLabel.className='wpbc_bfb__field-label';spanLabel.textContent=label+(is_required?' *':'');wrapper.appendChild(spanLabel);const spanType=document.createElement('span');spanType.className='wpbc_bfb__field-type';spanType.textContent=type;wrapper.appendChild(spanType);return wrapper.innerHTML;}/** * Debounce a function. * * @param {Function} fn - Function to debounce. * @param {number} wait - Delay in ms. * @returns {Function} Debounced function. */static debounce(fn,wait=120){let t=null;return function debounced(...args){if(t){clearTimeout(t);}t=setTimeout(()=>fn.apply(this,args),wait);};}};// Renderer registry. Allows late registration and avoids tight coupling to a global map. Core.WPBC_BFB_Field_Renderer_Registry=function(){const map=new Map();return{register(type,ClassRef){map.set(String(type),ClassRef);},get(type){return map.get(String(type));}};}();})(window);// --------------------------------------------------------------------------------------------------------------------- // == File /includes/page-form-builder/_out/core/bfb-fields.js == | 2025-09-10 15:47 // --------------------------------------------------------------------------------------------------------------------- (function(w){'use strict';// Single global namespace (idempotent & load-order safe). const Core=w.WPBC_BFB_Core=w.WPBC_BFB_Core||{};const UI=Core.UI=Core.UI||{};/** * Base class for field renderers (static-only contract). * ================================================================================================================ * Contract exposed to the builder (static methods on the CLASS itself): * - render(el, data, ctx) // REQUIRED * - on_field_drop(data, el, meta) // OPTIONAL (default provided) * * Helpers for subclasses: * - get_defaults() -> per-field defaults (MUST override in subclass to set type/label) * - normalize_data(d) -> shallow merge with defaults * - get_template(id) -> per-id cached wp.template compiler * * Subclass usage: * class WPBC_BFB_Field_Text extends Core.WPBC_BFB_Field_Base { static get_defaults(){ ... } } * WPBC_BFB_Field_Text.template_id = 'wpbc-bfb-field-text'; * ================================================================================================================ */Core.WPBC_BFB_Field_Base=class{/** * Default field data (generic baseline). * Subclasses MUST override to provide { type, label } appropriate for the field. * @returns {Object} */static get_defaults(){return{type:'field',label:'Field',name:'field',html_id:'',placeholder:'',required:false,minlength:'',maxlength:'',pattern:'',cssclass:'',help:''};}/** * Shallow-merge incoming data with defaults. * @param {Object} data * @returns {Object} */static normalize_data(data){var d=data||{};var defaults=this.get_defaults();var out={};var k;for(k in defaults){if(Object.prototype.hasOwnProperty.call(defaults,k)){out[k]=defaults[k];}}for(k in d){if(Object.prototype.hasOwnProperty.call(d,k)){out[k]=d[k];}}return out;}/** * Compile and cache a wp.template by id (per-id cache). * @param {string} template_id * @returns {Function|null} */static get_template(template_id){// Accept either "wpbc-bfb-field-text" or "tmpl-wpbc-bfb-field-text". if(!template_id||!window.wp||!wp.template){return null;}const domId=template_id.startsWith('tmpl-')?template_id:'tmpl-'+template_id;if(!document.getElementById(domId)){return null;}if(!Core.__bfb_tpl_cache_map){Core.__bfb_tpl_cache_map={};}// Normalize id for the compiler & cache. // wp.template expects id WITHOUT the "tmpl-" prefix ! const key=template_id.replace(/^tmpl-/,'');if(Core.__bfb_tpl_cache_map[key]){return Core.__bfb_tpl_cache_map[key];}const compiler=wp.template(key);// <-- normalized id here if(compiler){Core.__bfb_tpl_cache_map[key]=compiler;}return compiler;}/** * REQUIRED: render preview into host element (full redraw; idempotent). * Subclasses should set static `template_id` to a valid wp.template id. * @param {HTMLElement} el * @param {Object} data * @param {{mode?:string,builder?:any,tpl?:Function,sanit?:any}} ctx * @returns {void} */static render(el,data,ctx){if(!el){return;}var compile=this.get_template(this.template_id);var d=this.normalize_data(data);var s=ctx&&ctx.sanit?ctx.sanit:Core.WPBC_BFB_Sanitize;// Sanitize critical attributes before templating. if(s){d.html_id=d.html_id?s.sanitize_html_id(String(d.html_id)):'';d.name=s.sanitize_html_name(String(d.name||d.id||'field'));}else{d.html_id=d.html_id?String(d.html_id):'';d.name=String(d.name||d.id||'field');}// Fall back to generic preview if template not available. if(compile){el.innerHTML=compile(d);// After render, set attribute values via DOM so quotes/newlines are handled correctly. const input=el.querySelector('input, textarea, select');if(input){if(d.placeholder!=null)input.setAttribute('placeholder',String(d.placeholder));if(d.title!=null)input.setAttribute('title',String(d.title));}}else{el.innerHTML=Core.WPBC_Form_Builder_Helper.render_field_inner_html(d);}el.dataset.type=d.type||'field';el.setAttribute('data-label',d.label!=null?String(d.label):'');// allow "". }/** * OPTIONAL hook executed after field is dropped/loaded/preview. * Default extended: * - On first drop: stamp default label (existing behavior) and mark field as "fresh" for auto-name. * - On load: mark as loaded so later label edits do not rename the saved name. */static on_field_drop(data,el,meta){const context=meta&&meta.context?String(meta.context):'';// ----------------------------------------------------------------------------------------- // NEW: Seed default "help" (and keep it in Structure) for all field packs that define it. // This fixes the mismatch where: // - UI shows default help via normalize_data() / templates // - but get_structure() / exporters see `help` as undefined/empty. // // Behavior: // - Runs ONLY on initial drop (context === 'drop'). // - If get_defaults() exposes a non-empty "help", and data.help is // missing / null / empty string -> we persist the default into `data` // and notify Structure so exports see it. // - On "load" we do nothing, so existing forms where user *cleared* // help will not be overridden. // ----------------------------------------------------------------------------------------- if(context==='drop'&&data){try{const defs=typeof this.get_defaults==='function'?this.get_defaults():null;if(defs&&Object.prototype.hasOwnProperty.call(defs,'help')){const current=Object.prototype.hasOwnProperty.call(data,'help')?data.help:undefined;const hasValue=current!==undefined&¤t!==null&&String(current)!=='';const defaultVal=defs.help;if(!hasValue&&defaultVal!=null&&String(defaultVal)!==''){// 1) persist into data object (used by Structure). data.help=defaultVal;// 2) mirror into dataset (for any DOM-based consumers). if(el){el.dataset.help=String(defaultVal);// 3) notify Structure / listeners (if available). try{Core.Structure?.update_field_prop?.(el,'help',defaultVal);el.dispatchEvent(new CustomEvent('wpbc_bfb_field_data_changed',{bubbles:true,detail:{key:'help',value:defaultVal}}));}catch(_inner){}}}}}catch(_e){}}// ----------------------------------------------------------------------------------------- if(context==='drop'&&!Object.prototype.hasOwnProperty.call(data,'label')){const defs=this.get_defaults();data.label=defs.label||'Field';el.setAttribute('data-label',data.label);}// Mark provenance flags. if(context==='drop'){el.dataset.fresh='1';// can auto-name on first label edit. el.dataset.autoname='1';el.dataset.was_loaded='0';// Seed a provisional unique name immediately. try{const b=meta?.builder;if(b?.id&&(!el.hasAttribute('data-name')||!el.getAttribute('data-name'))){const S=Core.WPBC_BFB_Sanitize;const base=S.sanitize_html_name(el.getAttribute('data-id')||data?.id||data?.type||'field');const uniq=b.id.ensure_unique_field_name(base,el);el.setAttribute('data-name',uniq);el.dataset.name_user_touched='0';}}catch(_){}}else if(context==='load'){el.dataset.fresh='0';el.dataset.autoname='0';el.dataset.was_loaded='1';// never rename names for loaded fields. }}// --- Auto Rename "Fresh" field, on entering the new Label --- /** * Create a conservative field "name" from a human label. * Uses the same constraints as sanitize_html_name (letters/digits/_- and leading letter). */static name_from_label(label){const s=Core.WPBC_BFB_Sanitize.sanitize_html_name(String(label??''));return s.toLowerCase()||'field';}/** * Auto-fill data-name from label ONLY for freshly dropped fields that were not edited yet. * - Never runs for sections. * - Never runs for loaded/existing fields. * - Stops as soon as user edits the Name manually. * * @param {WPBC_Form_Builder} builder * @param {HTMLElement} el - .wpbc_bfb__field element * @param {string} labelVal */static maybe_autoname_from_label(builder,el,labelVal){if(!builder||!el)return;if(el.classList.contains('wpbc_bfb__section'))return;const allowAuto=el.dataset.autoname==='1';const userTouched=el.dataset.name_user_touched==='1';const isLoaded=el.dataset.was_loaded==='1';if(!allowAuto||userTouched||isLoaded)return;// Only override placeholder-y names const S=Core.WPBC_BFB_Sanitize;const base=this.name_from_label(labelVal);const unique=builder.id.ensure_unique_field_name(base,el);el.setAttribute('data-name',unique);const ins=document.getElementById('wpbc_bfb__inspector');const nameCtrl=ins?.querySelector('[data-inspector-key="name"]');if(nameCtrl&&'value'in nameCtrl&&nameCtrl.value!==unique)nameCtrl.value=unique;}};/** * Select_Base (shared base for select-like packs) * * @type {Core.WPBC_BFB_Select_Base} */Core.WPBC_BFB_Select_Base=class extends Core.WPBC_BFB_Field_Base{static template_id=null;// main preview template id static option_row_template_id='wpbc-bfb-inspector-select-option-row';// row tpl id static kind='select';static __root_wired=false;static __root_node=null;// Single source of selectors used by the inspector UI. static ui={list:'.wpbc_bfb__options_list',holder:'.wpbc_bfb__options_state[data-inspector-key="options"]',row:'.wpbc_bfb__options_row',label:'.wpbc_bfb__opt-label',value:'.wpbc_bfb__opt-value',toggle:'.wpbc_bfb__opt-selected-chk',add_btn:'.js-add-option',drag_handle:'.wpbc_bfb__drag-handle',multiple_chk:'.js-opt-multiple[data-inspector-key="multiple"]',default_text:'.js-default-value[data-inspector-key="default_value"]',placeholder_input:'.js-placeholder[data-inspector-key="placeholder"]',placeholder_note:'.js-placeholder-note',size_input:'.inspector__input[data-inspector-key="size"]',// Dropdown menu integration. menu_root:'.wpbc_ui_el__dropdown',menu_toggle:'[data-toggle="wpbc_dropdown"]',menu_action:'.ul_dropdown_menu_li_action[data-action]',// Value-differs toggle. value_differs_chk:'.js-value-differs[data-inspector-key="value_differs"]'};/** * Build option value from label. * - If `differs === true` -> generate token (slug-like machine value). * - If `differs === false` -> keep human text; escape only dangerous chars. * @param {string} label * @param {boolean} differs * @returns {string} */static build_value_from_label(label,differs){const S=Core.WPBC_BFB_Sanitize;if(differs){return S&&typeof S.to_token==='function'?S.to_token(String(label||'')):String(label||'').trim().toLowerCase().replace(/\s+/g,'_').replace(/[^\w-]/g,'');}// single-input mode: keep human text; template will escape safely. return String(label==null?'':label);}/** * Is the “value differs from label” toggle enabled? * @param {HTMLElement} panel * @returns {boolean} */static is_value_differs_enabled(panel){const chk=panel?.querySelector(this.ui.value_differs_chk);return!!(chk&&chk.checked);}/** * Ensure visibility/enabled state of Value inputs based on the toggle. * When disabled -> hide Value inputs and keep them mirrored from Label. * @param {HTMLElement} panel * @returns {void} */static sync_value_inputs_visibility(panel){const differs=this.is_value_differs_enabled(panel);const rows=panel?.querySelectorAll(this.ui.row)||[];for(let i=0;i OFF: cache once, then mirror if(!val_in.dataset.cached_value){val_in.dataset.cached_value=val_in.value||'';}const lbl=lbl_in?lbl_in.value:'';val_in.value=this.build_value_from_label(lbl,/*differs=*/false);val_in.setAttribute('disabled','disabled');val_in.style.display='none';// NOTE: do NOT mark as user_touched here }}}/** * Return whether this row’s value has been edited by user. * @param {HTMLElement} row * @returns {boolean} */static is_row_value_user_touched(row){return row?.dataset?.value_user_touched==='1';}/** * Mark this row’s value as edited by user. * @param {HTMLElement} row */static mark_row_value_user_touched(row){if(row)row.dataset.value_user_touched='1';}/** * Initialize “freshness” flags on a row (value untouched). * Call on creation/append of rows. * @param {HTMLElement} row */static init_row_fresh_flags(row){if(row){if(!row.dataset.value_user_touched){row.dataset.value_user_touched='0';}}}// ---- defaults (packs can override) ---- static get_defaults(){return{type:this.kind,label:'Select',name:'',html_id:'',placeholder:'--- Select ---',required:false,multiple:false,size:null,cssclass:'',help:'',default_value:'',options:[{label:'Option 1',value:'Option 1',selected:false},{label:'Option 2',value:'Option 2',selected:false},{label:'Option 3',value:'Option 3',selected:false},{label:'Option 4',value:'Option 4',selected:false}],min_width:'240px'};}// ---- preview render (idempotent) ---- static render(el,data,ctx){if(!el)return;const d=this.normalize_data(data);if(d.min_width!=null){el.dataset.min_width=String(d.min_width);try{el.style.setProperty('--wpbc-col-min',String(d.min_width));}catch(_){}}if(d.html_id!=null)el.dataset.html_id=String(d.html_id||'');if(d.cssclass!=null)el.dataset.cssclass=String(d.cssclass||'');if(d.placeholder!=null)el.dataset.placeholder=String(d.placeholder||'');const tpl=this.get_template(this.template_id);if(typeof tpl!=='function'){el.innerHTML='';return;}try{el.innerHTML=tpl(d);}catch(e){window._wpbc?.dev?.error?.('Select_Base.render',e);el.innerHTML='';return;}el.dataset.type=d.type||this.kind;el.setAttribute('data-label',d.label!=null?String(d.label):'');try{Core.UI?.WPBC_BFB_Overlay?.ensure?.(ctx?.builder,el);}catch(_){}if(!el.dataset.options&&Array.isArray(d.options)&&d.options.length){try{el.dataset.options=JSON.stringify(d.options);}catch(_){}}}// ---- drop seeding (options + placeholder) ---- static on_field_drop(data,el,meta){try{super.on_field_drop?.(data,el,meta);}catch(_){}const is_drop=meta&&meta.context==='drop';if(is_drop){if(!Array.isArray(data.options)||!data.options.length){const opts=(this.get_defaults().options||[]).map(o=>({label:o.label,value:o.value,selected:!!o.selected}));data.options=opts;try{el.dataset.options=JSON.stringify(opts);el.dispatchEvent(new CustomEvent('wpbc_bfb_field_data_changed',{bubbles:true,detail:{key:'options',value:opts}}));Core.Structure?.update_field_prop?.(el,'options',opts);}catch(_){}}const ph=(data.placeholder??'').toString().trim();if(!ph){const dflt=this.get_defaults().placeholder||'--- Select ---';data.placeholder=dflt;try{el.dataset.placeholder=String(dflt);el.dispatchEvent(new CustomEvent('wpbc_bfb_field_data_changed',{bubbles:true,detail:{key:'placeholder',value:dflt}}));Core.Structure?.update_field_prop?.(el,'placeholder',dflt);}catch(_){}}}}// ============================== // Inspector helpers (snake_case) // ============================== static get_panel_root(el){return el?.closest?.('.wpbc_bfb__inspector__body')||el?.closest?.('.wpbc_bfb__inspector')||null;}static get_list(panel){return panel?panel.querySelector(this.ui.list):null;}static get_holder(panel){return panel?panel.querySelector(this.ui.holder):null;}static make_uid(){return'wpbc_ins_auto_opt_'+Math.random().toString(36).slice(2,10);}static append_row(panel,data){const list=this.get_list(panel);if(!list)return;const idx=list.children.length;const rowd=Object.assign({label:'',value:'',selected:false,index:idx},data||{});if(!rowd.uid)rowd.uid=this.make_uid();const tpl_id=this.option_row_template_id;const tpl=window.wp&&wp.template?wp.template(tpl_id):null;const html=tpl?tpl(rowd):null;// In append_row() -> fallback HTML. const wrap=document.createElement('div');wrap.innerHTML=html||'
'+''+''+''+'
'+'
'+''+''+''+'
'+'
'+// 3-dot dropdown (uses existing plugin dropdown JS). '
'+''+''+'
'+'
';const node=wrap.firstElementChild;if(!node){return;}// pre-hide Value input if toggle is OFF **before** appending. const differs=this.is_value_differs_enabled(panel);const valIn=node.querySelector(this.ui.value);const lblIn=node.querySelector(this.ui.label);if(!differs&&valIn){if(!valIn.dataset.cached_value){valIn.dataset.cached_value=valIn.value||'';}if(lblIn)valIn.value=this.build_value_from_label(lblIn.value,false);valIn.setAttribute('disabled','disabled');valIn.style.display='none';}this.init_row_fresh_flags(node);list.appendChild(node);// Keep your existing post-append sync as a safety net this.sync_value_inputs_visibility(panel);}static close_dropdown(anchor_el){try{var root=anchor_el?.closest?.(this.ui.menu_root);if(root){// If your dropdown toggler toggles a class like 'open', close it. root.classList.remove('open');// Or if it relies on aria-expanded on the toggle. var t=root.querySelector(this.ui.menu_toggle);if(t){t.setAttribute('aria-expanded','false');}}}catch(_){}}static insert_after(new_node,ref_node){if(ref_node?.parentNode){if(ref_node.nextSibling){ref_node.parentNode.insertBefore(new_node,ref_node.nextSibling);}else{ref_node.parentNode.appendChild(new_node);}}}static commit_options(panel){const list=this.get_list(panel);const holder=this.get_holder(panel);if(!list||!holder)return;const differs=this.is_value_differs_enabled(panel);const rows=list.querySelectorAll(this.ui.row);const options=[];for(let i=0;i hard mirror to label. if(!differs){// single-input mode: mirror Label, minimal escaping (no slug). val=this.build_value_from_label(lbl,/*differs=*/false);if(val_in){val_in.value=val;// keep hidden input in sync for any previews/debug. }}const sel=!!(chk&&chk.checked);options.push({label:lbl,value:val,selected:sel});}try{holder.value=JSON.stringify(options);holder.dispatchEvent(new Event('input',{bubbles:true}));holder.dispatchEvent(new Event('change',{bubbles:true}));panel.dispatchEvent(new CustomEvent('wpbc_bfb_field_data_changed',{bubbles:true,detail:{key:'options',value:options}}));}catch(_){}this.sync_default_value_lock(panel);this.sync_placeholder_lock(panel);// Mirror to the selected field element so canvas/export sees current options immediately. const field=panel.__selectbase_field||document.querySelector('.wpbc_bfb__field.is-selected, .wpbc_bfb__field--selected');if(field){try{field.dataset.options=JSON.stringify(options);}catch(_){}Core.Structure?.update_field_prop?.(field,'options',options);field.dispatchEvent(new CustomEvent('wpbc_bfb_field_data_changed',{bubbles:true,detail:{key:'options',value:options}}));}}static ensure_sortable(panel){const list=this.get_list(panel);if(!list){return;}try{const existing=window.Sortable?.get?.(list);if(existing){return;}const builder=window.wpbc_bfb_api?.get_builder?.()||window.wpbc_bfb||null;// Prefer the shared Sortable manager so the sidebar list uses // the dedicated "simple_list" config instead of the canvas config. if(builder&&builder.sortable&&typeof builder.sortable.ensure==='function'){builder.sortable.ensure(list,'canvas',{sortable_kind:'simple_list',handle_selector:this.ui.drag_handle,draggable_selector:this.ui.row,onUpdate:()=>{this.commit_options(panel);}});}else if(window.Sortable?.create){// Fallback if builder is not ready for some reason. window.Sortable.create(list,{handle:this.ui.drag_handle,draggable:this.ui.row,animation:120,forceFallback:true,fallbackOnBody:false,fallbackTolerance:8,removeCloneOnHide:true,onUpdate:()=>{this.commit_options(panel);}});}list.dataset.sortable_init='1';}catch(e){window._wpbc?.dev?.error?.('Select_Base.ensure_sortable',e);}}static rebuild_if_empty(panel){const list=this.get_list(panel);const holder=this.get_holder(panel);if(!list||!holder||list.children.length)return;let data=[];try{data=JSON.parse(holder.value||'[]');}catch(_){data=[];}if(!Array.isArray(data)||!data.length){data=(this.get_defaults().options||[]).slice(0);try{holder.value=JSON.stringify(data);holder.dispatchEvent(new Event('input',{bubbles:true}));holder.dispatchEvent(new Event('change',{bubbles:true}));}catch(_){}}for(let i=0;i{const panel=ev?.detail?.panel;const field=ev?.detail?.el||ev?.detail?.field||null;if(!panel)return;if(field)panel.__selectbase_field=field;this.bootstrap_panel(panel);// If the inspector root was remounted, ensure root listeners are (re)bound. this.wire_root_listeners();};document.addEventListener('wpbc_bfb_inspector_ready',on_ready_or_render);document.addEventListener('wpbc_bfb_inspector_render',on_ready_or_render);this.wire_root_listeners();}static wire_root_listeners(){// If already wired AND the stored root is still in the DOM, bail out. if(this.__root_wired&&this.__root_node?.isConnected)return;const root=document.getElementById('wpbc_bfb__inspector');if(!root){// Root missing (e.g., SPA re-render) — clear flags so we can wire later. this.__root_wired=false;this.__root_node=null;return;}this.__root_node=root;this.__root_wired=true;root.dataset.selectbase_root_wired='1';const get_panel=target=>target?.closest?.('.wpbc_bfb__inspector__body')||root.querySelector('.wpbc_bfb__inspector__body')||null;// Click handlers: add / delete / duplicate root.addEventListener('click',e=>{const panel=get_panel(e.target);if(!panel)return;this.bootstrap_panel(panel);const ui=this.ui;// Existing "Add option" button (top toolbar) const add=e.target.closest?.(ui.add_btn);if(add){this.append_row(panel,{label:'',value:'',selected:false});this.commit_options(panel);this.sync_value_inputs_visibility(panel);return;}// Dropdown menu actions. const menu_action=e.target.closest?.(ui.menu_action);if(menu_action){e.preventDefault();e.stopPropagation();const action=(menu_action.getAttribute('data-action')||'').toLowerCase();const row=menu_action.closest?.(ui.row);if(!row){this.close_dropdown(menu_action);return;}if('add_after'===action){// Add empty row after current const prev_count=this.get_list(panel)?.children.length||0;this.append_row(panel,{label:'',value:'',selected:false});// Move the newly added last row just after current row to preserve "add after" const list=this.get_list(panel);if(list&&list.lastElementChild&&list.lastElementChild!==row){this.insert_after(list.lastElementChild,row);}this.commit_options(panel);this.sync_value_inputs_visibility(panel);}else if('duplicate'===action){const lbl=(row.querySelector(ui.label)||{}).value||'';const val=(row.querySelector(ui.value)||{}).value||'';const sel=!!(row.querySelector(ui.toggle)||{}).checked;this.append_row(panel,{label:lbl,value:val,selected:sel,uid:this.make_uid()});// Place the new row right after the current. const list=this.get_list(panel);if(list&&list.lastElementChild&&list.lastElementChild!==row){this.insert_after(list.lastElementChild,row);}this.enforce_single_default(panel,null);this.commit_options(panel);this.sync_value_inputs_visibility(panel);}else if('remove'===action){if(row&&row.parentNode)row.parentNode.removeChild(row);this.commit_options(panel);this.sync_value_inputs_visibility(panel);}this.close_dropdown(menu_action);return;}},true);// Input delegation. root.addEventListener('input',e=>{const panel=get_panel(e.target);if(!panel){return;}const ui=this.ui;const is_label_or_value=e.target.classList?.contains('wpbc_bfb__opt-label')||e.target.classList?.contains('wpbc_bfb__opt-value');const is_toggle=e.target.classList?.contains('wpbc_bfb__opt-selected-chk');const is_multiple=e.target.matches?.(ui.multiple_chk);const is_default_text=e.target.matches?.(ui.default_text);const is_value_differs=e.target.matches?.(ui.value_differs_chk);// Handle "value differs" toggle live if(is_value_differs){this.sync_value_inputs_visibility(panel);this.commit_options(panel);return;}// Track when the user edits VALUE explicitly if(e.target.classList?.contains('wpbc_bfb__opt-value')){const row=e.target.closest(this.ui.row);this.mark_row_value_user_touched(row);// Keep the cache updated so toggling OFF/ON later restores the latest custom value e.target.dataset.cached_value=e.target.value||'';}// Auto-fill VALUE from LABEL if value is fresh (and differs is ON); if differs is OFF, we mirror anyway in commit if(e.target.classList?.contains('wpbc_bfb__opt-label')){const row=e.target.closest(ui.row);const val_in=row?.querySelector(ui.value);const differs=this.is_value_differs_enabled(panel);if(val_in){if(!differs){// single-input mode: mirror human label with minimal escaping val_in.value=this.build_value_from_label(e.target.value,false);}else if(!this.is_row_value_user_touched(row)){// separate-value mode, only while fresh val_in.value=this.build_value_from_label(e.target.value,true);}}}if(is_label_or_value||is_toggle||is_multiple){if(is_toggle)e.target.setAttribute('aria-checked',e.target.checked?'true':'false');if(is_toggle||is_multiple)this.enforce_single_default(panel,is_toggle?e.target:null);this.commit_options(panel);}if(is_default_text){this.sync_default_value_lock(panel);this.sync_placeholder_lock(panel);const holder=this.get_holder(panel);if(holder){holder.dispatchEvent(new Event('input',{bubbles:true}));holder.dispatchEvent(new Event('change',{bubbles:true}));}}},true);// Change delegation root.addEventListener('change',e=>{const panel=get_panel(e.target);if(!panel)return;const ui=this.ui;const is_toggle=e.target.classList?.contains('wpbc_bfb__opt-selected-chk');const is_multi=e.target.matches?.(ui.multiple_chk);if(!is_toggle&&!is_multi)return;if(is_toggle)e.target.setAttribute('aria-checked',e.target.checked?'true':'false');this.enforce_single_default(panel,is_toggle?e.target:null);this.commit_options(panel);},true);// Lazy bootstrap root.addEventListener('mouseenter',e=>{const panel=get_panel(e.target);if(panel&&e.target?.closest?.(this.ui.list))this.bootstrap_panel(panel);},true);root.addEventListener('mousedown',e=>{const panel=get_panel(e.target);if(panel&&e.target?.closest?.(this.ui.drag_handle))this.bootstrap_panel(panel);},true);}};try{Core.WPBC_BFB_Select_Base.wire_once();}catch(_){}// Try immediately (if root is already in DOM), then again on DOMContentLoaded. Core.WPBC_BFB_Select_Base.wire_root_listeners();document.addEventListener('DOMContentLoaded',()=>{Core.WPBC_BFB_Select_Base.wire_root_listeners();});})(window);// --------------------------------------------------------------------------------------------------------------------- // == File /includes/page-form-builder/_out/core/bfb-ui.js == | 2025-09-10 15:47 // --------------------------------------------------------------------------------------------------------------------- (function(w,d){'use strict';// Single global namespace (idempotent & load-order safe). const Core=w.WPBC_BFB_Core=w.WPBC_BFB_Core||{};const UI=Core.UI=Core.UI||{};// --- Highlight Element, like Generator brn - Tiny UI helpers ------------------------------------ UI._pulse_timers=UI._pulse_timers||new Map();// el -> timer_id UI._pulse_meta=UI._pulse_meta||new Map();// el -> { token, last_ts, debounce_id, color_set } // Pulse tuning (milliseconds). UI.PULSE_THROTTLE_MS=Number.isFinite(UI.PULSE_THROTTLE_MS)?UI.PULSE_THROTTLE_MS:500;UI.PULSE_DEBOUNCE_MS=Number.isFinite(UI.PULSE_DEBOUNCE_MS)?UI.PULSE_DEBOUNCE_MS:750;// Debounce STRUCTURE_CHANGE for continuous inspector controls (sliders / scrubbing). // Tune: 180..350 is usually a sweet spot. UI.STRUCTURE_CHANGE_DEBOUNCE_MS=Number.isFinite(UI.STRUCTURE_CHANGE_DEBOUNCE_MS)?UI.STRUCTURE_CHANGE_DEBOUNCE_MS:180;// Change this to tune speed: 50..120 ms is a good range. Can be configured in
...
. UI.VALUE_SLIDER_THROTTLE_MS=Number.isFinite(UI.VALUE_SLIDER_THROTTLE_MS)?UI.VALUE_SLIDER_THROTTLE_MS:120;/** * Cancel any running pulse sequence for an element. * Uses token invalidation so already-scheduled callbacks become no-ops. * * @param {HTMLElement} el */UI.cancel_pulse=function(el){if(!el){return;}try{clearTimeout(UI._pulse_timers.get(el));}catch(_){}UI._pulse_timers.delete(el);var meta=UI._pulse_meta.get(el)||{};meta.token=(Number.isFinite(meta.token)?meta.token:0)+1;meta.color_set=false;try{el.classList.remove('wpbc_bfb__scroll-pulse','wpbc_bfb__highlight-pulse');}catch(_){}try{el.style.removeProperty('--wpbc-bfb-pulse-color');}catch(_){}UI._pulse_meta.set(el,meta);try{clearTimeout(meta.debounce_id);}catch(_){}meta.debounce_id=0;};/** * Force-restart a CSS animation on a class. * @param {HTMLElement} el * @param {string} cls */UI._restart_css_animation=function(el,cls){if(!el){return;}try{el.classList.remove(cls);}catch(_){}// Force reflow so the next add() retriggers the keyframes. void el.offsetWidth;try{el.classList.add(cls);}catch(_){}};/** Single pulse (back-compat). @param {HTMLElement} el @param {number} dur_ms */UI.pulse_once=function(el,dur_ms){if(!el){return;}var cls='wpbc_bfb__scroll-pulse';var ms=Number.isFinite(dur_ms)?dur_ms:700;UI.cancel_pulse(el);var meta=UI._pulse_meta.get(el)||{};var token=(Number.isFinite(meta.token)?meta.token:0)+1;meta.token=token;UI._pulse_meta.set(el,meta);UI._restart_css_animation(el,cls);var t=setTimeout(function(){// ignore if a newer pulse started. var m=UI._pulse_meta.get(el)||{};if(m.token!==token){return;}try{el.classList.remove(cls);}catch(_){}UI._pulse_timers.delete(el);},ms);UI._pulse_timers.set(el,t);};/** Multi-blink sequence with optional per-call color override. @param {HTMLElement} el @param {number} [times=3] @param {number} [on_ms=280] @param {number} [off_ms=180] @param {string} [hex_color] Optional CSS color (e.g. '#ff4d4f' or 'rgb(...)'). */UI.pulse_sequence=function(el,times,on_ms,off_ms,hex_color){if(!el||!d.body.contains(el)){return;}var cls='wpbc_bfb__highlight-pulse';var count=Number.isFinite(times)?times:2;var on=Number.isFinite(on_ms)?on_ms:280;var off=Number.isFinite(off_ms)?off_ms:180;// Throttle: avoid reflow spam if called repeatedly while typing/dragging. var meta=UI._pulse_meta.get(el)||{};var now=Date.now();var throttle_ms=Number.isFinite(UI.PULSE_THROTTLE_MS)?UI.PULSE_THROTTLE_MS:120;if(Number.isFinite(meta.last_ts)&&now-meta.last_ts=count){UI._pulse_timers.delete(el);if(have_color){try{el.style.removeProperty('--wpbc-bfb-pulse-color');}catch(_){}}return;}UI._restart_css_animation(el,cls);UI._pulse_timers.set(el,setTimeout(function(){// ON -> OFF var m2=UI._pulse_meta.get(el)||{};if(m2.token!==token){return;}try{el.classList.remove(cls);}catch(_){}UI._pulse_timers.set(el,setTimeout(function(){// OFF gap -> next var m3=UI._pulse_meta.get(el)||{};if(m3.token!==token){return;}i++;tick();},off));},on));})();};/** * Debounced query + pulse. * Useful for `input` events (sliders / typing) to avoid forced reflow spam. * * @param {HTMLElement|string} root_or_selector * @param {string} selector * @param {number} wait_ms * @param {number} [a] * @param {number} [b] * @param {number} [c] * @param {string} [color] */UI.pulse_query_debounced=function(root_or_selector,selector,wait_ms,a,b,c,color){var root=typeof root_or_selector==='string'?d:root_or_selector||d;var sel=typeof root_or_selector==='string'?root_or_selector:selector;if(!sel){return;}var el=root.querySelector(sel);if(!el){return;}var def_ms=Number.isFinite(UI.PULSE_DEBOUNCE_MS)?UI.PULSE_DEBOUNCE_MS:120;var ms=Number.isFinite(wait_ms)?wait_ms:def_ms;var meta=UI._pulse_meta.get(el)||{};try{clearTimeout(meta.debounce_id);}catch(_){}meta.debounce_id=setTimeout(function(){UI.pulse_sequence(el,a,b,c,color);},ms);UI._pulse_meta.set(el,meta);};/** Query + pulse: (BC) If only 3rd arg is a number and no 4th/5th -> single long pulse. Otherwise -> strong sequence (defaults 3×280/180). Optional 6th arg: color. @param {HTMLElement|string} root_or_selector @param {string} [selector] @param {number} [a] @param {number} [b] @param {number} [c] @param {string} [color] */UI.pulse_query=function(root_or_selector,selector,a,b,c,color){var root=typeof root_or_selector==='string'?d:root_or_selector||d;var sel=typeof root_or_selector==='string'?root_or_selector:selector;if(!sel){return;}var el=root.querySelector(sel);if(!el){return;}// Back-compat: UI.pulseQuery(root, sel, dur_ms) if(Number.isFinite(a)&&b===undefined&&c===undefined){return UI.pulse_once(el,a);}// New: sequence; params optional; supports optional color. UI.pulse_sequence(el,a,b,c,color);};/** Convenience helper (snake_case) to call a strong pulse with options. @param {HTMLElement} el @param {Object} [opts] @param {number} [opts.times=3] @param {number} [opts.on_ms=280] @param {number} [opts.off_ms=180] @param {string} [opts.color] */UI.pulse_sequence_strong=function(el,opts){opts=opts||{};UI.pulse_sequence(el,Number.isFinite(opts.times)?opts.times:3,Number.isFinite(opts.on_ms)?opts.on_ms:280,Number.isFinite(opts.off_ms)?opts.off_ms:180,opts.color);};/** * Base class for BFB modules. */UI.WPBC_BFB_Module=class{/** @param {WPBC_Form_Builder} builder */constructor(builder){this.builder=builder;}/** Initialize the module. */init(){}/** Cleanup the module. */destroy(){}};/** * Central overlay/controls manager for fields/sections. * Pure UI composition; all actions route back into the builder instance. */UI.WPBC_BFB_Overlay=class{/** * Ensure an overlay exists and is wired up on the element. * @param {WPBC_Form_Builder} builder * @param {HTMLElement} el - field or section element */static ensure(builder,el){if(!el){return;}const isSection=el.classList.contains('wpbc_bfb__section');// let overlay = el.querySelector( Core.WPBC_BFB_DOM.SELECTORS.overlay ); let overlay=el.querySelector(`:scope > ${Core.WPBC_BFB_DOM.SELECTORS.overlay}`);if(!overlay){overlay=Core.WPBC_Form_Builder_Helper.create_element('div','wpbc_bfb__overlay-controls');el.prepend(overlay);}// Drag handle. if(!overlay.querySelector('.wpbc_bfb__drag-handle')){const dragClass=isSection?'wpbc_bfb__drag-handle section-drag-handle':'wpbc_bfb__drag-handle';overlay.appendChild(Core.WPBC_Form_Builder_Helper.create_element('span',dragClass,''));}// SETTINGS button (shown for both fields & sections). if(!overlay.querySelector('.wpbc_bfb__settings-btn')){const settings_btn=Core.WPBC_Form_Builder_Helper.create_element('button','wpbc_bfb__settings-btn','');settings_btn.type='button';settings_btn.title='Open settings';settings_btn.onclick=e=>{e.preventDefault();// Select THIS element and scroll it into view. builder.select_field(el,{scrollIntoView:true});// Auto-open Inspector from the overlay “Settings” button. wpbc_bfb__dispatch_event_safe('wpbc_bfb:show_panel',{panel_id:'wpbc_bfb__inspector',tab_id:'wpbc_tab_inspector'});// Try to bring the inspector into view / focus first input. const ins=document.getElementById('wpbc_bfb__inspector');if(ins){ins.scrollIntoView({behavior:'smooth',block:'nearest'});// Focus first interactive control (best-effort). setTimeout(()=>{const focusable=ins.querySelector('input,select,textarea,button,[contenteditable],[tabindex]:not([tabindex="-1"])');focusable?.focus?.();},260);}};overlay.appendChild(settings_btn);}overlay.setAttribute('role','toolbar');overlay.setAttribute('aria-label',el.classList.contains('wpbc_bfb__section')?'Section tools':'Field tools');return overlay;}};/** * WPBC Layout Chips helper - visual layout picker (chips), e.g., "50%/50%", to a section overlay. * * Renders Equal/Presets/Custom chips into a host container and wires them to apply the layout. */UI.WPBC_BFB_Layout_Chips=class{/** Read per-column min (px) from CSS var set by the guard. */static _get_col_min_px(col){const v=getComputedStyle(col).getPropertyValue('--wpbc-col-min')||'0';const n=parseFloat(v);return Number.isFinite(n)?Math.max(0,n):0;}/** * Turn raw weights (e.g. [1,1], [2,1,1]) into effective "available-%" bases that * (a) sum to the row's available %, and (b) meet every column's min px. * Returns an array of bases (numbers) or null if impossible to satisfy mins. */static _fit_weights_respecting_min(builder,row,weights){const cols=Array.from(row.querySelectorAll(':scope > .wpbc_bfb__column'));const n=cols.length;if(!n)return null;if(!Array.isArray(weights)||weights.length!==n)return null;// available % after gaps (from LayoutService) const gp=builder.col_gap_percent;const eff=builder.layout.compute_effective_bases_from_row(row,gp);const availPct=eff.available;// e.g. 94 if 2 cols and 3% gap const rowPx=row.getBoundingClientRect().width;const availPx=rowPx*(availPct/100);// collect minima in % of "available" const minPct=cols.map(c=>{const minPx=UI.WPBC_BFB_Layout_Chips._get_col_min_px(c);if(availPx<=0)return 0;return minPx/availPx*availPct;});// If mins alone don't fit, bail. const sumMin=minPct.reduce((a,b)=>a+b,0);if(sumMin>availPct-1e-6){return null;// impossible to respect mins; don't apply preset }// Target percentages from weights, normalized to availPct. const wSum=weights.reduce((a,w)=>a+(Number(w)||0),0)||n;const targetPct=weights.map(w=>(Number(w)||0)/wSum*availPct);// Lock columns that would be below min, then distribute the remainder // across the remaining columns proportionally to their targetPct. const locked=new Array(n).fill(false);let lockedSum=0;for(let i=0;iresult[i]=each);return result;}// Distribute remaining proportionally to free columns' targetPct freeIdx.forEach(i=>{result[i]=remaining*(targetPct[i]/freeTargetSum);});return result;}/** Apply a preset but guard it by minima; returns true if applied, false if skipped. */static _apply_preset_with_min_guard(builder,section_el,weights){const row=section_el.querySelector(':scope > .wpbc_bfb__row');if(!row)return false;const fitted=UI.WPBC_BFB_Layout_Chips._fit_weights_respecting_min(builder,row,weights);if(!fitted){builder?._announce?.('Not enough space for this layout because of fields’ minimum widths.');return false;}// `fitted` already sums to the row’s available %, so we can apply bases directly. builder.layout.apply_bases_to_row(row,fitted);return true;}/** * Build and append layout chips for a section. * * @param {WPBC_Form_Builder} builder - The form builder instance. * @param {HTMLElement} section_el - The .wpbc_bfb__section element. * @param {HTMLElement} host_el - Container where chips should be rendered. * @returns {void} */static render_for_section(builder,section_el,host_el){if(!builder||!section_el||!host_el){return;}const row=section_el.querySelector(':scope > .wpbc_bfb__row');if(!row){return;}const cols=row.querySelectorAll(':scope > .wpbc_bfb__column').length||1;// Clear host. host_el.innerHTML='';// Equal chip. host_el.appendChild(UI.WPBC_BFB_Layout_Chips._make_chip(builder,section_el,Array(cols).fill(1),'Equal'));// Presets based on column count. const presets=builder.layout.build_presets_for_columns(cols);presets.forEach(weights=>{host_el.appendChild(UI.WPBC_BFB_Layout_Chips._make_chip(builder,section_el,weights,null));});// Custom chip. const customBtn=document.createElement('button');customBtn.type='button';customBtn.className='wpbc_bfb__layout_chip';customBtn.textContent='Custom…';customBtn.title=`Enter ${cols} percentages`;customBtn.addEventListener('click',()=>{const example=cols===2?'50,50':cols===3?'20,60,20':'25,25,25,25';const text=prompt(`Enter ${cols} percentages (comma or space separated):`,example);if(text==null)return;const weights=builder.layout.parse_weights(text);if(weights.length!==cols){alert(`Please enter exactly ${cols} numbers.`);return;}// OLD: // builder.layout.apply_layout_preset( section_el, weights, builder.col_gap_percent ); // Guarded apply:. if(!UI.WPBC_BFB_Layout_Chips._apply_preset_with_min_guard(builder,section_el,weights)){return;}host_el.querySelectorAll('.wpbc_bfb__layout_chip').forEach(c=>c.classList.remove('is-active'));customBtn.classList.add('is-active');});host_el.appendChild(customBtn);}/** * Create a single layout chip button. * * @private * @param {WPBC_Form_Builder} builder * @param {HTMLElement} section_el * @param {number[]} weights * @param {string|null} label * @returns {HTMLButtonElement} */static _make_chip(builder,section_el,weights,label=null){const btn=document.createElement('button');btn.type='button';btn.className='wpbc_bfb__layout_chip';const title=label||builder.layout.format_preset_label(weights);btn.title=title;// Visual miniature. const vis=document.createElement('div');vis.className='wpbc_bfb__layout_chip-vis';const sum=weights.reduce((a,b)=>a+(Number(b)||0),0)||1;weights.forEach(w=>{const bar=document.createElement('span');bar.style.flex=`0 0 calc( ${((Number(w)||0)/sum*100).toFixed(3)}% - 1.5px )`;vis.appendChild(bar);});btn.appendChild(vis);const txt=document.createElement('span');txt.className='wpbc_bfb__layout_chip-label';txt.textContent=label||builder.layout.format_preset_label(weights);btn.appendChild(txt);btn.addEventListener('click',()=>{// OLD: // builder.layout.apply_layout_preset( section_el, weights, builder.col_gap_percent ); // NEW: if(!UI.WPBC_BFB_Layout_Chips._apply_preset_with_min_guard(builder,section_el,weights)){return;// do not toggle active if we didn't change layout }btn.parentElement?.querySelectorAll('.wpbc_bfb__layout_chip').forEach(c=>c.classList.remove('is-active'));btn.classList.add('is-active');});return btn;}};/** * Selection controller for fields and announcements. */UI.WPBC_BFB_Selection_Controller=class extends UI.WPBC_BFB_Module{init(){this._selected_uid=null;this.builder.select_field=this.select_field.bind(this);this.builder.get_selected_field=this.get_selected_field.bind(this);this._on_clear=this.on_clear.bind(this);// Centralized delete command used by keyboard + inspector + overlay. this.builder.delete_item=el=>{if(!el){return null;}const b=this.builder;const neighbor=b._find_neighbor_selectable?.(el)||null;el.remove();// Use local Core constants (not a global) to avoid ReferenceErrors. b.bus?.emit?.(Core.WPBC_BFB_Events.FIELD_REMOVE,{el,id:el?.dataset?.id,uid:el?.dataset?.uid});b.usage?.update_palette_ui?.();// Notify generic structure listeners, too: b.bus?.emit?.(Core.WPBC_BFB_Events.STRUCTURE_CHANGE,{reason:'delete',el});// Defer selection a tick so the DOM is fully settled before Inspector hydrates. requestAnimationFrame(()=>{// This calls inspector.bind_to_field() and opens the Inspector panel. b.select_field?.(neighbor||null,{scrollIntoView:!!neighbor});});return neighbor;};this.builder.bus.on(Core.WPBC_BFB_Events.CLEAR_SELECTION,this._on_clear);this.builder.bus.on(Core.WPBC_BFB_Events.STRUCTURE_LOADED,this._on_clear);// delegated click selection (capture ensures we win before bubbling to containers). this._on_canvas_click=this._handle_canvas_click.bind(this);this.builder.pages_container.addEventListener('click',this._on_canvas_click,true);}destroy(){this.builder.bus.off(Core.WPBC_BFB_Events.CLEAR_SELECTION,this._on_clear);if(this._on_canvas_click){this.builder.pages_container.removeEventListener('click',this._on_canvas_click,true);this._on_canvas_click=null;}}/** * Delegated canvas click -> select closest field/section (inner beats outer). * @private * @param {MouseEvent} e */_handle_canvas_click(e){const root=this.builder.pages_container;if(!root)return;// Ignore clicks on controls/handles/resizers, etc. const IGNORE=['.wpbc_bfb__overlay-controls','.wpbc_bfb__layout_picker','.wpbc_bfb__drag-handle','.wpbc_bfb__field-remove-btn','.wpbc_bfb__field-move-up','.wpbc_bfb__field-move-down','.wpbc_bfb__column-resizer'].join(',');if(e.target.closest(IGNORE)){return;// let those controls do their own thing. }// Find the closest selectable (field OR section) from the click target. let hit=e.target.closest?.(`${Core.WPBC_BFB_DOM.SELECTORS.validField}, ${Core.WPBC_BFB_DOM.SELECTORS.section}, .wpbc_bfb__column`);if(!hit||!root.contains(hit)){this.select_field(null);// Clear selection on blank click. return;// Empty space is handled elsewhere. }// NEW: if user clicked a COLUMN -> remember tab key on its SECTION, but still select the section. let preselect_tab_key=null;if(hit.classList.contains('wpbc_bfb__column')){const row=hit.closest('.wpbc_bfb__row');const cols=row?Array.from(row.querySelectorAll(':scope > .wpbc_bfb__column')):[];const idx=Math.max(0,cols.indexOf(hit));const sec=hit.closest('.wpbc_bfb__section');if(sec){preselect_tab_key=String(idx+1);// tabs are 1-based in ui-column-styles.js // Hint for the renderer (it reads this BEFORE rendering and restores the tab). sec.dataset.col_styles_active_tab=preselect_tab_key;// promote selection to the section (same UX as before). hit=sec;// NEW: visually mark which column is being edited if(UI&&UI.WPBC_BFB_Column_Styles&&UI.WPBC_BFB_Column_Styles.set_selected_col_flag){UI.WPBC_BFB_Column_Styles.set_selected_col_flag(sec,preselect_tab_key);}}}// Select and stop bubbling so outer containers don’t reselect a parent. this.select_field(hit);e.stopPropagation();// Also set the tab after the inspector renders (works even if it was already open). if(preselect_tab_key){(window.requestAnimationFrame||setTimeout)(function(){try{const ins=document.getElementById('wpbc_bfb__inspector');const tabs=ins&&ins.querySelector('[data-bfb-slot="column_styles"] [data-wpbc-tabs]');if(tabs&&window.wpbc_ui_tabs&&typeof window.wpbc_ui_tabs.set_active==='function'){window.wpbc_ui_tabs.set_active(tabs,preselect_tab_key);}}catch(_e){}},0);// Politely ask the Inspector to focus/open the "Column Styles" group and tab. wpbc_bfb__dispatch_event_safe('wpbc_bfb:inspector_focus',{group:'column_styles',tab_key:preselect_tab_key});}}/** * Select a field element or clear selection. * * @param {HTMLElement|null} field_el * @param {{scrollIntoView?: boolean}} [opts = {}] */select_field(field_el,{scrollIntoView=false}={}){const root=this.builder.pages_container;const prevEl=this.get_selected_field?.()||null;// the one we’re leaving. // Ignore elements not in the canvas. if(field_el&&!root.contains(field_el)){field_el=null;// treat as "no selection". }// NEW: if we are leaving a section, clear its column highlight if(prevEl&&prevEl!==field_el&&prevEl.classList?.contains('wpbc_bfb__section')&&UI?.WPBC_BFB_Column_Styles?.clear_selected_col_flag){UI.WPBC_BFB_Column_Styles.clear_selected_col_flag(prevEl);}// If we're leaving a field, permanently stop auto-name for it. if(prevEl&&prevEl!==field_el&&prevEl.classList?.contains('wpbc_bfb__field')){prevEl.dataset.autoname='0';prevEl.dataset.fresh='0';}root.querySelectorAll('.is-selected').forEach(n=>{n.classList.remove('is-selected');});if(!field_el){const prev=this._selected_uid||null;this._selected_uid=null;this.builder.inspector?.clear?.();root.classList.remove('has-selection');this.builder.bus.emit(Core.WPBC_BFB_Events.CLEAR_SELECTION,{prev_uid:prev,source:'builder'});// Auto-open "Add Fields" when nothing is selected. wpbc_bfb__dispatch_event_safe('wpbc_bfb:show_panel',{panel_id:'wpbc_bfb__palette_add_new',tab_id:'wpbc_tab_library'});return;}field_el.classList.add('is-selected');this._selected_uid=field_el.getAttribute('data-uid')||null;// Fallback: ensure sections announce themselves as type="section". if(field_el.classList.contains('wpbc_bfb__section')&&!field_el.dataset.type){field_el.dataset.type='section';}if(scrollIntoView){field_el.scrollIntoView({behavior:'smooth',block:'center'});}this.builder.inspector?.bind_to_field?.(field_el);// Fallback: ensure inspector enhancers (incl. ValueSlider) run every bind. try{const ins=document.getElementById('wpbc_bfb__inspector')||document.querySelector('.wpbc_bfb__inspector');if(ins){UI.InspectorEnhancers?.scan?.(ins);// runs all enhancers UI.WPBC_BFB_ValueSlider?.init_on?.(ins);// extra belt-and-suspenders }}catch(_){}// NEW: when selecting a section, reflect its active tab as the highlighted column. if(field_el.classList.contains('wpbc_bfb__section')&&UI?.WPBC_BFB_Column_Styles?.set_selected_col_flag){var k=field_el.dataset&&field_el.dataset.col_styles_active_tab?field_el.dataset.col_styles_active_tab:'1';UI.WPBC_BFB_Column_Styles.set_selected_col_flag(field_el,k);}// Keep sections & fields in the same flow: // 1) Generic hydrator for simple dataset-backed controls. if(field_el){UI.WPBC_BFB_Inspector_Bridge._generic_hydrate_controls?.(this.builder,field_el);UI.WPBC_BFB_Inspector_Bridge._hydrate_special_controls?.(this.builder,field_el);}// Auto-open Inspector when a user selects a field/section . wpbc_bfb__dispatch_event_safe('wpbc_bfb:show_panel',{panel_id:'wpbc_bfb__inspector',tab_id:'wpbc_tab_inspector'});root.classList.add('has-selection');this.builder.bus.emit(Core.WPBC_BFB_Events.SELECT,{uid:this._selected_uid,el:field_el});const label=field_el?.querySelector('.wpbc_bfb__field-label')?.textContent||(field_el.classList.contains('wpbc_bfb__section')?'section':'')||field_el?.dataset?.id||'item';this.builder._announce('Selected '+label+'.');}/** @returns {HTMLElement|null} */get_selected_field(){if(!this._selected_uid){return null;}const esc_attr=Core.WPBC_BFB_Sanitize.esc_attr_value_for_selector(this._selected_uid);return this.builder.pages_container.querySelector(`.wpbc_bfb__field[data-uid="${esc_attr}"], .wpbc_bfb__section[data-uid="${esc_attr}"]`);}/** @param {CustomEvent} ev */on_clear(ev){const src=ev?.detail?.source??ev?.source;if(src!=='builder'){this.select_field(null);}}};/** * Bridges the builder with the Inspector and sanitizes id/name edits. */UI.WPBC_BFB_Inspector_Bridge=class extends UI.WPBC_BFB_Module{init(){this._attach_inspector();this._bind_id_sanitizer();this._open_inspector_after_field_added();this._bind_focus_shortcuts();}_attach_inspector(){const b=this.builder;const attach=()=>{if(typeof window.WPBC_BFB_Inspector==='function'){b.inspector=new WPBC_BFB_Inspector(document.getElementById('wpbc_bfb__inspector'),b);this._bind_id_sanitizer();document.removeEventListener('wpbc_bfb_inspector_ready',attach);}};// Ensure we bind after late ready as well. if(typeof window.WPBC_BFB_Inspector==='function'){attach();}else{b.inspector={bind_to_field(){},clear(){}};document.addEventListener('wpbc_bfb_inspector_ready',attach);setTimeout(attach,0);}}/** * Listen for "focus" hints from the canvas and open the right group/tab. * - Supports: group === 'column_styles' * - Also scrolls the group into view. */_bind_focus_shortcuts(){/** @param {CustomEvent} e */const on_focus=e=>{try{const grp_key=e&&e.detail&&e.detail.group;const tab_key=e&&e.detail&&e.detail.tab_key;if(!grp_key){return;}const ins=document.getElementById('wpbc_bfb__inspector')||document.querySelector('.wpbc_bfb__inspector');if(!ins){return;}if(grp_key==='column_styles'){// Find the Column Styles slot/group. const slot=ins.querySelector('[data-bfb-slot="column_styles"]')||ins.querySelector('[data-inspector-group-key="column_styles"]');if(slot){// Open collapsible container if present. const group_wrap=slot.closest('.inspector__group')||slot.closest('[data-inspector-group]');if(group_wrap&&!group_wrap.classList.contains('is-open')){group_wrap.classList.add('is-open');// Mirror ARIA state if your header uses aria-expanded. const header_btn=group_wrap.querySelector('[aria-expanded]');if(header_btn){header_btn.setAttribute('aria-expanded','true');}}// Optional: set the requested tab key if tabs exist in this group. if(tab_key){const tabs=slot.querySelector('[data-wpbc-tabs]');if(tabs&&window.wpbc_ui_tabs&&typeof window.wpbc_ui_tabs.set_active==='function'){window.wpbc_ui_tabs.set_active(tabs,String(tab_key));}}// Bring into view for convenience. try{// Uncomment (Only if needed) this to AUTO SCROLL to specific COLUMN in the section:. // slot.scrollIntoView( { behavior: 'smooth', block: 'nearest' } ); }catch(_e){}}}}catch(_e){}};this._on_inspector_focus=on_focus;document.addEventListener('wpbc_bfb:inspector_focus',on_focus,true);}destroy(){try{if(this._on_inspector_focus){document.removeEventListener('wpbc_bfb:inspector_focus',this._on_inspector_focus,true);this._on_inspector_focus=null;}}catch(_e){}}/** * Hydrate inspector inputs for "special" keys that we handle explicitly. * Works for both fields and sections. * @param {WPBC_Form_Builder} builder * @param {HTMLElement} sel */static _hydrate_special_controls(builder,sel){const ins=document.getElementById('wpbc_bfb__inspector');if(!ins||!sel)return;const setVal=(key,val)=>{const ctrl=ins.querySelector(`[data-inspector-key="${key}"]`);if(ctrl&&'value'in ctrl)ctrl.value=String(val??'');};// Internal id / name / public html_id. setVal('id',sel.getAttribute('data-id')||'');setVal('name',sel.getAttribute('data-name')||'');setVal('html_id',sel.getAttribute('data-html_id')||'');// Section-only extras are harmless to set for fields (controls may not exist). setVal('cssclass',sel.getAttribute('data-cssclass')||'');setVal('label',sel.getAttribute('data-label')||'');}/** * Hydrate inspector inputs that declare a generic dataset mapping via * [data-inspector-key] but do NOT declare a custom value_from adapter. * This makes sections follow the same data flow as fields with almost no glue. * * @param {WPBC_Form_Builder} builder * @param {HTMLElement} sel - currently selected field/section */static _generic_hydrate_controls(builder,sel){const ins=document.getElementById('wpbc_bfb__inspector');if(!ins||!sel)return;const SKIP=/^(id|name|html_id|cssclass|label)$/;// handled by _hydrate_special_controls // NEW: read schema for the selected element’s type. const schemas=window.WPBC_BFB_Schemas||{};const typeKey=sel.dataset&&sel.dataset.type||'';const schemaEntry=schemas[typeKey]||null;const propsSchema=schemaEntry&&schemaEntry.schema&&schemaEntry.schema.props?schemaEntry.schema.props:{};const hasOwn=Function.prototype.call.bind(Object.prototype.hasOwnProperty);const getDefault=key=>{const meta=propsSchema[key];return meta&&hasOwn(meta,'default')?meta.default:undefined;};ins.querySelectorAll('[data-inspector-key]').forEach(ctrl=>{const key=String(ctrl.dataset?.inspectorKey||'').toLowerCase();if(!key||SKIP.test(key))return;// Element-level lock. const dl=(ctrl.dataset?.locked||'').trim().toLowerCase();if(dl==='1'||dl==='true'||dl==='yes')return;// Respect explicit adapters. if(ctrl.dataset?.value_from||ctrl.dataset?.valueFrom)return;const raw=sel.dataset?sel.dataset[key]:undefined;const hasRaw=sel.dataset?hasOwn(sel.dataset,key):false;const defValue=getDefault(key);// Best-effort control typing with schema default fallback when value is absent. if(ctrl instanceof HTMLInputElement&&(ctrl.type==='checkbox'||ctrl.type==='radio')){// If dataset is missing the key entirely -> use schema default (boolean). if(!hasRaw){ctrl.checked=!!defValue;}else{ctrl.checked=Core.WPBC_BFB_Sanitize.coerce_boolean(raw,!!defValue);}}else if('value'in ctrl){if(hasRaw){ctrl.value=raw!=null?String(raw):'';}else{ctrl.value=defValue==null?'':String(defValue);}}});}_bind_id_sanitizer(){const b=this.builder;const ins=document.getElementById('wpbc_bfb__inspector');if(!ins){return;}if(ins.__wpbc_bfb_id_sanitizer_bound){return;}ins.__wpbc_bfb_id_sanitizer_bound=true;const handler=e=>{const t=e.target;if(!t||!('value'in t)){return;}const key=(t.dataset?.inspectorKey||'').toLowerCase();const sel=b.get_selected_field?.();const isSection=sel?.classList?.contains('wpbc_bfb__section');if(!sel)return;// Unified emitter that always includes the element reference. const EV=Core.WPBC_BFB_Events;// STRUCTURE_CHANGE can be "expensive" because other listeners may trigger full canvas refresh. // Debounce only continuous controls (e.g. value slider scrubbing) on the INPUT phase. const ensure_sc_debounce_state=()=>{if(b.__wpbc_bfb_sc_debounce_state){return b.__wpbc_bfb_sc_debounce_state;}b.__wpbc_bfb_sc_debounce_state={timer_id:0,pending_payload:null};return b.__wpbc_bfb_sc_debounce_state;};const cancel_sc_debounced_emit=()=>{const st=b.__wpbc_bfb_sc_debounce_state;if(!st)return;try{clearTimeout(st.timer_id);}catch(_){}st.timer_id=0;st.pending_payload=null;};const bus_emit_change=(reason,extra={})=>{// If we’re committing something (change/blur/etc), drop any pending "input" emit. cancel_sc_debounced_emit();b.bus?.emit?.(EV.STRUCTURE_CHANGE,{reason,el:sel,...extra});};const bus_emit_change_debounced=(reason,extra={},wait_ms)=>{const st=ensure_sc_debounce_state();const ms=Number.isFinite(wait_ms)?wait_ms:Number.isFinite(UI.STRUCTURE_CHANGE_DEBOUNCE_MS)?UI.STRUCTURE_CHANGE_DEBOUNCE_MS:240;// Capture the CURRENT selected element into the payload now (stable ref). st.pending_payload={reason,el:sel,...extra,debounced:true};try{clearTimeout(st.timer_id);}catch(_){}st.timer_id=setTimeout(function(){st.timer_id=0;const payload=st.pending_payload;st.pending_payload=null;if(payload){b.bus?.emit?.(EV.STRUCTURE_CHANGE,payload);}},ms);};// ---- FIELD/SECTION: internal id ---- if(key==='id'){const unique=b.id.set_field_id(sel,t.value);if(b.preview_mode&&!isSection){b.render_preview(sel);}if(t.value!==unique){t.value=unique;}bus_emit_change('id-change');return;}// ---- FIELD/SECTION: public HTML id ---- if(key==='html_id'){const applied=b.id.set_field_html_id(sel,t.value);// For sections, also set the real DOM id so anchors/CSS can target it. if(isSection){sel.id=applied||'';}else if(b.preview_mode){b.render_preview(sel);}if(t.value!==applied){t.value=applied;}bus_emit_change('html-id-change');return;}// ---- FIELDS ONLY: name ---- if(key==='name'&&!isSection){// Live typing: sanitize only (NO uniqueness yet) to avoid "-2" spam if(e.type==='input'){const before=t.value;const sanitized=Core.WPBC_BFB_Sanitize.sanitize_html_name(before);if(before!==sanitized){// optional: preserve caret to avoid jump const selStart=t.selectionStart,selEnd=t.selectionEnd;t.value=sanitized;try{t.setSelectionRange(selStart,selEnd);}catch(_){}}return;// uniqueness on change/blur }// Commit (change/blur) const raw=String(t.value??'').trim();if(!raw){// RESEED: keep name non-empty and provisional (autoname stays ON) const S=Core.WPBC_BFB_Sanitize;const base=S.sanitize_html_name(sel.getAttribute('data-id')||sel.dataset.id||sel.dataset.type||'field');const uniq=b.id.ensure_unique_field_name(base,sel);sel.setAttribute('data-name',uniq);sel.dataset.autoname='1';sel.dataset.name_user_touched='0';// Keep DOM in sync if we’re not re-rendering if(!b.preview_mode){const ctrl=sel.querySelector('input,textarea,select');if(ctrl)ctrl.setAttribute('name',uniq);}else{b.render_preview(sel);}if(t.value!==uniq)t.value=uniq;bus_emit_change('name-reseed');return;}// Non-empty commit: user takes control; disable autoname going forward sel.dataset.name_user_touched='1';sel.dataset.autoname='0';const sanitized=Core.WPBC_BFB_Sanitize.sanitize_html_name(raw);const unique=b.id.set_field_name(sel,sanitized);if(!b.preview_mode){const ctrl=sel.querySelector('input,textarea,select');if(ctrl)ctrl.setAttribute('name',unique);}else{b.render_preview(sel);}if(t.value!==unique)t.value=unique;bus_emit_change('name-change');return;}// ---- SECTIONS & FIELDS: cssclass (live apply; no re-render) ---- if(key==='cssclass'){const next=Core.WPBC_BFB_Sanitize.sanitize_css_classlist(t.value||'');const desiredArr=next.split(/\s+/).filter(Boolean);const desiredSet=new Set(desiredArr);// Core classes are never touched. const isCore=cls=>cls==='is-selected'||cls.startsWith('wpbc_');// Snapshot before mutating (DOMTokenList is live). const beforeClasses=Array.from(sel.classList);const customBefore=beforeClasses.filter(c=>!isCore(c));// Remove stray non-core classes not in desired. customBefore.forEach(c=>{if(!desiredSet.has(c))sel.classList.remove(c);});// Add missing desired classes in one go. const missing=desiredArr.filter(c=>!customBefore.includes(c));if(missing.length)sel.classList.add(...missing);// Keep dataset in sync (avoid useless attribute writes). if(sel.getAttribute('data-cssclass')!==next){sel.setAttribute('data-cssclass',next);}// Emit only if something actually changed. const afterClasses=Array.from(sel.classList);const changed=afterClasses.length!==beforeClasses.length||beforeClasses.some((c,i)=>c!==afterClasses[i]);const detail={key:'cssclass',phase:e.type};if(isSection){bus_emit_change('cssclass-change',detail);}else{bus_emit_change('prop-change',detail);}return;}// ---- SECTIONS: label ---- if(isSection&&key==='label'){const val=String(t.value??'');sel.setAttribute('data-label',val);bus_emit_change('label-change');return;}// ---- FIELDS: label (auto-name while typing; freeze on commit) ---- if(!isSection&&key==='label'){const val=String(t.value??'');sel.dataset.label=val;// while typing, allow auto-name (if flags permit) try{Core.WPBC_BFB_Field_Base.maybe_autoname_from_label(b,sel,val);}catch(_){}// if user committed the label (blur/change), freeze future auto-name if(e.type!=='input'){sel.dataset.autoname='0';// stop future label->name sync sel.dataset.fresh='0';// also kill the "fresh" escape hatch }// Optional UI nicety: disable Name when auto is ON, enable when OFF const ins=document.getElementById('wpbc_bfb__inspector');const nameCtrl=ins?.querySelector('[data-inspector-key="name"]');if(nameCtrl){const autoActive=(sel.dataset.autoname??'1')!=='0'&&sel.dataset.name_user_touched!=='1'&&sel.dataset.was_loaded!=='1';nameCtrl.toggleAttribute('disabled',autoActive);if(autoActive&&!nameCtrl.placeholder){nameCtrl.placeholder=b?.i18n?.auto_from_label??'auto — from label';}if(!autoActive&&nameCtrl.placeholder===(b?.i18n?.auto_from_label??'auto — from label')){nameCtrl.placeholder='';}}// Always re-render the preview so label changes are visible immediately. b.render_preview(sel);bus_emit_change('label-change');return;}// ---- DEFAULT (GENERIC): dataset writer for both fields & sections ---- // Any inspector control with [data-inspector-key] that doesn't have a custom // adapter/value_from will simply read/write sel.dataset[key]. if(key){const selfLocked=/^(1|true|yes)$/i.test((t.dataset?.locked||'').trim());if(selfLocked){return;}// Skip keys we handled above to avoid double work. if(key==='id'||key==='name'||key==='html_id'||key==='cssclass'||key==='label'){return;}let nextVal='';if(t instanceof HTMLInputElement&&(t.type==='checkbox'||t.type==='radio')){nextVal=t.checked?'1':'';}else if('value'in t){nextVal=String(t.value??'');}// Persist to dataset. if(sel?.dataset)sel.dataset[key]=nextVal;// Generator controls are "UI inputs" — avoid STRUCTURE_CHANGE spam while dragging/typing. const is_gen_key=key.indexOf('gen_')===0;// Re-render on visual keys so preview stays in sync (calendar label/help, etc.). const visualKeys=new Set(['help','placeholder','min_width','cssclass']);if(!isSection&&(visualKeys.has(key)||key.startsWith('ui_'))){// Light heuristic: only re-render on commit for heavy inputs; live for short ones is fine. if(e.type==='change'||key==='help'||key==='placeholder'){b.render_preview(sel);}}if(!(is_gen_key&&e.type==='input')){// Debounce continuous value slider input events to avoid full-canvas refresh spam. // We detect the slider group via [data-len-group] wrapper. const is_len_group_ctrl=!!(t&&t.closest&&t.closest('[data-len-group]'));if(is_len_group_ctrl&&e.type==='input'){bus_emit_change_debounced('prop-change',{key,phase:e.type});}else{bus_emit_change('prop-change',{key,phase:e.type});}}return;}};ins.addEventListener('change',handler,true);// reflect instantly while typing as well. ins.addEventListener('input',handler,true);}/** * Open Inspector after a field is added. * @private */_open_inspector_after_field_added(){const EV=Core.WPBC_BFB_Events;this.builder?.bus?.on?.(EV.FIELD_ADD,e=>{const el=e?.detail?.el||null;if(el&&this.builder?.select_field){this.builder.select_field(el,{scrollIntoView:true});}// Show Inspector Palette. wpbc_bfb__dispatch_event_safe('wpbc_bfb:show_panel',{panel_id:'wpbc_bfb__inspector',tab_id:'wpbc_tab_inspector'});});}};/** * Keyboard shortcuts for selection, deletion, and movement. */UI.WPBC_BFB_Keyboard_Controller=class extends UI.WPBC_BFB_Module{init(){this._on_key=this.on_key.bind(this);document.addEventListener('keydown',this._on_key,true);}destroy(){document.removeEventListener('keydown',this._on_key,true);}/** @param {KeyboardEvent} e */on_key(e){const b=this.builder;const is_typing=this._is_typing_anywhere();if(e.key==='Escape'){if(is_typing){return;}this.builder.bus.emit(Core.WPBC_BFB_Events.CLEAR_SELECTION,{source:'esc'});return;}const selected=b.get_selected_field?.();if(!selected||is_typing){return;}if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();b.delete_item?.(selected);return;}if((e.altKey||e.ctrlKey||e.metaKey)&&(e.key==='ArrowUp'||e.key==='ArrowDown')&&!e.shiftKey){e.preventDefault();const dir=e.key==='ArrowUp'?'up':'down';b.move_item?.(selected,dir);return;}if(e.key==='Enter'){e.preventDefault();b.select_field(selected,{scrollIntoView:true});}}/** @returns {boolean} */_is_typing_anywhere(){const a=document.activeElement;const tag=a?.tagName;if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT'||a?.isContentEditable===true){return true;}const ins=document.getElementById('wpbc_bfb__inspector');return!!(ins&&a&&ins.contains(a));}};/** * Column resize logic for section rows. */UI.WPBC_BFB_Resize_Controller=class extends UI.WPBC_BFB_Module{init(){this.builder.init_resize_handler=this.handle_resize.bind(this);}/** * read the CSS var (kept local so it doesn’t depend on the Min-Width module) * * @param col * @returns {number|number} * @private */_get_col_min_px(col){const v=getComputedStyle(col).getPropertyValue('--wpbc-col-min')||'0';const n=parseFloat(v);return Number.isFinite(n)?Math.max(0,n):0;}/** @param {MouseEvent} e */handle_resize(e){const b=this.builder;e.preventDefault();if(e.button!==0)return;const resizer=e.currentTarget;const row_el=resizer.parentElement;const cols=Array.from(row_el.querySelectorAll(':scope > .wpbc_bfb__column'));const left_col=resizer?.previousElementSibling;const right_col=resizer?.nextElementSibling;if(!left_col||!right_col||!left_col.classList.contains('wpbc_bfb__column')||!right_col.classList.contains('wpbc_bfb__column'))return;const left_index=cols.indexOf(left_col);const right_index=cols.indexOf(right_col);if(left_index===-1||right_index!==left_index+1)return;const start_x=e.clientX;const left_start_px=left_col.getBoundingClientRect().width;const right_start_px=right_col.getBoundingClientRect().width;const pair_px=Math.max(0,left_start_px+right_start_px);const gp=b.col_gap_percent;const computed=b.layout.compute_effective_bases_from_row(row_el,gp);const available=computed.available;// % of the “full 100” after gaps const bases=computed.bases.slice(0);// current effective % const pair_avail=bases[left_index]+bases[right_index];// Bail if we can’t compute sane deltas. if(!pair_px||!Number.isFinite(pair_avail)||pair_avail<=0)return;// --- MIN CLAMPS (pixels) ------------------------------------------------- const pctToPx=pct=>pair_px*(pct/pair_avail);// pair-local percent -> px const genericMinPct=Math.min(0.1,available);// original 0.1% floor (in “available %” space) const genericMinPx=pctToPx(genericMinPct);const leftMinPx=Math.max(this._get_col_min_px(left_col),genericMinPx);const rightMinPx=Math.max(this._get_col_min_px(right_col),genericMinPx);// freeze text selection + cursor const prev_user_select=document.body.style.userSelect;document.body.style.userSelect='none';row_el.style.cursor='col-resize';const on_mouse_move=ev=>{if(!pair_px)return;// work in pixels, clamp by each side’s min const delta_px=ev.clientX-start_x;let newLeftPx=left_start_px+delta_px;newLeftPx=Math.max(leftMinPx,Math.min(pair_px-rightMinPx,newLeftPx));const newRightPx=pair_px-newLeftPx;// translate back to pair-local percentages const newLeftPct=newLeftPx/pair_px*pair_avail;const newBases=bases.slice(0);newBases[left_index]=newLeftPct;newBases[right_index]=pair_avail-newLeftPct;b.layout.apply_bases_to_row(row_el,newBases);};const on_mouse_up=()=>{document.removeEventListener('mousemove',on_mouse_move);document.removeEventListener('mouseup',on_mouse_up);window.removeEventListener('mouseup',on_mouse_up);document.removeEventListener('mouseleave',on_mouse_up);document.body.style.userSelect=prev_user_select||'';row_el.style.cursor='';// normalize to the row’s available % again const normalized=b.layout.compute_effective_bases_from_row(row_el,gp);b.layout.apply_bases_to_row(row_el,normalized.bases);};document.addEventListener('mousemove',on_mouse_move);document.addEventListener('mouseup',on_mouse_up);window.addEventListener('mouseup',on_mouse_up);document.addEventListener('mouseleave',on_mouse_up);}};/** * Page and section creation, rebuilding, and nested Sortable setup. */UI.WPBC_BFB_Pages_Sections=class extends UI.WPBC_BFB_Module{init(){this.builder.add_page=opts=>this.add_page(opts);this.builder.add_section=(container,cols)=>this.add_section(container,cols);this.builder.rebuild_section=(section_data,container)=>this.rebuild_section(section_data,container);this.builder.init_all_nested_sortables=el=>this.init_all_nested_sortables(el);this.builder.init_section_sortable=el=>this.init_section_sortable(el);this.builder.pages_sections=this;}/** * Give every field/section in a cloned subtree a fresh data-uid so * uniqueness checks don't exclude their originals. */_retag_uids_in_subtree(root){const b=this.builder;if(!root)return;const nodes=[];if(root.classList?.contains('wpbc_bfb__section')||root.classList?.contains('wpbc_bfb__field')){nodes.push(root);}nodes.push(...root.querySelectorAll('.wpbc_bfb__section, .wpbc_bfb__field'));nodes.forEach(el=>{const prefix=el.classList.contains('wpbc_bfb__section')?'s':'f';el.dataset.uid=`${prefix}-${++b._uid_counter}-${Date.now()}-${Math.random().toString(36).slice(2,7)}`;});}/** * Bump "foo", "foo-2", "foo-3", ... */_make_unique(base,taken){const s=Core.WPBC_BFB_Sanitize;let v=String(base||'');if(!v)v='field';const m=v.match(/-(\d+)$/);let n=m?parseInt(m[1],10)||1:1;let stem=m?v.replace(/-\d+$/,''):v;while(taken.has(v)){n=Math.max(2,n+1);v=`${stem}-${n}`;}taken.add(v);return v;}/** * Strict, one-pass de-duplication for a newly-inserted subtree. * - Ensures unique data-id (internal), data-name (fields), data-html_id (public) * - Also updates DOM:
, ,