// --------------------------------------------------------------------------------------------------------------------- // == 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 { /** * Synchronize every overlay settings button with the real Inspector state. * * A selected canvas item can remain selected while another right-bar panel is * visible. Combining both states prevents a stale pressed button after the * administrator switches away from the Inspector. * * @param {WPBC_Form_Builder} builder - Active Form Builder instance. * @param {HTMLButtonElement[]|NodeListOf|null} [settings_buttons=null] * Optional bounded controls to reconcile during incremental rendering. * @returns {void} */ static sync_settings_button_states(builder, settings_buttons = null) { const pages_container = builder?.pages_container; if ( !pages_container?.querySelectorAll ) { return; } const inspector = document.getElementById( 'wpbc_bfb__inspector' ); const inspector_is_open = !!inspector && !inspector.hasAttribute( 'hidden' ) && inspector.getAttribute( 'aria-hidden' ) !== 'true'; const selectable_query = `${Core.WPBC_BFB_DOM.SELECTORS.field}, ${Core.WPBC_BFB_DOM.SELECTORS.section}`; const buttons = settings_buttons || pages_container.querySelectorAll( '.wpbc_bfb__settings-btn' ); Array.from( buttons ).forEach( (settings_button) => { const owner = settings_button.closest( selectable_query ); const is_pressed = inspector_is_open && !!owner?.classList?.contains( Core.WPBC_BFB_DOM.CLASSES.selected ); settings_button.setAttribute( 'aria-pressed', is_pressed ? 'true' : 'false' ); } ); } /** * 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). let settings_btn = overlay.querySelector( '.wpbc_bfb__settings-btn' ); if ( !settings_btn ) { 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.setAttribute( 'aria-label', settings_btn.title ); settings_btn.setAttribute( 'aria-controls', 'wpbc_bfb__inspector' ); settings_btn.setAttribute( 'aria-pressed', 'false' ); 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' ); UI.WPBC_BFB_Overlay.sync_settings_button_states( builder, [ settings_btn ] ); 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 ); this._on_panel_shown = this._sync_settings_button_states.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 ); document.addEventListener( 'wpbc_bfb:panel_shown', this._on_panel_shown ); this._sync_settings_button_states(); } 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; } if ( this._on_panel_shown ) { document.removeEventListener( 'wpbc_bfb:panel_shown', this._on_panel_shown ); this._on_panel_shown = null; } } /** * Refresh overlay button state after selection or right-bar panel changes. * * @returns {void} */ _sync_settings_button_states() { UI.WPBC_BFB_Overlay.sync_settings_button_states( this.builder ); } /** * 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' } ); this._sync_settings_button_states(); 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' } ); this._sync_settings_button_states(); 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 { // An explicit empty value is the legacy persisted representation of false. ctrl.checked = Core.WPBC_BFB_Sanitize.coerce_boolean( raw, false ); } } 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') ) { // Persist both boolean states explicitly so schema defaults cannot replace false. nextVal = t.checked ? 'true' : 'false'; } 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:
, ,