Last active
April 14, 2026 03:43
-
-
Save arenagroove/8219300c211879c1f6eb5896b2e77924 to your computer and use it in GitHub Desktop.
LR ACF Quick Text Editor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin Name: LR ACF Quick Text Editor | |
| * Description: Lightweight meta box for editing ACF flexible content text fields directly in the post editor. Also supports root-level ACF fields outside flex content. Configurable via Settings > Quick Text Editor. | |
| * Author: Luis Martinez | |
| * Author URI: https://www.lessrain.com | |
| * Version: 7.3.3 | |
| * Requires PHP: 7.4 | |
| * | |
| * Changelog 7.3.3 (14/04/2026) | |
| * - Fix: post_types can now be saved as empty (all checkboxes unticked), | |
| * correctly disabling the plugin. Previously empty() fallback in | |
| * lr_acf_quick_settings() treated an explicit [] identically to | |
| * "never saved", restoring the 'page' default on every load. | |
| * - Fix: meta box registration now guarded with get_current_screen() base | |
| * check — prevents registration (and render attempt) on ACF options pages | |
| * which in some ACF versions fire add_meta_boxes on a 'page'-typed screen. | |
| * | |
| * Changelog 7.3.2 (13/04/2026) | |
| * - Fix: wysiwyg/textarea fields reverting when saved via native ACF editor — | |
| * TinyMCE entity encoding (&) and CRLF differences caused false dirty | |
| * detection. $norm() now applies html_entity_decode() + line ending | |
| * normalization before comparing submitted value against page-load original. | |
| * | |
| * Changelog 7.3.1 (12/04/2026) | |
| * - Fix: box visibility now uses field visibility exclusively — previously | |
| * empty filter string caused hidden boxes to reappear incorrectly in | |
| * dirty/empty modes. | |
| * - Fix: form submit calls guarded with null check on #post element. | |
| * - Fix: settings exclusion textareas now render newline-separated for | |
| * cleaner editing of long lists. | |
| * - Fix: filter callback now explicitly hides fields that fail dirty/empty | |
| * mode conditions rather than early-returning with stale display state. | |
| * - Fix: active filter reapplied on textarea input when dirty/empty mode | |
| * is active, preventing stale field visibility after live edits. | |
| * - UI: toolbar counter text truncates with ellipsis on narrow screens. | |
| * | |
| * Changelog 7.3.0 (12/04/2026) | |
| * - New: "Show empty" toolbar button — filters to fields with no content, | |
| * useful for translation and content audit workflows. Mutually exclusive | |
| * with "Show changed" mode. | |
| * - New: Expand all / Collapse all toolbar buttons. | |
| * - New: Toolbar counters — live display of total fields, empty, changed, hidden. | |
| * - New: Multi-line settings parsing — exclusion textareas now accept newlines | |
| * in addition to commas as separators. | |
| * - New: Reset to Defaults button in settings page. | |
| * | |
| * Changelog 7.2.2 (12/04/2026) | |
| * - Fix: sanitize_textarea_field() used for all textarea-sourced settings fields | |
| * (excluded_names, excluded_patterns, excluded_keys and root equivalents) — | |
| * sanitize_text_field() was stripping line breaks in multi-line textarea input. | |
| * - Fix: recursion depth guard added to lr_acf_quick_collect_candidates() — | |
| * prevents infinite recursion on pathological circular clone references. | |
| * | |
| * Changelog 7.2.1 (12/04/2026) | |
| * - UI: exclusion inputs (names, patterns, keys) changed from single-line text | |
| * inputs to resizable textareas for easier editing of long comma-separated lists. | |
| * | |
| * Changelog 7.2.0 (12/04/2026) | |
| * - Fix: save_post now runs at priority 20 with original-value tracking via hidden inputs, | |
| * preventing LR editor from overwriting ACF's own save (priority 10) when fields | |
| * are edited via the native ACF interface. | |
| * - Fix: column-aware meta key matching — clone-prefixed field names (col_1_field_name) | |
| * and regular group fields now resolve correctly without cross-column bleed. | |
| * - Fix: repeater row sort order — items now group by repeater row index first, | |
| * then by candidate order, instead of grouping all instances of each field together. | |
| * - Fix: fields never previously saved now render as empty editable fields via | |
| * synthetic meta key fallback. | |
| * - Fix: excluded_patterns with leading underscore (e.g. _ratio) now correctly | |
| * matches field names without the underscore prefix. | |
| * - Fix: default excluded_patterns/excluded_keys no longer overwritten by saved | |
| * empty arrays — array fields fall back to defaults explicitly. | |
| * - Fix: dedup uses key+parents to avoid dropping legitimate sibling fields | |
| * that share the same field name across different columns. | |
| * - New: excluded_keys — exclude flex fields by ACF field key. | |
| * - New: root_excluded_keys — exclude root fields by ACF field key. | |
| * - New: layouts with zero editable fields are silently skipped (no empty box shown), | |
| * unless debug mode is active. | |
| */ | |
| if ( ! defined( 'ABSPATH' ) ) exit; | |
| /* ============================================================================= | |
| DEFAULTS & SETTINGS | |
| ============================================================================= */ | |
| function lr_acf_quick_defaults(): array { | |
| return [ | |
| 'flex_field' => 'acf_lr_flexible_content', | |
| 'post_types' => [ 'page' ], | |
| 'excluded_names' => [ 'notes', 'inline_css_vars', 'inline_css', 'custom_css_class','section_class' ], | |
| 'excluded_patterns' => ['_ratio', '_width', '_terms','_intermittent_pattern','_scrub_start','_slides_per_view','_slides_per_view_md_down','_slides_per_view_sm_down'], | |
| 'excluded_keys' => ['field_649c39571ee2f_field_644c95ece22df'], | |
| 'allowed_types' => [ 'text', 'textarea', 'wysiwyg', 'url', 'email', 'number' ], | |
| 'debug_mode' => false, | |
| 'collapsed' => false, | |
| 'root_fields_enabled' => false, | |
| 'root_fields_placement' => 'same', | |
| 'root_excluded_names' => [ 'notes', 'inline_css_vars', 'inline_css', 'custom_css_class' ], | |
| 'root_excluded_patterns' => ['_ratio'], | |
| 'root_excluded_keys' => [], | |
| 'root_allowed_types' => [ 'text', 'textarea', 'wysiwyg', 'url', 'email', 'number' ], | |
| ]; | |
| } | |
| // Merge array fields explicitly. | |
| // Uses array_key_exists against $saved so an explicitly saved empty array | |
| // (e.g. post_types unchecked = plugin disabled) is preserved as-is and | |
| // never silently replaced by the default value. | |
| // Only fields that were NEVER saved (key absent from stored option) fall | |
| // back to defaults — covers fresh installs and newly added settings. | |
| function lr_acf_quick_settings(): array { | |
| $saved = get_option( 'lr_acf_quick_settings', null ); | |
| $defaults = lr_acf_quick_defaults(); | |
| // First install — nothing saved yet, return defaults immediately. | |
| if ( ! is_array( $saved ) ) { | |
| return $defaults; | |
| } | |
| $merged = wp_parse_args( $saved, $defaults ); | |
| $array_fields = [ | |
| 'excluded_patterns', | |
| 'excluded_keys', | |
| 'root_excluded_patterns', | |
| 'root_excluded_keys', | |
| 'excluded_names', | |
| 'root_excluded_names', | |
| 'allowed_types', | |
| 'root_allowed_types', | |
| 'post_types', | |
| ]; | |
| foreach ( $array_fields as $key ) { | |
| if ( ! array_key_exists( $key, $saved ) && ! empty( $defaults[ $key ] ) ) { | |
| $merged[ $key ] = $defaults[ $key ]; | |
| } | |
| } | |
| return $merged; | |
| } | |
| function lr_acf_quick_all_types(): array { | |
| return [ 'text', 'textarea', 'wysiwyg', 'url', 'email', 'number' ]; | |
| } | |
| /* ============================================================================= | |
| SETTINGS PAGE — REGISTRATION | |
| ============================================================================= */ | |
| add_action( 'admin_menu', function () { | |
| add_options_page( | |
| 'LR ACF Quick Text Editor', | |
| 'LR ACF Quick Text Editor', | |
| 'manage_options', | |
| 'lr-acf-quick-text', | |
| 'lr_acf_quick_settings_render' | |
| ); | |
| } ); | |
| /* ============================================================================= | |
| SETTINGS PAGE — SAVE | |
| ============================================================================= */ | |
| add_action( 'admin_init', function () { | |
| if ( ! isset( $_POST['lr_acf_quick_settings_nonce'] ) ) return; | |
| if ( ! wp_verify_nonce( $_POST['lr_acf_quick_settings_nonce'], 'lr_acf_quick_settings_save' ) ) return; | |
| if ( ! current_user_can( 'manage_options' ) ) return; | |
| // Reset defaults | |
| if ( isset( $_POST['lr_acf_reset_defaults'] ) ) { | |
| delete_option( 'lr_acf_quick_settings' ); | |
| wp_redirect( add_query_arg( [ 'page' => 'lr-acf-quick-text', 'lr_saved' => '2' ], admin_url( 'options-general.php' ) ) ); | |
| exit; | |
| } | |
| $all_types = lr_acf_quick_all_types(); | |
| $flex_field = sanitize_text_field( wp_unslash( $_POST['lr_acf_flex_field'] ?? '' ) ); | |
| $post_types = isset( $_POST['lr_acf_post_types'] ) && is_array( $_POST['lr_acf_post_types'] ) | |
| ? array_map( 'sanitize_key', wp_unslash( $_POST['lr_acf_post_types'] ) ) | |
| : []; | |
| $allowed_types = isset( $_POST['lr_acf_allowed_types'] ) && is_array( $_POST['lr_acf_allowed_types'] ) | |
| ? array_values( array_intersect( array_map( 'sanitize_key', wp_unslash( $_POST['lr_acf_allowed_types'] ) ), $all_types ) ) | |
| : $all_types; | |
| $split = function( $raw ) { | |
| return array_values( array_filter( array_map( 'trim', preg_split( '/[\n\r,]+/', sanitize_textarea_field( wp_unslash( $raw ) ) ) ) ) ); | |
| }; | |
| update_option( 'lr_acf_quick_settings', [ | |
| 'flex_field' => $flex_field, | |
| 'post_types' => $post_types, | |
| 'excluded_names' => $split( $_POST['lr_acf_excluded_names'] ?? '' ), | |
| 'excluded_patterns' => $split( $_POST['lr_acf_excluded_patterns'] ?? '' ), | |
| 'excluded_keys' => $split( $_POST['lr_acf_excluded_keys'] ?? '' ), | |
| 'allowed_types' => $allowed_types, | |
| 'debug_mode' => ! empty( $_POST['lr_acf_debug_mode'] ), | |
| 'collapsed' => ! empty( $_POST['lr_acf_collapsed'] ), | |
| 'root_fields_enabled' => ! empty( $_POST['lr_acf_root_fields_enabled'] ), | |
| 'root_fields_placement' => in_array( $_POST['lr_acf_root_fields_placement'] ?? '', [ 'same', 'separate' ], true ) | |
| ? $_POST['lr_acf_root_fields_placement'] | |
| : 'same', | |
| 'root_excluded_names' => $split( $_POST['lr_acf_root_excluded_names'] ?? '' ), | |
| 'root_excluded_patterns' => $split( $_POST['lr_acf_root_excluded_patterns'] ?? '' ), | |
| 'root_excluded_keys' => $split( $_POST['lr_acf_root_excluded_keys'] ?? '' ), | |
| 'root_allowed_types' => isset( $_POST['lr_acf_root_allowed_types'] ) && is_array( $_POST['lr_acf_root_allowed_types'] ) | |
| ? array_values( array_intersect( array_map( 'sanitize_key', wp_unslash( $_POST['lr_acf_root_allowed_types'] ) ), $all_types ) ) | |
| : $all_types, | |
| ] ); | |
| wp_redirect( add_query_arg( [ 'page' => 'lr-acf-quick-text', 'lr_saved' => '1' ], admin_url( 'options-general.php' ) ) ); | |
| exit; | |
| } ); | |
| /* ============================================================================= | |
| SETTINGS PAGE — RENDER | |
| ============================================================================= */ | |
| function lr_acf_quick_settings_render(): void { | |
| if ( ! current_user_can( 'manage_options' ) ) return; | |
| $s = lr_acf_quick_settings(); | |
| $all_types = lr_acf_quick_all_types(); | |
| $post_types = get_post_types( [ 'public' => true ], 'objects' ); | |
| ?> | |
| <div class="wrap lr-settings-wrap"> | |
| <h1>LR ACF Quick Text Editor — Settings</h1> | |
| <?php if ( ! empty( $_GET['lr_saved'] ) && $_GET['lr_saved'] === '1' ) : ?> | |
| <div class="notice notice-success is-dismissible"><p>Settings saved.</p></div> | |
| <?php elseif ( ! empty( $_GET['lr_saved'] ) && $_GET['lr_saved'] === '2' ) : ?> | |
| <div class="notice notice-success is-dismissible"><p>Settings reset to defaults.</p></div> | |
| <?php endif; ?> | |
| <form method="post" action=""> | |
| <?php wp_nonce_field( 'lr_acf_quick_settings_save', 'lr_acf_quick_settings_nonce' ); ?> | |
| <!-- FLEX FIELD --> | |
| <div class="lr-section"> | |
| <h2 class="lr-section-title">Flexible Content Field</h2> | |
| <p class="description">The ACF flexible content field name to target. Must match the field name (not label) in ACF.</p> | |
| <input type="text" | |
| name="lr_acf_flex_field" | |
| class="regular-text" | |
| value="<?php echo esc_attr( $s['flex_field'] ); ?>" | |
| placeholder="e.g. acf_lr_flexible_content"> | |
| </div> | |
| <!-- POST TYPES --> | |
| <div class="lr-section"> | |
| <h2 class="lr-section-title">Post Types</h2> | |
| <p class="description">Show the Quick Text Editor meta box on these post types.</p> | |
| <div class="lr-checks"> | |
| <?php foreach ( $post_types as $pt ) : ?> | |
| <label class="lr-check-label"> | |
| <input type="checkbox" name="lr_acf_post_types[]" | |
| value="<?php echo esc_attr( $pt->name ); ?>" | |
| <?php checked( in_array( $pt->name, $s['post_types'], true ) ); ?>> | |
| <span><?php echo esc_html( $pt->label ); ?></span> | |
| <code><?php echo esc_html( $pt->name ); ?></code> | |
| </label> | |
| <?php endforeach; ?> | |
| </div> | |
| </div> | |
| <!-- EXCLUDED NAMES --> | |
| <div class="lr-section"> | |
| <h2 class="lr-section-title">Excluded Field Names</h2> | |
| <p class="description">Sub-field names to always skip, regardless of type. Comma or newline separated.</p> | |
| <textarea | |
| name="lr_acf_excluded_names" | |
| class="large-text lr-excl-textarea" | |
| placeholder="e.g. notes, inline_css, custom_css_class" | |
| rows="2"><?php echo esc_textarea( implode( "\n", $s['excluded_names'] ) ); ?></textarea> | |
| <p class="description" style="margin-top:10px;">Excluded name patterns — comma or newline separated substrings or regex. Plain text = contains match. Wrap in <code>/…/</code> for regex.</p> | |
| <textarea | |
| name="lr_acf_excluded_patterns" | |
| class="large-text lr-excl-textarea" | |
| placeholder="e.g. _ratio, _colors, /^col_\d+/i" | |
| rows="2"><?php echo esc_textarea( implode( "\n", $s['excluded_patterns'] ) ); ?></textarea> | |
| <p class="description" style="margin-top:10px;">Excluded field keys — comma or newline separated ACF field keys.</p> | |
| <textarea | |
| name="lr_acf_excluded_keys" | |
| class="large-text lr-excl-textarea" | |
| placeholder="e.g. field_653b240a2b453" | |
| rows="2"><?php echo esc_textarea( implode( "\n", $s['excluded_keys'] ) ); ?></textarea> | |
| </div> | |
| <!-- ALLOWED TYPES --> | |
| <div class="lr-section"> | |
| <h2 class="lr-section-title">Allowed Field Types</h2> | |
| <p class="description">Only fields of these ACF types will appear in the editor.</p> | |
| <div class="lr-checks"> | |
| <?php foreach ( $all_types as $type ) : ?> | |
| <label class="lr-check-label"> | |
| <input type="checkbox" name="lr_acf_allowed_types[]" | |
| value="<?php echo esc_attr( $type ); ?>" | |
| <?php checked( in_array( $type, $s['allowed_types'], true ) ); ?>> | |
| <code><?php echo esc_html( $type ); ?></code> | |
| </label> | |
| <?php endforeach; ?> | |
| </div> | |
| </div> | |
| <!-- DISPLAY OPTIONS --> | |
| <div class="lr-section"> | |
| <h2 class="lr-section-title">Display Options</h2> | |
| <div class="lr-checks lr-checks--stack"> | |
| <label class="lr-check-label"> | |
| <input type="checkbox" name="lr_acf_collapsed" value="1" | |
| <?php checked( ! empty( $s['collapsed'] ) ); ?>> | |
| <span>Collapse layout rows by default</span> | |
| </label> | |
| <label class="lr-check-label"> | |
| <input type="checkbox" name="lr_acf_debug_mode" value="1" | |
| <?php checked( ! empty( $s['debug_mode'] ) ); ?>> | |
| <span>Enable debug mode</span> | |
| <em class="lr-hint">Shows raw schema info per layout row. Disable when not diagnosing.</em> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- ROOT FIELDS --> | |
| <div class="lr-section"> | |
| <h2 class="lr-section-title">Root ACF Fields</h2> | |
| <p class="description">Show non-flexible ACF fields assigned to this post — fields outside the flex content block. Values are resolved via ACF labels where available.</p> | |
| <div class="lr-checks lr-checks--stack"> | |
| <label class="lr-check-label"> | |
| <input type="checkbox" name="lr_acf_root_fields_enabled" value="1" | |
| id="lr_root_enabled" | |
| <?php checked( ! empty( $s['root_fields_enabled'] ) ); ?>> | |
| <span>Enable root fields editor</span> | |
| </label> | |
| <div class="lr-indent" id="lr_root_placement_wrap" <?php echo empty( $s['root_fields_enabled'] ) ? 'style="display:none"' : ''; ?>> | |
| <p class="description" style="margin-bottom:8px;">Where to show root fields:</p> | |
| <label class="lr-check-label"> | |
| <input type="radio" name="lr_acf_root_fields_placement" value="same" | |
| <?php checked( $s['root_fields_placement'], 'same' ); ?>> | |
| <span>Same meta box, below flex content</span> | |
| </label> | |
| <label class="lr-check-label" style="margin-top:6px;"> | |
| <input type="radio" name="lr_acf_root_fields_placement" value="separate" | |
| <?php checked( $s['root_fields_placement'], 'separate' ); ?>> | |
| <span>Separate meta box</span> | |
| </label> | |
| <hr style="margin:14px 0;border:none;border-top:1px solid #f0f0f0;"> | |
| <p class="description" style="margin-bottom:6px;"><strong>Excluded field names</strong> — comma or newline separated names to skip.</p> | |
| <textarea | |
| name="lr_acf_root_excluded_names" | |
| class="large-text lr-excl-textarea" | |
| placeholder="e.g. notes, inline_css, custom_css_class" | |
| rows="2"><?php echo esc_textarea( implode( "\n", $s['root_excluded_names'] ) ); ?></textarea> | |
| <p class="description" style="margin-top:10px;margin-bottom:6px;"><strong>Excluded name patterns</strong> — substrings or regex. Plain text = contains match. Wrap in <code>/…/</code> for regex.</p> | |
| <textarea | |
| name="lr_acf_root_excluded_patterns" | |
| class="large-text lr-excl-textarea" | |
| placeholder="e.g. _ratio, _colors, /^col_\d+/i" | |
| rows="2"><?php echo esc_textarea( implode( "\n", $s['root_excluded_patterns'] ) ); ?></textarea> | |
| <p class="description" style="margin-top:10px;margin-bottom:6px;"><strong>Excluded field keys</strong> — comma or newline separated ACF field keys.</p> | |
| <textarea | |
| name="lr_acf_root_excluded_keys" | |
| class="large-text lr-excl-textarea" | |
| placeholder="e.g. field_653b240a2b453" | |
| rows="2"><?php echo esc_textarea( implode( "\n", $s['root_excluded_keys'] ) ); ?></textarea> | |
| <p class="description" style="margin:12px 0 6px;"><strong>Allowed field types</strong></p> | |
| <div class="lr-checks" style="margin-top:0;"> | |
| <?php foreach ( $all_types as $type ) : ?> | |
| <label class="lr-check-label"> | |
| <input type="checkbox" name="lr_acf_root_allowed_types[]" | |
| value="<?php echo esc_attr( $type ); ?>" | |
| <?php checked( in_array( $type, $s['root_allowed_types'], true ) ); ?>> | |
| <code><?php echo esc_html( $type ); ?></code> | |
| </label> | |
| <?php endforeach; ?> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="lr-form-actions"> | |
| <?php submit_button( 'Save Settings', 'primary', 'submit', false ); ?> | |
| <button type="submit" name="lr_acf_reset_defaults" value="1" | |
| class="button button-secondary lr-btn-reset" | |
| onclick="return confirm('Reset all settings to defaults? This cannot be undone.');"> | |
| Reset to Defaults | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| <?php lr_acf_quick_settings_styles(); ?> | |
| <?php | |
| } | |
| /* ============================================================================= | |
| META BOX — REGISTRATION | |
| ============================================================================= */ | |
| add_action( 'add_meta_boxes', function () { | |
| $s = lr_acf_quick_settings(); | |
| $post_types = ! empty( $s['post_types'] ) ? $s['post_types'] : []; | |
| if ( empty( $post_types ) ) return; | |
| // Guard: don't register on ACF options pages or any non-post screen. | |
| $screen = get_current_screen(); | |
| if ( ! $screen || $screen->base !== 'post' ) return; | |
| add_meta_box( | |
| 'lr_acf_quick_text', | |
| 'Quick Text Editor', | |
| 'lr_acf_quick_render', | |
| $post_types, | |
| 'normal', | |
| 'low' | |
| ); | |
| if ( ! empty( $s['root_fields_enabled'] ) && $s['root_fields_placement'] === 'separate' ) { | |
| add_meta_box( | |
| 'lr_acf_quick_root', | |
| 'Quick Text Editor — Root Fields', | |
| 'lr_acf_quick_render_root_box', | |
| $post_types, | |
| 'normal', | |
| 'low' | |
| ); | |
| } | |
| } ); | |
| /* ============================================================================= | |
| PATTERN MATCHER | |
| Tests a field name against a list of substrings or regex patterns. | |
| Plain text = contains match. /pattern/flags = regex match. | |
| ============================================================================= */ | |
| function lr_acf_quick_matches_pattern( string $name, array $patterns ): bool { | |
| foreach ( $patterns as $pattern ) { | |
| if ( $pattern === '' ) continue; | |
| if ( isset( $pattern[0] ) && $pattern[0] === '/' ) { | |
| if ( @preg_match( $pattern, $name ) ) return true; | |
| } else { | |
| $stripped = ltrim( $pattern, '_' ); | |
| if ( strpos( $name, $pattern ) !== false ) return true; | |
| if ( $stripped !== $pattern && strpos( $name, $stripped ) !== false ) return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /* ============================================================================= | |
| SCHEMA TRAVERSAL | |
| Walks field definitions recursively to collect all editable leaf fields. | |
| Discovers definitions only — no value reading happens here. | |
| ============================================================================= */ | |
| function lr_acf_quick_collect_candidates( array $fields, array $s, array $parents = [], int $depth = 0 ): array { | |
| if ( $depth > 15 ) return []; | |
| $out = []; | |
| static $acf_field_cache = []; | |
| foreach ( $fields as $field ) { | |
| $name = $field['name'] ?? ''; | |
| $label = $field['label'] ?? $name; | |
| $type = $field['type'] ?? ''; | |
| $key = $field['key'] ?? ''; | |
| if ( $name !== '' && in_array( $name, $s['excluded_names'], true ) ) continue; | |
| if ( $name !== '' && lr_acf_quick_matches_pattern( $name, $s['excluded_patterns'] ?? [] ) ) continue; | |
| if ( $key !== '' && in_array( $key, $s['excluded_keys'] ?? [], true ) ) continue; | |
| $current_parents = $parents; | |
| if ( $name !== '' ) { | |
| $current_parents[] = $name; | |
| } | |
| if ( in_array( $type, $s['allowed_types'], true ) ) { | |
| $out[] = [ | |
| 'label' => (string) $label, | |
| 'name' => (string) $name, | |
| 'key' => (string) $key, | |
| 'type' => (string) $type, | |
| 'parents' => $parents, | |
| ]; | |
| } | |
| if ( ! empty( $field['sub_fields'] ) && is_array( $field['sub_fields'] ) ) { | |
| $out = array_merge( $out, lr_acf_quick_collect_candidates( $field['sub_fields'], $s, $current_parents, $depth + 1 ) ); | |
| } | |
| if ( ! empty( $field['layouts'] ) && is_array( $field['layouts'] ) ) { | |
| foreach ( $field['layouts'] as $layout ) { | |
| if ( ! empty( $layout['sub_fields'] ) ) { | |
| $out = array_merge( $out, lr_acf_quick_collect_candidates( $layout['sub_fields'], $s, $current_parents, $depth + 1 ) ); | |
| } | |
| } | |
| } | |
| if ( ! empty( $field['clone'] ) && is_array( $field['clone'] ) && function_exists( 'acf_get_field' ) ) { | |
| foreach ( $field['clone'] as $cloned_key ) { | |
| if ( ! isset( $acf_field_cache[ $cloned_key ] ) ) { | |
| $acf_field_cache[ $cloned_key ] = acf_get_field( $cloned_key ); | |
| } | |
| $cloned = $acf_field_cache[ $cloned_key ]; | |
| if ( $cloned ) { | |
| $out = array_merge( $out, lr_acf_quick_collect_candidates( [ $cloned ], $s, $current_parents, $depth + 1 ) ); | |
| } | |
| } | |
| } | |
| } | |
| return $out; | |
| } | |
| /* ============================================================================= | |
| META KEY MAP | |
| Reads ALL post meta once and builds: field_key => [ meta_key => value ] | |
| ACF stores _meta_key = field_key reference entries — we reverse this. | |
| ============================================================================= */ | |
| function lr_acf_quick_build_key_map( int $post_id ): array { | |
| $s = lr_acf_quick_settings(); | |
| $flex = $s['flex_field']; | |
| $all_meta = get_post_meta( $post_id ); | |
| $map = []; | |
| foreach ( $all_meta as $meta_key => $values ) { | |
| if ( empty( $meta_key ) || $meta_key[0] !== '_' ) continue; | |
| $field_key = isset( $values[0] ) ? $values[0] : ''; | |
| if ( strpos( $field_key, 'field_' ) !== 0 ) continue; | |
| $base_key = substr( $meta_key, 1 ); | |
| if ( ! isset( $all_meta[ $base_key ] ) ) continue; | |
| if ( ! empty( $flex ) && empty( $s['root_fields_enabled'] ) ) { | |
| if ( strpos( $base_key, $flex . '_' ) !== 0 ) continue; | |
| } | |
| $map[ $field_key ][ $base_key ] = isset( $all_meta[ $base_key ][0] ) ? $all_meta[ $base_key ][0] : ''; | |
| } | |
| return $map; | |
| } | |
| /* ============================================================================= | |
| KEY LOOKUP | |
| Handles composite clone keys by falling back to the final segment. | |
| e.g. field_aaa_field_bbb_field_ccc → field_ccc | |
| ============================================================================= */ | |
| function lr_acf_quick_lookup( array $key_map, string $field_key ): array { | |
| if ( isset( $key_map[ $field_key ] ) ) { | |
| return $key_map[ $field_key ]; | |
| } | |
| if ( substr_count( $field_key, 'field_' ) > 1 ) { | |
| $segments = explode( '_field_', $field_key ); | |
| $final_key = 'field_' . end( $segments ); | |
| if ( isset( $key_map[ $final_key ] ) ) { | |
| return $key_map[ $final_key ]; | |
| } | |
| } | |
| return []; | |
| } | |
| /* ============================================================================= | |
| META BOX — RENDER | |
| ============================================================================= */ | |
| function lr_acf_quick_render( WP_Post $post ): void { | |
| if ( ! function_exists( 'have_rows' ) || ! function_exists( 'acf_get_field' ) ) { | |
| echo '<p class="lr-notice">ACF is not active.</p>'; | |
| return; | |
| } | |
| $s = lr_acf_quick_settings(); | |
| $flex = $s['flex_field']; | |
| if ( empty( $flex ) ) { | |
| echo '<p class="lr-notice">No flex field configured. <a href="' . esc_url( admin_url( 'options-general.php?page=lr-acf-quick-text' ) ) . '">Open Settings</a></p>'; | |
| return; | |
| } | |
| $field = acf_get_field( $flex ); | |
| if ( ! $field || empty( $field['layouts'] ) ) { | |
| echo '<p class="lr-notice">Flexible content field <code>' . esc_html( $flex ) . '</code> not found.</p>'; | |
| return; | |
| } | |
| if ( ! have_rows( $flex, $post->ID ) ) { | |
| echo '<p class="lr-notice">No rows in this flexible content field.</p>'; | |
| return; | |
| } | |
| $key_map = lr_acf_quick_build_key_map( $post->ID ); | |
| wp_nonce_field( 'lr_acf_quick_save', 'lr_acf_quick_nonce' ); | |
| echo '<div class="lr-toolbar">'; | |
| echo '<button type="button" id="lr_refresh_btn" class="button button-secondary">↻ Save & Refresh</button>'; | |
| echo '<span id="lr_dirty_count" class="lr-dirty-count" style="display:none"></span>'; | |
| echo '<button type="button" id="lr_filter_dirty" class="button lr-btn-dirty" style="display:none">Show changed</button>'; | |
| echo '<button type="button" id="lr_filter_empty" class="button lr-btn-dirty">Show empty</button>'; | |
| echo '<button type="button" id="lr_expand_all" class="button lr-btn-utility">Expand all</button>'; | |
| echo '<button type="button" id="lr_collapse_all" class="button lr-btn-utility">Collapse all</button>'; | |
| echo '<input type="text" id="lr_filter" class="lr-filter" placeholder="Filter fields…">'; | |
| echo '<span id="lr_toolbar_counts" class="lr-toolbar-counts"></span>'; | |
| $settings_url = admin_url( 'options-general.php?page=lr-acf-quick-text' ); | |
| echo '<a href="' . esc_url( $settings_url ) . '" class="lr-settings-link" title="Quick Text Editor Settings">⚙</a>'; | |
| echo '</div>'; | |
| echo '<div class="lr-qte-wrap">'; | |
| $row_index = 0; | |
| $layout_index = []; | |
| $open_attr = empty( $s['collapsed'] ) ? ' open' : ''; | |
| while ( have_rows( $flex, $post->ID ) ) { | |
| the_row(); | |
| $row_index++; | |
| $layout = get_row_layout(); | |
| $layout_index[ $layout ] = ( $layout_index[ $layout ] ?? 0 ) + 1; | |
| $layout_def = null; | |
| foreach ( $field['layouts'] as $def ) { | |
| if ( isset( $def['name'] ) && $def['name'] === $layout ) { | |
| $layout_def = $def; | |
| break; | |
| } | |
| } | |
| if ( ! $layout_def ) continue; | |
| $row_prefix = $flex . '_' . ( $row_index - 1 ) . '_'; | |
| $candidates = lr_acf_quick_collect_candidates( $layout_def['sub_fields'] ?? [], $s ); | |
| $unique_candidates = []; | |
| $seen_keys = []; | |
| foreach ( $candidates as $c ) { | |
| $dedup_key = $c['key'] . '|' . implode( '|', $c['parents'] ); | |
| if ( ! isset( $seen_keys[ $dedup_key ] ) ) { | |
| $unique_candidates[] = $c; | |
| $seen_keys[ $dedup_key ] = true; | |
| } | |
| } | |
| $items = []; | |
| $rendered_meta_keys = []; | |
| foreach ( $unique_candidates as $cand_index => $candidate ) { | |
| $meta_entries = lr_acf_quick_lookup( $key_map, $candidate['key'] ); | |
| $col = ! empty( $candidate['parents'] ) ? $candidate['parents'][0] : ''; | |
| $col_prefix = $row_prefix . ( $col !== '' ? $col . '_' : '' ); | |
| $match_name = $candidate['name']; | |
| if ( $col !== '' && strpos( $match_name, $col . '_' ) === 0 ) { | |
| $match_name = substr( $match_name, strlen( $col ) + 1 ); | |
| } | |
| $name_rx = '/^(?:\d+_)?' . preg_quote( $match_name, '/' ) . '$/'; | |
| $row_instances = []; | |
| foreach ( $meta_entries as $meta_key => $value ) { | |
| if ( strpos( $meta_key, $col_prefix ) !== 0 ) continue; | |
| $suffix = substr( $meta_key, strlen( $col_prefix ) ); | |
| if ( ! preg_match( $name_rx, $suffix ) ) continue; | |
| $row_instances[ $meta_key ] = $value; | |
| } | |
| ksort( $row_instances ); | |
| if ( empty( $row_instances ) ) { | |
| $synthetic_key = $col_prefix . $match_name; | |
| $row_instances[ $synthetic_key ] = ''; | |
| } | |
| $instance_count = count( $row_instances ); | |
| $instance_num = 0; | |
| foreach ( $row_instances as $meta_key => $value ) { | |
| if ( isset( $rendered_meta_keys[ $meta_key ] ) ) continue; | |
| if ( ! is_string( $value ) ) continue; | |
| $rendered_meta_keys[ $meta_key ] = true; | |
| $instance_num++; | |
| $rep_index = 0; | |
| if ( $col !== '' ) { | |
| $after_prefix = substr( $meta_key, strlen( $row_prefix ) ); | |
| if ( preg_match( '/^' . preg_quote( $col, '/' ) . '_(\d+)_/', $after_prefix, $m ) ) { | |
| $rep_index = (int) $m[1]; | |
| } | |
| } | |
| $label = $candidate['label']; | |
| if ( ! empty( $candidate['parents'] ) ) { | |
| $label = implode( ' / ', $candidate['parents'] ) . ' / ' . $label; | |
| } | |
| if ( $instance_count > 1 ) { | |
| $label .= ' #' . $instance_num; | |
| } | |
| $items[] = [ | |
| 'input_name' => 'lr_acf_quick_meta[' . esc_attr( $meta_key ) . ']', | |
| 'label' => $label, | |
| 'meta' => esc_html( $candidate['name'] ) . ' · ' . esc_html( $candidate['type'] ), | |
| 'meta_key' => $meta_key, | |
| 'type' => $candidate['type'], | |
| 'value' => $value, | |
| 'empty' => trim( (string) $value ) === '', | |
| 'rep_index' => $rep_index, | |
| 'cand_index' => $cand_index, | |
| ]; | |
| } | |
| } | |
| usort( $items, function( $a, $b ) { | |
| if ( $a['rep_index'] !== $b['rep_index'] ) { | |
| return $a['rep_index'] <=> $b['rep_index']; | |
| } | |
| return $a['cand_index'] <=> $b['cand_index']; | |
| } ); | |
| $heading = esc_html( $layout_def['label'] ?? $layout ); | |
| $heading .= ' <span class="lr-row-index">#' . $layout_index[ $layout ] . ' — row ' . $row_index . '</span>'; | |
| if ( ! $items ) continue; | |
| echo '<details class="lr-box"' . $open_attr . '>'; | |
| echo '<summary class="lr-box-header">' . $heading . '</summary>'; | |
| echo '<div class="lr-box-body">'; | |
| foreach ( $items as $item ) { | |
| lr_acf_quick_render_field_item( $item, $s ); | |
| } | |
| if ( ! empty( $s['debug_mode'] ) ) { | |
| echo '<details class="lr-debug">'; | |
| echo '<summary class="lr-debug-summary">🔍 Debug: schema candidates for this layout</summary>'; | |
| echo '<table class="lr-debug-table">'; | |
| echo '<thead><tr><th>Label</th><th>Name</th><th>Type</th><th>Key</th><th>Parents</th></tr></thead><tbody>'; | |
| foreach ( $unique_candidates as $c ) { | |
| echo '<tr>'; | |
| echo '<td>' . esc_html( $c['label'] ) . '</td>'; | |
| echo '<td><code>' . esc_html( $c['name'] ) . '</code></td>'; | |
| echo '<td><code>' . esc_html( $c['type'] ) . '</code></td>'; | |
| echo '<td><code>' . esc_html( $c['key'] ) . '</code></td>'; | |
| echo '<td><code>' . esc_html( implode( ' → ', $c['parents'] ) ?: '—' ) . '</code></td>'; | |
| echo '</tr>'; | |
| } | |
| echo '</tbody></table></details>'; | |
| } | |
| echo '</div></details>'; | |
| } | |
| echo '</div>'; | |
| if ( ! empty( $s['root_fields_enabled'] ) && $s['root_fields_placement'] === 'same' ) { | |
| lr_acf_quick_render_root_fields( $post->ID, $key_map, $s ); | |
| } | |
| lr_acf_quick_meta_styles(); | |
| lr_acf_quick_meta_scripts(); | |
| } | |
| /* ============================================================================= | |
| ROOT FIELDS — COLLECTOR | |
| Iterates the key_map and returns all ACF fields NOT inside the flex field. | |
| Labels are resolved via acf_get_field(); meta key is used as fallback. | |
| Only allowed types are included; excluded names and patterns are respected. | |
| ============================================================================= */ | |
| function lr_acf_quick_collect_root_fields( array $key_map, array $s ): array { | |
| $items = []; | |
| $flex = $s['flex_field']; | |
| $seen = []; | |
| static $field_cache = []; | |
| foreach ( $key_map as $field_key => $entries ) { | |
| if ( ! array_key_exists( $field_key, $field_cache ) ) { | |
| $resolved = null; | |
| if ( function_exists( 'acf_get_field' ) ) { | |
| $resolved = acf_get_field( $field_key ); | |
| if ( ! $resolved && substr_count( $field_key, 'field_' ) > 1 ) { | |
| $segments = explode( '_field_', $field_key ); | |
| $resolved = acf_get_field( 'field_' . end( $segments ) ); | |
| } | |
| } | |
| $field_cache[ $field_key ] = $resolved; | |
| } | |
| $field_def = $field_cache[ $field_key ]; | |
| if ( ! $field_def ) continue; | |
| $label = $field_def['label'] ?? ''; | |
| $type = $field_def['type'] ?? ''; | |
| $field_name = $field_def['name'] ?? ''; | |
| $parent = $field_def['parent'] ?? ''; | |
| $group_label = 'Ungrouped'; | |
| if ( $parent && function_exists( 'acf_get_field_group' ) ) { | |
| static $group_cache = []; | |
| if ( ! array_key_exists( $parent, $group_cache ) ) { | |
| $group = acf_get_field_group( $parent ); | |
| $group_cache[ $parent ] = ( $group && ! empty( $group['title'] ) ) ? $group['title'] : 'Ungrouped'; | |
| } | |
| $group_label = $group_cache[ $parent ]; | |
| } | |
| if ( ! $type || ! in_array( $type, $s['root_allowed_types'], true ) ) continue; | |
| if ( $field_name && in_array( $field_name, $s['root_excluded_names'], true ) ) continue; | |
| if ( $field_name && lr_acf_quick_matches_pattern( $field_name, $s['root_excluded_patterns'] ?? [] ) ) continue; | |
| if ( $field_key && in_array( $field_key, $s['root_excluded_keys'] ?? [], true ) ) continue; | |
| $valid_entries = []; | |
| foreach ( $entries as $meta_key => $value ) { | |
| if ( ! empty( $flex ) && strpos( $meta_key, $flex . '_' ) === 0 ) continue; | |
| if ( ! is_string( $value ) ) continue; | |
| if ( isset( $seen[ $meta_key ] ) ) continue; | |
| $valid_entries[ $meta_key ] = $value; | |
| } | |
| if ( empty( $valid_entries ) ) continue; | |
| $entry_count = count( $valid_entries ); | |
| $instance_num = 0; | |
| foreach ( $valid_entries as $meta_key => $value ) { | |
| $seen[ $meta_key ] = true; | |
| $instance_num++; | |
| $display_label = $label ?: ( $field_name ?: $meta_key ); | |
| if ( $entry_count > 1 ) { | |
| $display_label .= ' #' . $instance_num; | |
| } | |
| $items[] = [ | |
| 'input_name' => 'lr_acf_quick_meta[' . esc_attr( $meta_key ) . ']', | |
| 'label' => $display_label, | |
| 'meta' => esc_html( $field_name ?: $meta_key ) . ' · ' . esc_html( $type ), | |
| 'meta_key' => $meta_key, | |
| 'field_key' => $field_key, | |
| 'field_name' => $field_name, | |
| 'type' => $type, | |
| 'group_label' => $group_label, | |
| 'value' => $value, | |
| 'empty' => trim( $value ) === '', | |
| ]; | |
| } | |
| } | |
| usort( $items, function ( $a, $b ) { | |
| $g = strcmp( strtolower( $a['group_label'] ), strtolower( $b['group_label'] ) ); | |
| return $g !== 0 ? $g : strcmp( strtolower( $a['label'] ), strtolower( $b['label'] ) ); | |
| } ); | |
| return $items; | |
| } | |
| /* ============================================================================= | |
| ROOT FIELDS — RENDER HELPER | |
| Shared by both inline (same box) and separate meta box rendering. | |
| ============================================================================= */ | |
| function lr_acf_quick_render_root_fields( int $post_id, array $key_map, array $s ): void { | |
| $items = lr_acf_quick_collect_root_fields( $key_map, $s ); | |
| $open_attr = empty( $s['collapsed'] ) ? ' open' : ''; | |
| echo '<details class="lr-box lr-box--root"' . $open_attr . '>'; | |
| echo '<summary class="lr-box-header">Root Fields <span class="lr-row-index">ACF fields outside flexible content</span></summary>'; | |
| echo '<div class="lr-box-body">'; | |
| if ( $items ) { | |
| $groups = []; | |
| foreach ( $items as $item ) { | |
| $groups[ $item['group_label'] ][] = $item; | |
| } | |
| $multiple_groups = count( $groups ) > 1; | |
| foreach ( $groups as $group_title => $group_items ) { | |
| if ( $multiple_groups ) { | |
| echo '<details class="lr-root-group" open>'; | |
| echo '<summary class="lr-root-group-title">' . esc_html( $group_title ) . '</summary>'; | |
| echo '<div class="lr-root-group-body">'; | |
| } | |
| foreach ( $group_items as $item ) { | |
| lr_acf_quick_render_field_item( $item, $s ); | |
| } | |
| if ( $multiple_groups ) { | |
| echo '</div></details>'; | |
| } | |
| } | |
| } else { | |
| echo '<p class="lr-empty">No editable root ACF fields found on this post.</p>'; | |
| } | |
| if ( ! empty( $s['debug_mode'] ) ) { | |
| echo '<details class="lr-debug">'; | |
| echo '<summary class="lr-debug-summary">🔍 Debug: root field map</summary>'; | |
| echo '<table class="lr-debug-table">'; | |
| echo '<thead><tr><th>Group</th><th>Label</th><th>Field Name</th><th>Type</th><th>Field Key</th><th>Meta Key</th></tr></thead><tbody>'; | |
| foreach ( $items as $item ) { | |
| echo '<tr>'; | |
| echo '<td>' . esc_html( $item['group_label'] ) . '</td>'; | |
| echo '<td>' . esc_html( $item['label'] ) . '</td>'; | |
| echo '<td><code>' . esc_html( $item['field_name'] ) . '</code></td>'; | |
| echo '<td><code>' . esc_html( $item['type'] ) . '</code></td>'; | |
| echo '<td><code>' . esc_html( $item['field_key'] ) . '</code></td>'; | |
| echo '<td><code>' . esc_html( $item['meta_key'] ) . '</code></td>'; | |
| echo '</tr>'; | |
| } | |
| echo '</tbody></table></details>'; | |
| } | |
| echo '</div></details>'; | |
| } | |
| /* ============================================================================= | |
| ROOT FIELDS — SEPARATE META BOX CALLBACK | |
| ============================================================================= */ | |
| function lr_acf_quick_render_root_box( WP_Post $post ): void { | |
| if ( ! function_exists( 'acf_get_field' ) ) { | |
| echo '<p class="lr-notice">ACF is not active.</p>'; | |
| return; | |
| } | |
| $s = lr_acf_quick_settings(); | |
| $key_map = lr_acf_quick_build_key_map( $post->ID ); | |
| wp_nonce_field( 'lr_acf_quick_save', 'lr_acf_quick_nonce' ); | |
| echo '<div class="lr-toolbar">'; | |
| echo '<button type="button" id="lr_refresh_btn_root" class="button button-secondary">↻ Save & Refresh</button>'; | |
| echo '<input type="text" id="lr_filter_root" class="lr-filter" placeholder="Filter fields…">'; | |
| echo '</div>'; | |
| echo '<div class="lr-qte-wrap">'; | |
| lr_acf_quick_render_root_fields( $post->ID, $key_map, $s ); | |
| echo '</div>'; | |
| lr_acf_quick_meta_styles(); | |
| echo '<script> | |
| document.addEventListener("DOMContentLoaded", function() { | |
| var btn = document.getElementById("lr_refresh_btn_root"); | |
| if (btn) btn.addEventListener("click", function(e) { | |
| e.preventDefault(); | |
| document.getElementById("post").submit(); | |
| }); | |
| var filter = document.getElementById("lr_filter_root"); | |
| if (filter) { | |
| document.querySelectorAll("#lr_acf_quick_root .lr-field").forEach(function(el) { | |
| el.dataset.search = el.textContent.toLowerCase(); | |
| }); | |
| filter.addEventListener("input", function() { | |
| var v = this.value.toLowerCase(); | |
| document.querySelectorAll("#lr_acf_quick_root .lr-field").forEach(function(el) { | |
| el.style.display = el.dataset.search.includes(v) ? "" : "none"; | |
| }); | |
| }); | |
| } | |
| }); | |
| </script>'; | |
| } | |
| /* ============================================================================= | |
| FIELD ITEM RENDERER (update 11/04/2026) | |
| Shared helper — renders a single field textarea with label and meta. | |
| ============================================================================= */ | |
| function lr_acf_quick_render_field_item( array $item, array $s ): void { | |
| $rows_attr = $item['type'] === 'text' ? 2 : 5; | |
| echo '<div class="lr-field lr-type-' . esc_attr( $item['type'] ) . ( $item['empty'] ? ' lr-field--empty' : '' ) . '">'; | |
| echo '<label class="lr-field-label">'; | |
| echo '<span class="lr-label-text">' . esc_html( $item['label'] ) . '</span>'; | |
| echo '<span class="lr-field-meta">' . $item['meta'] . '</span>'; | |
| echo '</label>'; | |
| if ( ! empty( $s['debug_mode'] ) ) { | |
| echo '<div class="lr-meta-key">' . esc_html( $item['meta_key'] ) . '</div>'; | |
| } | |
| echo '<input type="hidden" name="lr_acf_quick_orig[' . esc_attr( $item['meta_key'] ) . ']" value="' . esc_attr( $item['value'] ) . '">'; | |
| echo '<textarea name="' . $item['input_name'] . '" class="lr-textarea" rows="' . $rows_attr . '">' | |
| . esc_textarea( $item['value'] ) | |
| . '</textarea>'; | |
| echo '</div>'; | |
| } | |
| /* ============================================================================= | |
| SAVE | |
| ============================================================================= */ | |
| add_action( 'save_post', function ( $post_id ) { | |
| if ( ! isset( $_POST['lr_acf_quick_nonce'] ) ) return; | |
| if ( ! wp_verify_nonce( $_POST['lr_acf_quick_nonce'], 'lr_acf_quick_save' ) ) return; | |
| if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return; | |
| if ( wp_is_post_revision( $post_id ) ) return; | |
| if ( ! current_user_can( 'edit_post', $post_id ) ) return; | |
| if ( empty( $_POST['lr_acf_quick_meta'] ) || ! is_array( $_POST['lr_acf_quick_meta'] ) ) return; | |
| $s = lr_acf_quick_settings(); | |
| $flex = $s['flex_field']; | |
| $originals = isset( $_POST['lr_acf_quick_orig'] ) && is_array( $_POST['lr_acf_quick_orig'] ) | |
| ? wp_unslash( $_POST['lr_acf_quick_orig'] ) | |
| : []; | |
| $norm = function( $v ) { | |
| $v = str_replace( [ "\r\n", "\r" ], "\n", (string) $v ); | |
| return html_entity_decode( $v, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); | |
| }; | |
| foreach ( $_POST['lr_acf_quick_meta'] as $meta_key => $value ) { | |
| if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $meta_key ) ) continue; | |
| if ( isset( $meta_key[0] ) && $meta_key[0] === '_' ) continue; | |
| $is_flex_key = ! empty( $flex ) && strpos( $meta_key, $flex . '_' ) === 0; | |
| if ( ! $is_flex_key && empty( $s['root_fields_enabled'] ) ) continue; | |
| $value = wp_unslash( $value ); | |
| if ( ! array_key_exists( $meta_key, $originals ) ) continue; | |
| if ( $norm( $value ) === $norm( $originals[ $meta_key ] ) ) continue; | |
| update_post_meta( $post_id, $meta_key, $value ); | |
| } | |
| }, 20 ); | |
| /* ============================================================================= | |
| META BOX STYLES | |
| ============================================================================= */ | |
| function lr_acf_quick_meta_styles(): void { | |
| echo '<style> | |
| .lr-qte-wrap { margin-top: 12px; } | |
| .lr-toolbar { | |
| position: sticky; | |
| top: 32px; | |
| z-index: 10; | |
| background: #fff; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 14px; | |
| padding: 8px 0 8px; | |
| border-bottom: 1px solid #f0f0f0; | |
| } | |
| .lr-filter { | |
| flex: 1; | |
| max-width: 220px; | |
| font-size: 12px; | |
| height: 30px; | |
| padding: 0 8px; | |
| border: 1px solid #dcdcde; | |
| border-radius: 3px; | |
| } | |
| .lr-filter:focus { border-color: #9ec2e6; outline: none; box-shadow: 0 0 0 1px #9ec2e6; } | |
| .lr-toolbar-counts { | |
| font-size: 11px; | |
| color: #888; | |
| flex: 1; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| min-width: 0; | |
| } | |
| .lr-settings-link { | |
| font-size: 16px; | |
| color: #999; | |
| text-decoration: none; | |
| line-height: 1; | |
| } | |
| .lr-settings-link:hover { color: #333; } | |
| .lr-box { | |
| margin-bottom: 12px; | |
| background: #fff; | |
| border: 1px solid #dcdcde; | |
| border-left: 4px solid #dcdcde; | |
| border-radius: 3px; | |
| transition: border-color 0.2s ease; | |
| } | |
| .lr-box[open] { border-left-color: #2271b1; } | |
| .lr-box-header { | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 14px 10px 26px; | |
| font-weight: 600; | |
| font-size: 13px; | |
| cursor: pointer; | |
| background: #f6f7f7; | |
| border-bottom: 1px solid #dcdcde; | |
| list-style: none; | |
| user-select: none; | |
| } | |
| .lr-box-header:hover { background: #eef4f9; } | |
| .lr-box-header::before { | |
| content: "▸"; | |
| position: absolute; | |
| left: 10px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-size: 10px; | |
| color: #888; | |
| transition: transform 0.15s ease; | |
| } | |
| .lr-box[open] > .lr-box-header::before { transform: translateY(-50%) rotate(90deg); } | |
| .lr-row-index { font-weight: 400; color: #888; font-size: 11px; } | |
| .lr-box-body { padding: 14px; background: #fff; } | |
| .lr-root-group { | |
| margin-bottom: 6px; | |
| margin-left: 8px; | |
| border: 1px solid #f0f0f0; | |
| border-left: 3px solid #eee; | |
| border-radius: 3px; | |
| } | |
| .lr-root-group[open] { border-left-color: #999; } | |
| .lr-root-group-title { | |
| position: relative; | |
| display: block; | |
| width: 100%; | |
| font-size: 11px; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| color: #666; | |
| padding: 8px 12px 8px 22px; | |
| background: #fafafa; | |
| cursor: pointer; | |
| user-select: none; | |
| list-style: none; | |
| } | |
| .lr-root-group-title:hover { background: #eef4f9; } | |
| .lr-root-group-title::before { | |
| content: "▸"; | |
| position: absolute; | |
| left: 8px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| font-size: 10px; | |
| color: #888; | |
| transition: transform 0.15s ease; | |
| } | |
| .lr-root-group[open] > .lr-root-group-title::before { transform: translateY(-50%) rotate(90deg); } | |
| .lr-root-group-body { padding: 14px; background: #fafafa; } | |
| .lr-field { | |
| margin-bottom: 14px; | |
| margin-left: 14px; | |
| background: #fff; | |
| } | |
| .lr-field:last-child { margin-bottom: 0; } | |
| .lr-field-label { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| margin-bottom: 5px; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .lr-label-text { font-weight: 600; font-size: 12px; } | |
| .lr-field-meta { font-size: 10px; color: #aaa; font-family: monospace; } | |
| .lr-textarea { | |
| width: 100%; | |
| font-family: monospace; | |
| font-size: 12px; | |
| resize: vertical; | |
| box-sizing: border-box; | |
| } | |
| .lr-excl-textarea { | |
| font-family: monospace; | |
| font-size: 12px; | |
| min-height: 52px; | |
| resize: vertical; | |
| line-height: 1.5; | |
| padding: 6px 8px; | |
| box-sizing: border-box; | |
| } | |
| .lr-type-text .lr-textarea { min-height: 42px; } | |
| .lr-type-textarea .lr-textarea, | |
| .lr-type-wysiwyg .lr-textarea { min-height: 100px; } | |
| .lr-field--empty .lr-textarea { background: #fff8f8; } | |
| .lr-field--dirty .lr-textarea { background: #fffbe6; } | |
| .lr-dirty-count { | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: #b45309; | |
| background: #fef3c7; | |
| border: 1px solid #fcd34d; | |
| border-radius: 3px; | |
| padding: 2px 8px; | |
| white-space: nowrap; | |
| } | |
| .lr-dirty-count--saving { | |
| color: #1e6a1e; | |
| background: #f0fdf0; | |
| border-color: #86efac; | |
| } | |
| .lr-btn-dirty { font-size: 11px !important; height: 26px; line-height: 24px; padding: 0 8px !important; } | |
| .lr-btn-dirty--active { background: #fef3c7 !important; border-color: #fcd34d !important; color: #b45309 !important; } | |
| .lr-btn-utility { font-size: 11px !important; height: 26px; line-height: 24px; padding: 0 8px !important; } | |
| .lr-meta-key { | |
| font-size: 10px; | |
| color: #c0c0c0; | |
| font-family: monospace; | |
| margin-bottom: 4px; | |
| word-break: break-all; | |
| } | |
| .lr-empty { color: #999; font-style: italic; margin: 0; font-size: 12px; } | |
| .lr-notice { color: #888; font-style: italic; margin: 8px 0; } | |
| .lr-debug { | |
| margin-top: 16px; | |
| border-top: 1px dashed #e0e0e0; | |
| padding-top: 12px; | |
| } | |
| .lr-debug-summary { | |
| cursor: pointer; | |
| font-size: 11px; | |
| font-weight: 600; | |
| color: #b32d2e; | |
| user-select: none; | |
| } | |
| .lr-debug-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 11px; | |
| margin-top: 10px; | |
| } | |
| .lr-debug-table th, | |
| .lr-debug-table td { | |
| border: 1px solid #eee; | |
| padding: 5px 8px; | |
| text-align: left; | |
| vertical-align: top; | |
| } | |
| .lr-debug-table th { background: #f6f7f7; font-weight: 600; } | |
| .lr-debug-table code { font-size: 10px; word-break: break-all; } | |
| </style>'; | |
| } | |
| /* ============================================================================= | |
| META BOX SCRIPTS | |
| ============================================================================= */ | |
| function lr_acf_quick_meta_scripts(): void { | |
| echo '<script> | |
| document.addEventListener("DOMContentLoaded", function() { | |
| document.addEventListener("keydown", function(e) { | |
| if ((e.ctrlKey || e.metaKey) && e.key === "s") { | |
| e.preventDefault(); | |
| var form = document.getElementById("post"); | |
| if (form) form.submit(); | |
| } | |
| }); | |
| var btn = document.getElementById("lr_refresh_btn"); | |
| if (btn) { | |
| btn.addEventListener("click", function(e) { | |
| e.preventDefault(); | |
| var dc = document.getElementById("lr_dirty_count"); | |
| if (dc) { | |
| dc.textContent = "Saving\u2026"; | |
| dc.style.display = ""; | |
| dc.classList.add("lr-dirty-count--saving"); | |
| } | |
| var form = document.getElementById("post"); | |
| if (form) form.submit(); | |
| }); | |
| } | |
| var dirtyCount = document.getElementById("lr_dirty_count"); | |
| var dirtyBtn = document.getElementById("lr_filter_dirty"); | |
| var emptyBtn = document.getElementById("lr_filter_empty"); | |
| var expandBtn = document.getElementById("lr_expand_all"); | |
| var collapseBtn = document.getElementById("lr_collapse_all"); | |
| var countsEl = document.getElementById("lr_toolbar_counts"); | |
| var filterEl = document.getElementById("lr_filter"); | |
| var dirtyModeOn = false; | |
| var emptyModeOn = false; | |
| var currentFilter = ""; | |
| document.querySelectorAll(".lr-textarea").forEach(function(ta) { | |
| ta.dataset.initialValue = ta.value; | |
| }); | |
| var postForm = document.getElementById("post"); | |
| if (postForm) { | |
| postForm.addEventListener("submit", function() { | |
| document.querySelectorAll(".lr-textarea").forEach(function(ta) { | |
| ta.dataset.initialValue = ta.value; | |
| }); | |
| }); | |
| } | |
| function lrUpdateCounts() { | |
| var fields = document.querySelectorAll(".lr-field"); | |
| var total = fields.length; | |
| var empty = 0; | |
| var changed = 0; | |
| var hidden = 0; | |
| fields.forEach(function(el) { | |
| var ta = el.querySelector(".lr-textarea"); | |
| if (ta && ta.value.trim() === "") empty++; | |
| if (el.classList.contains("lr-field--dirty")) changed++; | |
| if (el.style.display === "none") hidden++; | |
| }); | |
| if (countsEl) { | |
| countsEl.textContent = total + " fields | " + empty + " empty | " + changed + " changed" + (hidden > 0 ? " | " + hidden + " hidden" : ""); | |
| } | |
| } | |
| function lrUpdateDirty() { | |
| var count = 0; | |
| document.querySelectorAll(".lr-textarea").forEach(function(ta) { | |
| var changed = ta.value !== ta.dataset.initialValue; | |
| ta.closest(".lr-field").classList.toggle("lr-field--dirty", changed); | |
| if (changed) count++; | |
| }); | |
| if (dirtyCount) { | |
| dirtyCount.textContent = count > 0 ? count + " unsaved" : ""; | |
| dirtyCount.style.display = count > 0 ? "" : "none"; | |
| dirtyCount.classList.remove("lr-dirty-count--saving"); | |
| } | |
| if (dirtyBtn) { | |
| dirtyBtn.style.display = count > 0 ? "" : "none"; | |
| if (count === 0 && dirtyModeOn) lrSetDirtyMode(false); | |
| } | |
| if (dirtyModeOn || emptyModeOn) { | |
| lrApplyFilter(currentFilter); | |
| } else { | |
| lrUpdateCounts(); | |
| } | |
| } | |
| function lrSetDirtyMode(on) { | |
| dirtyModeOn = on; | |
| if (on && emptyModeOn) lrSetEmptyMode(false); | |
| if (dirtyBtn) { | |
| dirtyBtn.textContent = on ? "Show all" : "Show changed"; | |
| dirtyBtn.classList.toggle("lr-btn-dirty--active", on); | |
| } | |
| lrApplyFilter(currentFilter); | |
| } | |
| function lrSetEmptyMode(on) { | |
| emptyModeOn = on; | |
| if (on && dirtyModeOn) lrSetDirtyMode(false); | |
| if (emptyBtn) { | |
| emptyBtn.textContent = on ? "Show all" : "Show empty"; | |
| emptyBtn.classList.toggle("lr-btn-dirty--active", on); | |
| } | |
| lrApplyFilter(currentFilter); | |
| } | |
| if (expandBtn) { | |
| expandBtn.addEventListener("click", function() { | |
| document.querySelectorAll(".lr-box, .lr-root-group").forEach(function(el) { | |
| if (el.style.display !== "none") el.open = true; | |
| }); | |
| }); | |
| } | |
| if (collapseBtn) { | |
| collapseBtn.addEventListener("click", function() { | |
| document.querySelectorAll(".lr-box, .lr-root-group").forEach(function(el) { | |
| if (el.style.display !== "none") el.open = false; | |
| }); | |
| }); | |
| } | |
| if (dirtyBtn) { | |
| dirtyBtn.addEventListener("click", function() { | |
| lrSetDirtyMode(!dirtyModeOn); | |
| }); | |
| } | |
| if (emptyBtn) { | |
| emptyBtn.addEventListener("click", function() { | |
| lrSetEmptyMode(!emptyModeOn); | |
| }); | |
| } | |
| document.querySelectorAll(".lr-textarea").forEach(function(ta) { | |
| ta.addEventListener("input", lrUpdateDirty); | |
| }); | |
| function lrApplyFilter(v) { | |
| document.querySelectorAll(".lr-field").forEach(function(el) { | |
| if (dirtyModeOn && !el.classList.contains("lr-field--dirty")) { | |
| el.style.display = "none"; | |
| return; | |
| } | |
| if (emptyModeOn) { | |
| var ta = el.querySelector(".lr-textarea"); | |
| if (ta && ta.value.trim() !== "") { | |
| el.style.display = "none"; | |
| return; | |
| } | |
| } | |
| el.style.display = ( !v || el.dataset.search.includes(v) ) ? "" : "none"; | |
| }); | |
| document.querySelectorAll(".lr-box").forEach(function(box) { | |
| var visible = Array.from(box.querySelectorAll(".lr-field")) | |
| .filter(function(el) { return el.style.display !== "none"; }).length; | |
| box.style.display = visible ? "" : "none"; | |
| if (visible && v) box.open = true; | |
| }); | |
| document.querySelectorAll(".lr-root-group").forEach(function(group) { | |
| var visible = Array.from(group.querySelectorAll(".lr-field")) | |
| .filter(function(el) { return el.style.display !== "none"; }).length; | |
| group.style.display = visible ? "" : "none"; | |
| if (visible && v) { | |
| group.open = true; | |
| } else if (!v) { | |
| group.open = group.dataset.initialOpen === "1"; | |
| } | |
| }); | |
| lrUpdateCounts(); | |
| } | |
| function lrIndexFields() { | |
| document.querySelectorAll(".lr-field").forEach(function(el) { | |
| el.dataset.search = el.textContent.toLowerCase(); | |
| }); | |
| document.querySelectorAll(".lr-root-group").forEach(function(group) { | |
| if (!group.hasAttribute("data-initial-open")) { | |
| group.dataset.initialOpen = group.open ? "1" : "0"; | |
| } | |
| }); | |
| } | |
| lrIndexFields(); | |
| setTimeout(lrIndexFields, 300); | |
| lrUpdateCounts(); | |
| if (filterEl) { | |
| filterEl.addEventListener("input", function() { | |
| currentFilter = this.value.toLowerCase(); | |
| lrApplyFilter(currentFilter); | |
| }); | |
| } | |
| }); | |
| </script>'; | |
| } | |
| /* ============================================================================= | |
| SETTINGS PAGE STYLES | |
| ============================================================================= */ | |
| function lr_acf_quick_settings_styles(): void { | |
| echo '<style> | |
| .lr-settings-wrap { max-width: 780px; } | |
| .lr-settings-wrap h1 { margin-bottom: 20px; } | |
| .lr-section { | |
| background: #fff; | |
| border: 1px solid #dcdcde; | |
| border-radius: 3px; | |
| padding: 18px 22px; | |
| margin-bottom: 16px; | |
| } | |
| .lr-section-title { | |
| font-size: 13px; | |
| font-weight: 700; | |
| margin: 0 0 8px; | |
| padding: 0 0 8px; | |
| border-bottom: 1px solid #f0f0f0; | |
| } | |
| .lr-section .description { margin: 0 0 12px; color: #666; } | |
| .lr-section input[type="text"] { margin-top: 4px; } | |
| .lr-checks { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px 20px; | |
| margin-top: 6px; | |
| } | |
| .lr-checks--stack { flex-direction: column; gap: 10px; } | |
| .lr-check-label { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 13px; | |
| cursor: pointer; | |
| } | |
| .lr-check-label code { | |
| font-size: 11px; | |
| color: #888; | |
| background: #f6f7f7; | |
| padding: 1px 5px; | |
| border-radius: 2px; | |
| } | |
| .lr-check-label em.lr-hint { font-size: 11px; color: #888; font-style: normal; } | |
| .lr-indent { | |
| margin-left: 22px; | |
| margin-top: 8px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .lr-form-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| margin-top: 8px; | |
| } | |
| .lr-btn-reset { color: #b32d2e !important; } | |
| .lr-btn-reset:hover { border-color: #b32d2e !important; } | |
| </style> | |
| <script> | |
| document.addEventListener("DOMContentLoaded", function() { | |
| var toggle = document.getElementById("lr_root_enabled"); | |
| var wrap = document.getElementById("lr_root_placement_wrap"); | |
| if (toggle && wrap) { | |
| toggle.addEventListener("change", function() { | |
| wrap.style.display = this.checked ? "" : "none"; | |
| }); | |
| } | |
| }); | |
| </script>'; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment