// --------------------------------------------------------------------------------------------------------------------- // == 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 && currentEl && 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 < limit ) { limit = n; } } } ); return limit; } /** * Disable/enable palette items based on current usage counts and limits. * * @returns {void} */ update_palette_ui() { // Always compute usage from the canvas: const usage = this.count_usage_by_key(); // Update all palettes currently in DOM (not just the initially captured ones) const palettes = document.querySelectorAll( '.wpbc_bfb__panel_field_types__ul' ); palettes.forEach( (pal) => { 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 ) && current >= 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 && evtX < innerRight; const insideY = evtY > innerTop && evtY < innerBottom; if ( !(insideX && insideY) ) return false; // stay in current column until well inside new one // --- Sticky target commit distance: only switch if we’re clearly inside the new column const ds = this._dragState; if ( ds ) { if ( ds.stickyTo && ds.stickyTo !== to ) { // require a deeper penetration to switch columns const commitX = Core.WPBC_BFB_Sanitize.clamp( rect.width * 0.25, 18, 40 ); // 25% or 18–40px const commitY = Core.WPBC_BFB_Sanitize.clamp( rect.height * 0.15, 10, 28 ); // 15% or 10–28px const deepInside = (evtX > rect.left + commitX && evtX < rect.right - commitX) && (evtY > rect.top + commitY && evtY < rect.bottom - commitY); if ( !deepInside ) return false; } // We accept the new target now. ds.stickyTo = to; ds.lastSwitchTs = performance.now(); } return true; }, onStart: (evt) => { 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 < to.children.length - 1 ) { const ref = to.children[evt.newIndex] || null; to.insertBefore( section, ref ); } // Finalize: overlay, selection, events, usage refresh. this.builder.add_overlay_toolbar?.( section ); this.builder.select_field?.( section ); this.builder.bus?.emit?.( Core.WPBC_BFB_Events.FIELD_ADD, { el : section, id : section.dataset.id, uid: section.dataset.uid } ); this.builder.usage?.update_palette_ui?.(); return true; } /** * Destroy all Sortable instances created by this manager. * * @returns {void} */ destroyAll() { this._containers.forEach( ( el ) => { 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 && current !== 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 < rows.length; i++ ) { const r = rows[i]; const lbl_in = r.querySelector( this.ui.label ); const val_in = r.querySelector( this.ui.value ); if ( !val_in ) continue; if ( differs ) { // Re-enable & show value input val_in.removeAttribute( 'disabled' ); val_in.style.display = ''; // If we have a cached custom value and the row wasn't edited while OFF, restore it const hasCache = !!val_in.dataset.cached_value; const userEdited = r.dataset.value_user_touched === '1'; if ( hasCache && !userEdited ) { val_in.value = val_in.dataset.cached_value; } else if ( !hasCache ) { // No cache: if value is just a mirrored label, offer a tokenized default const lbl = lbl_in ? lbl_in.value : ''; const mirrored = this.build_value_from_label( lbl, /*differs=*/false ); if ( val_in.value === mirrored ) { val_in.value = this.build_value_from_label( lbl, /*differs=*/true ); } } } else { // ON -> 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 < rows.length; i++ ) { const r = rows[i]; const lbl_in = r.querySelector( this.ui.label ); const val_in = r.querySelector( this.ui.value ); const chk = r.querySelector( this.ui.toggle ); const lbl = (lbl_in && lbl_in.value) || ''; let val = (val_in && val_in.value) || ''; // If single-input mode -> 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 < data.length; i++ ) { this.append_row( panel, { label : data[i]?.label || '', value : data[i]?.value || '', selected: !!data[i]?.selected, index : i, uid : this.make_uid() } ); } this.sync_default_value_lock( panel ); this.sync_placeholder_lock( panel ); this.sync_value_inputs_visibility( panel ); } static has_row_defaults(panel) { const checks = panel?.querySelectorAll( this.ui.toggle ); if ( !checks?.length ) return false; for ( let i = 0; i < checks.length; i++ ) if ( checks[i].checked ) return true; return false; } static is_multiple_enabled(panel) { const chk = panel?.querySelector( this.ui.multiple_chk ); return !!(chk && chk.checked); } static has_text_default_value(panel) { const dv = panel?.querySelector( this.ui.default_text ); return !!(dv && String( dv.value || '' ).trim().length); } static sync_default_value_lock(panel) { const input = panel?.querySelector( this.ui.default_text ); const note = panel?.querySelector( '.js-default-value-note' ); if ( !input ) return; const lock = this.has_row_defaults( panel ); input.disabled = !!lock; if ( lock ) { input.setAttribute( 'aria-disabled', 'true' ); if ( note ) note.style.display = ''; } else { input.removeAttribute( 'aria-disabled' ); if ( note ) note.style.display = 'none'; } } static sync_placeholder_lock(panel) { const input = panel?.querySelector( this.ui.placeholder_input ); const note = panel?.querySelector( this.ui.placeholder_note ); // NEW: compute multiple and toggle row visibility const isMultiple = this.is_multiple_enabled( panel ); const placeholderRow = input?.closest( '.inspector__row' ) || null; const sizeInput = panel?.querySelector( this.ui.size_input ) || null; const sizeRow = sizeInput?.closest( '.inspector__row' ) || null; // Show placeholder only for single-select; show size only for multiple if ( placeholderRow ) placeholderRow.style.display = isMultiple ? 'none' : ''; if ( sizeRow ) sizeRow.style.display = isMultiple ? '' : 'none'; // Existing behavior (keep as-is) if ( !input ) return; const lock = isMultiple || this.has_row_defaults( panel ) || this.has_text_default_value( panel ); if ( note && !note.id ) note.id = 'wpbc_placeholder_note_' + Math.random().toString( 36 ).slice( 2, 10 ); input.disabled = !!lock; if ( lock ) { input.setAttribute( 'aria-disabled', 'true' ); if ( note ) { note.style.display = ''; input.setAttribute( 'aria-describedby', note.id ); } } else { input.removeAttribute( 'aria-disabled' ); input.removeAttribute( 'aria-describedby' ); if ( note ) note.style.display = 'none'; } } static enforce_single_default(panel, clicked) { if ( this.is_multiple_enabled( panel ) ) return; const checks = panel?.querySelectorAll( this.ui.toggle ); if ( !checks?.length ) return; if ( clicked && clicked.checked ) { for ( let i = 0; i < checks.length; i++ ) if ( checks[i] !== clicked ) { checks[i].checked = false; checks[i].setAttribute( 'aria-checked', 'false' ); } clicked.setAttribute( 'aria-checked', 'true' ); return; } let kept = false; for ( let j = 0; j < checks.length; j++ ) if ( checks[j].checked ) { if ( !kept ) { kept = true; } else { checks[j].checked = false; checks[j].setAttribute( 'aria-checked', 'false' ); } } this.sync_default_value_lock( panel ); this.sync_placeholder_lock( panel ); } // ---- one-time bootstrap of a panel ---- static bootstrap_panel(panel) { if ( !panel ) return; if ( !panel.querySelector( '.wpbc_bfb__options_editor' ) ) return; // only select-like UIs if ( panel.dataset.selectbase_bootstrapped === '1' ) { this.ensure_sortable( panel ); return; } this.rebuild_if_empty( panel ); this.ensure_sortable( panel ); panel.dataset.selectbase_bootstrapped = '1'; this.sync_default_value_lock( panel ); this.sync_placeholder_lock( panel ); this.sync_value_inputs_visibility( panel ); } // ---- hook into inspector lifecycle (fires ONCE) ---- static wire_once() { if ( Core.__selectbase_wired ) return; Core.__selectbase_wired = true; const on_ready_or_render = (ev) => { 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) < throttle_ms ) { return; } meta.last_ts = now; // cancel any running pulse and reset class (token invalidation). UI.cancel_pulse( el ); // new token for this run var token = (Number.isFinite( meta.token ) ? meta.token : 0) + 1; meta.token = token; var have_color = !!hex_color && typeof hex_color === 'string'; if ( have_color ) { try { el.style.setProperty( '--wpbc-bfb-pulse-color', hex_color ); } catch ( _ ) {} meta.color_set = true; } UI._pulse_meta.set( el, meta ); var i = 0; (function tick() { var m = UI._pulse_meta.get( el ) || {}; if ( m.token !== token ) { // canceled/replaced return; } if ( i >= 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; i < n; i++ ) { if ( targetPct[i] < minPct[i] ) { locked[i] = true; lockedSum += minPct[i]; } } let remaining = availPct - lockedSum; const freeIdx = []; let freeTargetSum = 0; for ( let i = 0; i < n; i++ ) { if ( !locked[i] ) { freeIdx.push( i ); freeTargetSum += targetPct[i]; } } const result = new Array( n ).fill( 0 ); // Seed locked with their minima. for ( let i = 0; i < n; i++ ) { if ( locked[i] ) result[i] = minPct[i]; } if ( freeIdx.length === 0 ) { // everything locked exactly at min; any leftover (shouldn't happen) // would be ignored to keep simplicity and stability. return result; } if ( remaining <= 0 ) { // nothing left to distribute; keep exactly mins on locked, // nothing for free (degenerate but consistent) return result; } if ( freeTargetSum <= 0 ) { // distribute equally among free columns const each = remaining / freeIdx.length; freeIdx.forEach( (i) => (result[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:
, ,