PluginProbe ʕ •ᴥ•ʔ
ECS – Ele Custom Skin for Elementor / 4.3.11
ECS – Ele Custom Skin for Elementor v4.3.11
4.3.11 4.3.10 4.3.9 4.3.8 4.3.7 4.3.6 4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.11 4.1.10 4.1.9 4.1.8 4.1.6 4.1.7 4.1.5 4.1.4 4.1.1 4.1.2 trunk 1.0.0 1.0.1 1.0.9 1.1.3 1.1.4 1.1.5 1.2.0 1.2.1 1.2.4 1.2.5 1.3.10 1.3.11 1.3.3 1.3.4 1.3.6 1.3.7 1.3.9 1.4.0 2.0.2 2.1.0 2.2.0 2.2.1 2.2.2 3.0.0 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 4.1.0
ele-custom-skin / modules / loop-custom-layout / class-ecs-loop-custom-layout-module.php
ele-custom-skin / modules / loop-custom-layout Last commit date
assets 2 weeks ago class-ecs-loop-custom-layout-module.php 2 weeks ago
class-ecs-loop-custom-layout-module.php
518 lines
1 <?php
2 /**
3 * Module: Loop Custom Layout
4 *
5 * Replaces the default Loop Grid widget output with an ECS Custom Layout
6 * template. Each queried loop item is rendered in post context, then
7 * distributed into ECS Container Placeholder widgets inside the template —
8 * cycling when items > placeholders, leaving placeholders empty when
9 * items < placeholders.
10 *
11 * Requirements:
12 * - Elementor Pro ≥ 3.8 (Loop Builder module — provides the Loop Grid widget)
13 * - ECS Container Layout module (registers the ecs_custom_layout document type
14 * and the ECS Container Placeholder widget)
15 */
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 class ECS_Loop_Custom_Layout_Module extends ECS_Module_Base {
22
23 /**
24 * Collected loop-grid widgets (widget_id => template_id) that have
25 * ecs_use_custom_layout enabled. Populated before each document renders,
26 * consumed in the elementor/frontend/the_content filter.
27 *
28 * @var array<string, int>
29 */
30 private array $pending_widgets = [];
31
32 // ── Identity ──────────────────────────────────────────────────────────────
33
34 public function get_id(): string {
35 return 'loop_custom_layout';
36 }
37
38 public function get_title(): string {
39 return __( 'Loop Custom Layout', 'ele-custom-skin' );
40 }
41
42 public function get_description(): string {
43 return __( 'Arrange Loop Grid items inside an ECS Custom Layout template via placeholder widgets — the same injection system as Container Content Layout but powered by the Loop Grid query. Requires Elementor Pro and the Container Layout module.', 'ele-custom-skin' );
44 }
45
46 // ── Boot ──────────────────────────────────────────────────────────────────
47
48 public function boot(): void {
49 // Requires Elementor Pro Loop Builder (Loop Grid widget).
50 if ( ! class_exists( '\ElementorPro\Modules\LoopBuilder\Module' ) ) {
51 return;
52 }
53
54 // Inject the toggle + template selector at the bottom of the Layout section.
55 add_action(
56 'elementor/element/loop-grid/section_layout/before_section_end',
57 [ $this, 'add_loop_grid_controls' ],
58 10, 2
59 );
60
61 // Before each document renders: collect loop-grid widgets with our setting.
62 // (edit-mode check is deferred to inside the callbacks to avoid calling
63 // is_edit_mode() here — doing so would trigger documents->get(), firing
64 // elementor/documents/register before EPro registers its loop-item type.)
65 add_action( 'elementor/frontend/before_get_builder_content', [ $this, 'collect_widgets' ], 10, 1 );
66 add_filter( 'elementor/frontend/the_content', [ $this, 'inject_custom_layouts' ], 10, 1 );
67
68 // AJAX endpoint: render template with loop items for the editor preview.
69 add_action( 'wp_ajax_ecs_loop_custom_layout_preview', [ $this, 'ajax_loop_custom_layout_preview' ] );
70 }
71
72 // ── Controls ──────────────────────────────────────────────────────────────
73
74 /**
75 * Inject a switcher + template selector at the bottom of the Layout section.
76 */
77 public function add_loop_grid_controls( $element, $args ): void {
78 $element->add_control( 'ecs_use_custom_layout', [
79 'label' => esc_html__( 'Use Custom Layout', 'ele-custom-skin' ),
80 'type' => \Elementor\Controls_Manager::SWITCHER,
81 'return_value' => 'yes',
82 'default' => '',
83 'separator' => 'before',
84 ] );
85
86 $element->add_control( 'ecs_loop_layout_id', [
87 'label' => esc_html__( 'Custom Layout Template', 'ele-custom-skin' ),
88 'type' => \Elementor\Controls_Manager::SELECT2,
89 'options' => $this->get_custom_layout_templates(),
90 'label_block' => true,
91 'description' => esc_html__( 'Placeholder widgets inside the template receive loop items in order, cycling when items exceed placeholders.', 'ele-custom-skin' ),
92 'condition' => [ 'ecs_use_custom_layout' => 'yes' ],
93 ] );
94 }
95
96 // ── Document-level interception ───────────────────────────────────────────
97
98 /**
99 * Fired before Elementor renders a document.
100 * Walks the document element tree and registers any loop-grid widgets that
101 * have ecs_use_custom_layout enabled.
102 *
103 * @param \Elementor\Core\Base\Document $document
104 */
105 public function collect_widgets( $document ): void {
106 // Skip in the editor panel but NOT in the editor's preview iframe —
107 // is_edit_mode()=true covers both; is_preview_mode()=true only for the iframe.
108 if ( \Elementor\Plugin::$instance->editor->is_edit_mode()
109 && ! \Elementor\Plugin::$instance->preview->is_preview_mode() ) {
110 return;
111 }
112
113 $elements_data = $document->get_elements_data();
114 if ( empty( $elements_data ) ) {
115 return;
116 }
117
118 $this->walk_elements( $elements_data );
119 }
120
121 /**
122 * Fired after Elementor renders a document (receives its full HTML).
123 * For each collected loop-grid widget whose ID class appears in the HTML,
124 * extract the rendered loop items and replace the widget output with the
125 * ECS Custom Layout template.
126 */
127 public function inject_custom_layouts( string $content ): string {
128 if ( empty( $this->pending_widgets ) ) {
129 return $content;
130 }
131
132 foreach ( $this->pending_widgets as $widget_id => $template_id ) {
133 // Quick check before the expensive extraction.
134 if ( false === strpos( $content, 'elementor-element-' . $widget_id ) ) {
135 continue;
136 }
137
138 $widget_html = $this->extract_element_by_id( $content, $widget_id );
139 if ( ! $widget_html ) {
140 continue;
141 }
142
143 $items = $this->extract_loop_items( $widget_html );
144 if ( empty( $items ) ) {
145 continue; // No loop items rendered — leave original output.
146 }
147
148 ob_start();
149 $this->render_with_custom_layout( $template_id, $items );
150 $replacement = ob_get_clean();
151
152 $pos = strpos( $content, $widget_html );
153 if ( false !== $pos ) {
154 $content = substr( $content, 0, $pos )
155 . $replacement
156 . substr( $content, $pos + strlen( $widget_html ) );
157 }
158
159 unset( $this->pending_widgets[ $widget_id ] );
160 }
161
162 return $content;
163 }
164
165 // ── Injection ─────────────────────────────────────────────────────────────
166
167 /**
168 * Render the ECS Custom Layout template with loop items distributed into
169 * placeholder widgets. Mirrors the logic in ECS_Container_Layout_Module.
170 *
171 * @param int $template_id Published ECS Custom Layout post ID.
172 * @param list<string> $items Rendered loop item HTML strings.
173 */
174 private function render_with_custom_layout( int $template_id, array $items ): void {
175 if ( ! class_exists( 'ECS_Container_Placeholder_Widget' ) ) {
176 // Container Layout module inactive — output items without a template.
177 echo '<div class="elementor-widget-container">' . implode( '', $items ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
178 return;
179 }
180
181 // Inline template CSS — same technique as Container Content Layout.
182 $css_path = WP_CONTENT_DIR . '/uploads/elementor/css/post-' . $template_id . '.css';
183 if ( file_exists( $css_path ) ) {
184 echo '<style id="elementor-post-' . esc_attr( $template_id ) . '-css">'
185 . file_get_contents( $css_path ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
186 . '</style>';
187 }
188
189 // The shortcode-rendering filter breaks placeholder output during
190 // Elementor's element-cache build pass — temporarily remove it.
191 $filter_was_active = has_filter( 'elementor/element/should_render_shortcode', '__return_true' );
192 if ( $filter_was_active ) {
193 remove_filter( 'elementor/element/should_render_shortcode', '__return_true' );
194 }
195
196 echo '<div class="elementor-widget-container"><div class="ecs-loop-custom-layout-wrap">';
197
198 $batch = $items;
199 $first_pass = true;
200
201 do {
202 ECS_Container_Placeholder_Widget::set_pending_children( $batch );
203
204 $output = \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $template_id, true );
205 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
206
207 if ( $first_pass && ! ECS_Container_Placeholder_Widget::any_consumed() && ! empty( $items ) ) {
208 // Template has no placeholder widgets — show fallback.
209 ECS_Container_Placeholder_Widget::reset_pending_children();
210 echo '<div class="ecs-missing-placeholder">';
211 foreach ( $items as $item ) {
212 echo $item; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
213 }
214 echo '</div>';
215 break;
216 }
217
218 $batch = ECS_Container_Placeholder_Widget::get_overflow_children();
219 $first_pass = false;
220 ECS_Container_Placeholder_Widget::reset_pending_children();
221
222 } while ( ! empty( $batch ) );
223
224 echo '</div></div>'; // .ecs-loop-custom-layout-wrap + .elementor-widget-container
225
226 if ( $filter_was_active ) {
227 add_filter( 'elementor/element/should_render_shortcode', '__return_true' );
228 }
229 }
230
231 // ── Helpers ───────────────────────────────────────────────────────────────
232
233 /**
234 * Recursively walk the element tree and add qualifying loop-grid widgets
235 * to $this->pending_widgets.
236 */
237 private function walk_elements( array $elements ): void {
238 foreach ( $elements as $element ) {
239 if (
240 ( $element['elType'] ?? '' ) === 'widget'
241 && ( $element['widgetType'] ?? '' ) === 'loop-grid'
242 && ( $element['settings']['ecs_use_custom_layout'] ?? '' ) === 'yes'
243 ) {
244 $template_id = absint( $element['settings']['ecs_loop_layout_id'] ?? 0 );
245 if ( $template_id && 'publish' === get_post_status( $template_id ) ) {
246 $this->pending_widgets[ $element['id'] ] = $template_id;
247 }
248 }
249
250 if ( ! empty( $element['elements'] ) ) {
251 $this->walk_elements( $element['elements'] );
252 }
253 }
254 }
255
256 /**
257 * Find and return the outermost element HTML that carries the class
258 * elementor-element-{$element_id}. Uses tag-depth tracking to find the
259 * matching closing tag, handling arbitrary nesting.
260 */
261 private function extract_element_by_id( string $html, string $element_id ): string {
262 if ( ! preg_match(
263 '/<([\w-]+)(\s[^>]*?\bclass="[^"]*\belementor-element-' . preg_quote( $element_id, '/' ) . '\b[^"]*"[^>]*)>/i',
264 $html,
265 $m,
266 PREG_OFFSET_CAPTURE
267 ) ) {
268 return '';
269 }
270
271 $tag = $m[1][0];
272 $start = $m[0][1];
273 $after_open = $start + strlen( $m[0][0] );
274 $len = strlen( $html );
275 $depth = 1;
276 $cursor = $after_open;
277 $end = $after_open;
278
279 while ( $depth > 0 && $cursor < $len ) {
280 $next_open = strpos( $html, '<' . $tag, $cursor );
281 $next_close = strpos( $html, '</' . $tag . '>', $cursor );
282
283 if ( false === $next_close ) {
284 break;
285 }
286 if ( false === $next_open ) {
287 $next_open = $len;
288 }
289
290 if ( $next_open < $next_close ) {
291 $depth++;
292 $cursor = $next_open + 1;
293 } else {
294 $depth--;
295 $cursor = $next_close + 1;
296 if ( 0 === $depth ) {
297 $end = $next_close + strlen( '</' . $tag . '>' );
298 }
299 }
300 }
301
302 return ( 0 === $depth ) ? substr( $html, $start, $end - $start ) : '';
303 }
304
305 /**
306 * Extract individual loop item HTML strings from the buffered Loop Grid output.
307 *
308 * Elementor Pro marks each rendered loop item with class `e-loop-item` on
309 * its outermost element. We use tag-depth tracking to find the matching
310 * closing tag for each item, handling arbitrary nesting correctly.
311 *
312 * @return list<string>
313 */
314 private function extract_loop_items( string $html ): array {
315 $items = [];
316 $pos = 0;
317 $len = strlen( $html );
318
319 while ( $pos < $len ) {
320 // Find the next opening tag that carries the e-loop-item class.
321 if ( ! preg_match(
322 '/<(\w[\w-]*)(\s[^>]*?\bclass="[^"]*\be-loop-item\b[^"]*"[^>]*|[^>]*\bclass=\'[^\']*\be-loop-item\b[^\']*\'[^>]*)>/i',
323 $html,
324 $m,
325 PREG_OFFSET_CAPTURE,
326 $pos
327 ) ) {
328 break;
329 }
330
331 $tag = $m[1][0];
332 $start = $m[0][1];
333 $after_open = $start + strlen( $m[0][0] );
334
335 // Walk forward tracking open/close depth to find the matching close tag.
336 $depth = 1;
337 $cursor = $after_open;
338 $end = $after_open;
339
340 while ( $depth > 0 && $cursor < $len ) {
341 $next_open = strpos( $html, '<' . $tag, $cursor );
342 $next_close = strpos( $html, '</' . $tag . '>', $cursor );
343
344 if ( false === $next_close ) {
345 break; // Malformed HTML — stop.
346 }
347 if ( false === $next_open ) {
348 $next_open = $len;
349 }
350
351 if ( $next_open < $next_close ) {
352 $depth++;
353 $cursor = $next_open + 1;
354 } else {
355 $depth--;
356 $cursor = $next_close + 1;
357 if ( 0 === $depth ) {
358 $end = $next_close + strlen( '</' . $tag . '>' );
359 }
360 }
361 }
362
363 if ( 0 === $depth ) {
364 $items[] = substr( $html, $start, $end - $start );
365 $pos = $end;
366 } else {
367 break; // Could not close the tag — stop parsing.
368 }
369 }
370
371 return $items;
372 }
373
374 // ── Editor assets ─────────────────────────────────────────────────────────
375
376 /**
377 * Enqueue the editor preview script for the Loop Custom Layout feature.
378 */
379 public function enqueue_editor_assets(): void {
380 wp_enqueue_script(
381 'ecs-loop-custom-layout-editor',
382 $this->module_asset_url( 'assets/js/ecs-loop-custom-layout-editor.js' ),
383 [ 'elementor-editor' ],
384 ECS_VERSION,
385 true
386 );
387
388 wp_localize_script(
389 'ecs-loop-custom-layout-editor',
390 'ecsLoopPreview',
391 [
392 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
393 'nonce' => wp_create_nonce( 'ecs-loop-custom-layout-preview' ),
394 ]
395 );
396 }
397
398 // ── AJAX ──────────────────────────────────────────────────────────────────
399
400 /**
401 * AJAX handler: render a custom layout template with caller-supplied loop
402 * item HTML strings so the Elementor editor canvas can preview the result.
403 *
404 * Security: nonce + capability check (wp_ajax_ requires authentication).
405 */
406 public function ajax_loop_custom_layout_preview(): void {
407 check_ajax_referer( 'ecs-loop-custom-layout-preview', 'nonce' );
408
409 if ( ! current_user_can( 'edit_posts' ) ) {
410 wp_send_json_error( [ 'message' => 'Unauthorized' ] );
411 return;
412 }
413
414 $template_id = absint( $_POST['template_id'] ?? 0 );
415 $items = isset( $_POST['items'] ) && is_array( $_POST['items'] )
416 ? array_values( wp_unslash( $_POST['items'] ) )
417 : [];
418
419 if ( ! $template_id || 'publish' !== get_post_status( $template_id ) ) {
420 wp_send_json_error( [ 'message' => 'Invalid template' ] );
421 return;
422 }
423
424 // Ensure the placeholder widget class is loaded before static calls.
425 if ( ! class_exists( 'ECS_Container_Placeholder_Widget', false ) ) {
426 $widget_file = ECS_PATH . 'modules/container-layout/widgets/class-ecs-container-placeholder-widget.php';
427 if ( file_exists( $widget_file ) ) {
428 require_once $widget_file;
429 }
430 }
431
432 if ( ! class_exists( 'ECS_Container_Placeholder_Widget', false ) ) {
433 wp_send_json_error( [ 'message' => 'Container Layout module not active' ] );
434 return;
435 }
436
437 ob_start();
438 // Render only what goes inside .elementor-widget-container —
439 // i.e. the ecs-loop-custom-layout-wrap div and its template instances.
440 $this->render_loop_items_with_template( $template_id, $items );
441 $html = ob_get_clean();
442
443 wp_send_json_success( [ 'html' => $html ] );
444 }
445
446 /**
447 * Like render_with_custom_layout() but emits only the inner content —
448 * the ecs-loop-custom-layout-wrap div — without the outer
449 * .elementor-widget-container wrapper (the editor injects into that wrapper).
450 *
451 * @param int $template_id Published ECS Custom Layout post ID.
452 * @param list<string> $items Rendered loop item HTML strings.
453 */
454 private function render_loop_items_with_template( int $template_id, array $items ): void {
455 $filter_was_active = has_filter( 'elementor/element/should_render_shortcode', '__return_true' );
456 if ( $filter_was_active ) {
457 remove_filter( 'elementor/element/should_render_shortcode', '__return_true' );
458 }
459
460 echo '<div class="ecs-loop-custom-layout-wrap">';
461
462 $batch = $items;
463 $first_pass = true;
464
465 do {
466 ECS_Container_Placeholder_Widget::set_pending_children( $batch );
467
468 $output = \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $template_id, true );
469 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
470
471 if ( $first_pass && ! ECS_Container_Placeholder_Widget::any_consumed() && ! empty( $items ) ) {
472 ECS_Container_Placeholder_Widget::reset_pending_children();
473 echo '<div class="ecs-missing-placeholder">';
474 foreach ( $items as $item ) {
475 echo $item; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
476 }
477 echo '</div>';
478 break;
479 }
480
481 $batch = ECS_Container_Placeholder_Widget::get_overflow_children();
482 $first_pass = false;
483 ECS_Container_Placeholder_Widget::reset_pending_children();
484
485 } while ( ! empty( $batch ) );
486
487 echo '</div>'; // .ecs-loop-custom-layout-wrap
488
489 if ( $filter_was_active ) {
490 add_filter( 'elementor/element/should_render_shortcode', '__return_true' );
491 }
492 }
493
494 /**
495 * Return an id → title map of all published ECS Custom Layout templates.
496 */
497 private function get_custom_layout_templates(): array {
498 $posts = get_posts( [
499 'post_type' => 'elementor_library',
500 'post_status' => 'publish',
501 'posts_per_page' => -1,
502 'meta_query' => [
503 [
504 'key' => '_elementor_template_type',
505 'value' => 'ecs_custom_layout',
506 ],
507 ],
508 ] );
509
510 $options = [ '' => esc_html__( '— Select Template —', 'ele-custom-skin' ) ];
511 foreach ( $posts as $post ) {
512 $options[ $post->ID ] = $post->post_title;
513 }
514
515 return $options;
516 }
517 }
518