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 / container-layout / class-ecs-container-layout-module.php
ele-custom-skin / modules / container-layout Last commit date
assets 2 weeks ago widgets 4 months ago class-ecs-container-layout-module.php 2 weeks ago class-ecs-custom-layout-document.php 4 months ago
class-ecs-container-layout-module.php
1447 lines
1 <?php
2 /**
3 * Module: Container Content Layout
4 *
5 * Provides:
6 * - Layout modes on Elementor Containers: inherit | slider | custom_layout
7 * - DTE Custom Layout template type (ecs_custom_layout)
8 * - DTE Container Placeholder widget
9 * - Child injection: container's rendered children are captured via output
10 * buffering, extracted from .e-con-inner, and passed to the placeholder
11 * widget inside the selected DTE Custom Layout template.
12 * - Recursion guard via ECS_Container_Placeholder_Widget::is_rendering()
13 */
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 class ECS_Container_Layout_Module extends ECS_Module_Base {
20
21 public function get_id(): string {
22 return 'container_layout';
23 }
24
25 public function get_title(): string {
26 return __( 'Container Content Layout', 'ele-custom-skin' );
27 }
28
29 public function get_description(): string {
30 return __( 'Two extra layout modes for containers: Slider (CSS-only) and Custom Layout (distribute children into a template via placeholder widgets).', 'ele-custom-skin' );
31 }
32
33 public function boot(): void {
34 // Normalize template_type slug before Elementor's new-post handler validates it.
35 // Elementor Pro's "Add New Template" modal sends the display title ("DTE Custom Layout")
36 // instead of the registered slug ("ecs_custom_layout") for custom document types.
37 add_action( 'admin_init', [ $this, 'fix_new_post_template_type' ], 1 );
38
39 // Register the custom document type (template type)
40 add_action( 'elementor/documents/register', [ $this, 'register_document_types' ] );
41
42 // Add layout controls to every Container element.
43 // We use TWO hooks:
44 // - after_section_start (priority 5) → fallback simple ecs_container_type control,
45 // active ONLY when the Responsive Container Layout module is not enabled.
46 // When that module IS active, it registers the responsive version at priority 10
47 // and this callback does nothing.
48 // - before_section_end → inject DTE slider/custom-specific controls LAST
49 add_action( 'elementor/element/container/section_layout_container/after_section_start', [ $this, 'maybe_add_fallback_type_control' ], 5, 2 );
50 add_action( 'elementor/element/container/section_layout_container/before_section_end', [ $this, 'add_container_controls' ], 10, 2 );
51
52 // Slider style controls (arrows + dots) in the Style tab
53 add_action( 'elementor/element/container/section_border/after_section_end',
54 [ $this, 'add_slider_style_controls' ], 10, 2 );
55
56 // Intercept container render for layout injection.
57 // No should_render override — third-party conditional display (EA, etc.) must work for all containers.
58 add_action( 'elementor/frontend/container/before_render', [ $this, 'before_container_render' ] );
59 add_action( 'elementor/frontend/container/after_render', [ $this, 'after_container_render' ] );
60
61 // When a DTE Custom Layout template is saved, bust the element cache for
62 // all pages — their cached HTML may contain the old template output.
63 add_action( 'elementor/document/after_save', [ $this, 'on_custom_layout_saved' ] );
64
65 // AJAX endpoint: render template with injected children for the editor preview.
66 add_action( 'wp_ajax_ecs_preview_layout', [ $this, 'ajax_preview_layout' ] );
67 }
68
69 public function register_widgets( $widgets_manager ): void {
70 require_once $this->module_path() . 'widgets/class-ecs-container-placeholder-widget.php';
71 $widgets_manager->register( new ECS_Container_Placeholder_Widget() );
72 }
73
74 /**
75 * When a DTE Custom Layout template is saved, delete the Elementor element
76 * cache for all posts so the next frontend request rebuilds with the new template.
77 *
78 * We use delete_post_meta_by_key() because we have no reverse-index of which
79 * pages use which template — a global bust is safe and inexpensive (cache
80 * rebuilds on next load, just like after a regular Elementor publish).
81 */
82 public function on_custom_layout_saved( $document ): void {
83 if ( ECS_Custom_Layout_Document::get_type() !== $document->get_type() ) {
84 return;
85 }
86 delete_post_meta_by_key( \Elementor\Core\Base\Document::CACHE_META_KEY );
87 }
88
89 /**
90 * Normalize the template_type request parameter for the elementor_new_post action.
91 *
92 * Elementor Pro's "Add New Template" modal passes the document's display title
93 * ("DTE Custom Layout") as template_type instead of the registered slug
94 * ("ecs_custom_layout"). We catch this at priority 1 — before Elementor's own
95 * admin_init handler — and rewrite all three superglobals so validation passes.
96 */
97 public function fix_new_post_template_type(): void {
98 if ( empty( $_REQUEST['action'] ) || 'elementor_new_post' !== $_REQUEST['action'] ) {
99 return;
100 }
101 if ( empty( $_REQUEST['template_type'] ) ) {
102 return;
103 }
104
105 $raw = sanitize_text_field( wp_unslash( $_REQUEST['template_type'] ) );
106 $normalized = strtolower( preg_replace( '/[\s-]+/', '_', $raw ) );
107
108 if ( 'ecs_custom_layout' === $normalized && 'ecs_custom_layout' !== $raw ) {
109 $_REQUEST['template_type'] = 'ecs_custom_layout';
110 $_GET['template_type'] = 'ecs_custom_layout';
111 $_POST['template_type'] = 'ecs_custom_layout';
112 }
113 }
114
115 /**
116 * Register the DTE Custom Layout document type.
117 */
118 public function register_document_types( $documents_manager ): void {
119 require_once $this->module_path() . 'class-ecs-custom-layout-document.php';
120 $documents_manager->register_document_type( 'ecs_custom_layout', ECS_Custom_Layout_Document::class );
121 }
122
123 /**
124 * Fallback container type control — used only when the Responsive Container Layout
125 * module is NOT active. Adds a simple (non-responsive) ecs_container_type SELECT
126 * so Slider and Custom Layout modes can still be selected at the desktop level.
127 *
128 * When the Responsive Container Layout module IS active it registers the full
129 * responsive version of this control at priority 10 (after this priority-5 hook).
130 * In that case this method exits early so the control is not registered twice.
131 */
132 public function maybe_add_fallback_type_control( $element, $args ): void {
133 if ( ECS_Core::instance()->modules()->is_active( 'container_responsive' ) ) {
134 return; // Responsive module handles ecs_container_type.
135 }
136
137 $element->update_control( 'container_type', [ 'classes' => 'ecs-hidden-control' ] );
138
139 $element->add_control(
140 'ecs_container_type',
141 [
142 'label' => esc_html__( 'Container Layout', 'ele-custom-skin' ),
143 'type' => \Elementor\Controls_Manager::SELECT,
144 'default' => 'flex',
145 'options' => [
146 'flex' => esc_html__( 'Flexbox', 'ele-custom-skin' ),
147 'grid' => esc_html__( 'Grid', 'ele-custom-skin' ),
148 'slider' => esc_html__( 'Slider', 'ele-custom-skin' ),
149 'custom' => esc_html__( 'Custom Layout', 'ele-custom-skin' ),
150 ],
151 'prefix_class' => 'e-ecs-',
152 'frontend_available' => true,
153 ]
154 );
155 }
156
157 public function add_container_type_control( $element, $args ): void {
158 // Hide the built-in Container Layout control — replaced by our responsive ecs_container_type.
159 // prefix_class uses sprintf() format: desktop → 'e-ecs-', tablet → 'e-ecs-tablet-', mobile → 'e-ecs-mobile-'
160 // This generates the same CSS classes the stylesheet already uses.
161 $element->update_control( 'container_type', [ 'classes' => 'ecs-hidden-control' ] );
162
163 $element->add_responsive_control(
164 'ecs_container_type',
165 [
166 'label' => esc_html__( 'Container Layout', 'ele-custom-skin' ),
167 'type' => \Elementor\Controls_Manager::SELECT,
168 'default' => 'flex',
169 'tablet_default' => '',
170 'mobile_default' => '',
171 'options' => [
172 '' => esc_html__( '— Inherit —', 'ele-custom-skin' ),
173 'flex' => esc_html__( 'Flexbox', 'ele-custom-skin' ),
174 'grid' => esc_html__( 'Grid', 'ele-custom-skin' ),
175 'slider' => esc_html__( 'Slider', 'ele-custom-skin' ),
176 'custom' => esc_html__( 'Custom Layout', 'ele-custom-skin' ),
177 ],
178 'prefix_class' => 'e-ecs%s-',
179 'frontend_available' => true,
180 ]
181 );
182 }
183
184 public function add_container_controls( $element, $args ): void {
185
186 // ecs_active_type is a virtual key set by JS (ecs-editor-preview.js) to reflect
187 // the effective container type for the currently active device mode.
188 // All dependent controls condition on this key so Elementor re-evaluates them
189 // automatically when JS calls container.settings.set('ecs_active_type', type).
190
191 // ── Slider settings ───────────────────────────────────────────────────
192 $element->add_responsive_control(
193 'ecs_slider_columns',
194 [
195 'label' => esc_html__( 'Slides Visible', 'ele-custom-skin' ),
196 'type' => \Elementor\Controls_Manager::SELECT,
197 'default' => '1',
198 'tablet_default' => '',
199 'mobile_default' => '',
200 'options' => [
201 '' => esc_html__( '— Inherit —', 'ele-custom-skin' ),
202 '1' => '1',
203 '2' => '2',
204 '3' => '3',
205 '4' => '4',
206 ],
207 'condition' => [ 'ecs_active_type' => 'slider' ],
208 'selectors' => [
209 '{{WRAPPER}}' => '--ecs-slider-columns: {{VALUE}};',
210 ],
211 'separator' => 'before',
212 'frontend_available' => true,
213 ]
214 );
215
216 // ── Slider behavior ───────────────────────────────────────────────────
217 $element->add_control(
218 'ecs_loop',
219 [
220 'label' => esc_html__( 'Infinite Loop', 'ele-custom-skin' ),
221 'type' => \Elementor\Controls_Manager::SWITCHER,
222 'default' => 'yes',
223 'condition' => [ 'ecs_active_type' => 'slider' ],
224 'frontend_available' => true,
225 ]
226 );
227
228 $element->add_control(
229 'ecs_autoplay',
230 [
231 'label' => esc_html__( 'Autoplay', 'ele-custom-skin' ),
232 'type' => \Elementor\Controls_Manager::SWITCHER,
233 'default' => '',
234 'condition' => [ 'ecs_active_type' => 'slider' ],
235 'frontend_available' => true,
236 ]
237 );
238
239 $element->add_control(
240 'ecs_autoplay_speed',
241 [
242 'label' => esc_html__( 'Autoplay Speed (ms)', 'ele-custom-skin' ),
243 'type' => \Elementor\Controls_Manager::NUMBER,
244 'default' => 3000,
245 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_autoplay' => 'yes' ],
246 'frontend_available' => true,
247 ]
248 );
249
250 $element->add_control(
251 'ecs_pause_on_hover',
252 [
253 'label' => esc_html__( 'Pause on Hover', 'ele-custom-skin' ),
254 'type' => \Elementor\Controls_Manager::SWITCHER,
255 'default' => 'yes',
256 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_autoplay' => 'yes' ],
257 'frontend_available' => true,
258 ]
259 );
260
261 $element->add_control(
262 'ecs_speed',
263 [
264 'label' => esc_html__( 'Transition Speed (ms)', 'ele-custom-skin' ),
265 'type' => \Elementor\Controls_Manager::NUMBER,
266 'default' => 500,
267 'condition' => [ 'ecs_active_type' => 'slider' ],
268 'frontend_available' => true,
269 ]
270 );
271
272 $element->add_control(
273 'ecs_space_between',
274 [
275 'label' => esc_html__( 'Space Between (px)', 'ele-custom-skin' ),
276 'type' => \Elementor\Controls_Manager::NUMBER,
277 'default' => 0,
278 'condition' => [ 'ecs_active_type' => 'slider' ],
279 'frontend_available' => true,
280 ]
281 );
282
283 $element->add_control(
284 'ecs_navigation',
285 [
286 'label' => esc_html__( 'Navigation', 'ele-custom-skin' ),
287 'type' => \Elementor\Controls_Manager::SELECT,
288 'default' => 'arrows',
289 'options' => [
290 'none' => esc_html__( 'None', 'ele-custom-skin' ),
291 'arrows' => esc_html__( 'Arrows', 'ele-custom-skin' ),
292 'dots' => esc_html__( 'Dots', 'ele-custom-skin' ),
293 'both' => esc_html__( 'Arrows & Dots', 'ele-custom-skin' ),
294 ],
295 'condition' => [ 'ecs_active_type' => 'slider' ],
296 'frontend_available' => true,
297 ]
298 );
299
300 // ── Custom Layout settings ────────────────────────────────────────────
301 $element->add_control(
302 'ecs_custom_layout_id',
303 [
304 'label' => esc_html__( 'Custom Layout Template', 'ele-custom-skin' ),
305 'type' => \Elementor\Controls_Manager::SELECT2,
306 'options' => $this->get_custom_layout_templates(),
307 'condition' => [ 'ecs_active_type' => 'custom' ],
308 'separator' => 'before',
309 ]
310 );
311
312 $element->add_control(
313 'ecs_hide_empty_slots',
314 [
315 'label' => esc_html__( 'Hide empty slots', 'ele-custom-skin' ),
316 'type' => \Elementor\Controls_Manager::SWITCHER,
317 'return_value' => 'hide-empty-slots',
318 'default' => '',
319 'prefix_class' => 'e-ecs-',
320 'condition' => [ 'ecs_active_type' => 'custom' ],
321 ]
322 );
323
324 $element->add_control(
325 'ecs_direction',
326 [
327 'label' => esc_html__( 'Direction', 'ele-custom-skin' ),
328 'type' => \Elementor\Controls_Manager::CHOOSE,
329 'options' => [
330 'row' => [ 'title' => esc_html__( 'Row', 'ele-custom-skin' ), 'icon' => 'eicon-arrow-right' ],
331 'column' => [ 'title' => esc_html__( 'Column', 'ele-custom-skin' ), 'icon' => 'eicon-arrow-down' ],
332 'row-reverse' => [ 'title' => esc_html__( 'Row - Reverse', 'ele-custom-skin' ), 'icon' => 'eicon-arrow-left' ],
333 'column-reverse' => [ 'title' => esc_html__( 'Column - Reverse', 'ele-custom-skin' ), 'icon' => 'eicon-arrow-up' ],
334 ],
335 'default' => 'row',
336 'condition' => [ 'ecs_active_type' => 'custom' ],
337 'selectors' => [ '{{WRAPPER}}' => 'flex-direction: {{VALUE}};' ],
338 ]
339 );
340 }
341
342 /**
343 * Add "Slider Navigation" style controls (arrows + dots) in the Style tab.
344 * Hooked after 'section_border' so it appears near the bottom of Style tab.
345 */
346 public function add_slider_style_controls( $element, $args ): void {
347 $nav_arrows = [ 'relation' => 'or', 'terms' => [
348 [ 'name' => 'ecs_navigation', 'operator' => '==', 'value' => 'arrows' ],
349 [ 'name' => 'ecs_navigation', 'operator' => '==', 'value' => 'both' ],
350 ] ];
351 $nav_dots = [ 'relation' => 'or', 'terms' => [
352 [ 'name' => 'ecs_navigation', 'operator' => '==', 'value' => 'dots' ],
353 [ 'name' => 'ecs_navigation', 'operator' => '==', 'value' => 'both' ],
354 ] ];
355
356 $element->start_controls_section(
357 'section_ecs_slider_navigation',
358 [
359 'label' => esc_html__( 'Slider Navigation', 'ele-custom-skin' ),
360 'tab' => \Elementor\Controls_Manager::TAB_STYLE,
361 'condition' => [ 'ecs_active_type' => 'slider' ],
362 ]
363 );
364
365 // ── Arrows ────────────────────────────────────────────────────────────
366 $element->add_control(
367 'ecs_arrows_heading',
368 [
369 'label' => esc_html__( 'Arrows', 'ele-custom-skin' ),
370 'type' => \Elementor\Controls_Manager::HEADING,
371 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'arrows', 'both' ] ],
372 ]
373 );
374
375 $element->add_control(
376 'ecs_arrows_position',
377 [
378 'label' => esc_html__( 'Position', 'ele-custom-skin' ),
379 'type' => \Elementor\Controls_Manager::SELECT,
380 'default' => 'inside',
381 'options' => [
382 'inside' => esc_html__( 'Inside', 'ele-custom-skin' ),
383 'outside' => esc_html__( 'Outside', 'ele-custom-skin' ),
384 ],
385 'prefix_class' => 'elementor-arrows-position-',
386 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'arrows', 'both' ] ],
387 ]
388 );
389
390 $element->add_responsive_control(
391 'ecs_arrows_size',
392 [
393 'label' => esc_html__( 'Size', 'ele-custom-skin' ),
394 'type' => \Elementor\Controls_Manager::SLIDER,
395 'size_units' => [ 'px' ],
396 'range' => [ 'px' => [ 'min' => 10, 'max' => 100 ] ],
397 'selectors' => [
398 '{{WRAPPER}} .elementor-swiper-button' => 'font-size: {{SIZE}}{{UNIT}};',
399 ],
400 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'arrows', 'both' ] ],
401 ]
402 );
403
404 $element->start_controls_tabs(
405 'ecs_arrows_colors',
406 [
407 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'arrows', 'both' ] ],
408 ]
409 );
410
411 $element->start_controls_tab(
412 'ecs_arrows_normal',
413 [ 'label' => esc_html__( 'Normal', 'ele-custom-skin' ) ]
414 );
415
416 $element->add_control(
417 'ecs_arrows_color',
418 [
419 'label' => esc_html__( 'Color', 'ele-custom-skin' ),
420 'type' => \Elementor\Controls_Manager::COLOR,
421 'selectors' => [
422 '{{WRAPPER}} .elementor-swiper-button' => 'color: {{VALUE}};',
423 ],
424 ]
425 );
426
427 $element->end_controls_tab();
428
429 $element->start_controls_tab(
430 'ecs_arrows_hover',
431 [ 'label' => esc_html__( 'Hover', 'ele-custom-skin' ) ]
432 );
433
434 $element->add_control(
435 'ecs_arrows_hover_color',
436 [
437 'label' => esc_html__( 'Color', 'ele-custom-skin' ),
438 'type' => \Elementor\Controls_Manager::COLOR,
439 'selectors' => [
440 '{{WRAPPER}} .elementor-swiper-button:hover' => 'color: {{VALUE}};',
441 ],
442 ]
443 );
444
445 $element->end_controls_tab();
446 $element->end_controls_tabs();
447
448 // ── Dots ──────────────────────────────────────────────────────────────
449 $element->add_control(
450 'ecs_dots_heading',
451 [
452 'label' => esc_html__( 'Dots', 'ele-custom-skin' ),
453 'type' => \Elementor\Controls_Manager::HEADING,
454 'separator' => 'before',
455 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'dots', 'both' ] ],
456 ]
457 );
458
459 $element->add_control(
460 'ecs_dots_position',
461 [
462 'label' => esc_html__( 'Position', 'ele-custom-skin' ),
463 'type' => \Elementor\Controls_Manager::SELECT,
464 'default' => 'inside',
465 'options' => [
466 'inside' => esc_html__( 'Inside', 'ele-custom-skin' ),
467 'outside' => esc_html__( 'Outside', 'ele-custom-skin' ),
468 ],
469 'prefix_class' => 'elementor-pagination-position-',
470 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'dots', 'both' ] ],
471 ]
472 );
473
474 $element->add_responsive_control(
475 'ecs_dots_size',
476 [
477 'label' => esc_html__( 'Size', 'ele-custom-skin' ),
478 'type' => \Elementor\Controls_Manager::SLIDER,
479 'size_units' => [ 'px' ],
480 'range' => [ 'px' => [ 'min' => 4, 'max' => 30 ] ],
481 'selectors' => [
482 '{{WRAPPER}} .swiper-pagination-bullet' => 'width: {{SIZE}}{{UNIT}}; height: {{SIZE}}{{UNIT}};',
483 ],
484 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'dots', 'both' ] ],
485 ]
486 );
487
488 $element->add_responsive_control(
489 'ecs_dots_gap',
490 [
491 'label' => esc_html__( 'Gap', 'ele-custom-skin' ),
492 'type' => \Elementor\Controls_Manager::SLIDER,
493 'size_units' => [ 'px' ],
494 'range' => [ 'px' => [ 'min' => 0, 'max' => 20 ] ],
495 'selectors' => [
496 '{{WRAPPER}} .swiper-pagination-bullet' => 'margin: 0 {{SIZE}}{{UNIT}};',
497 ],
498 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'dots', 'both' ] ],
499 ]
500 );
501
502 $element->add_control(
503 'ecs_dots_color',
504 [
505 'label' => esc_html__( 'Color', 'ele-custom-skin' ),
506 'type' => \Elementor\Controls_Manager::COLOR,
507 'selectors' => [
508 '{{WRAPPER}} .swiper-pagination-bullet' => 'background: {{VALUE}};',
509 ],
510 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'dots', 'both' ] ],
511 ]
512 );
513
514 $element->add_control(
515 'ecs_dots_active_color',
516 [
517 'label' => esc_html__( 'Active Color', 'ele-custom-skin' ),
518 'type' => \Elementor\Controls_Manager::COLOR,
519 'selectors' => [
520 '{{WRAPPER}} .swiper-pagination-bullet-active' => 'background: {{VALUE}};',
521 ],
522 'condition' => [ 'ecs_active_type' => 'slider', 'ecs_navigation' => [ 'dots', 'both' ] ],
523 ]
524 );
525
526 $element->end_controls_section();
527 }
528
529 /**
530 * Return an id → title map of all published DTE Custom Layout templates.
531 */
532 private function get_custom_layout_templates(): array {
533 $posts = get_posts( [
534 'post_type' => 'elementor_library',
535 'post_status' => 'publish',
536 'posts_per_page' => -1,
537 'meta_query' => [
538 [
539 'key' => '_elementor_template_type',
540 'value' => 'ecs_custom_layout',
541 ],
542 ],
543 ] );
544
545 $options = [ '' => esc_html__( '— Select Template —', 'ele-custom-skin' ) ];
546 foreach ( $posts as $post ) {
547 $options[ $post->ID ] = $post->post_title;
548 }
549 return $options;
550 }
551
552 // ── Render stack ──────────────────────────────────────────────────────────
553
554 /** @var array<int, array{mode:string, layout_id:int, id:string, buffering:bool}> */
555 private array $render_stack = [];
556
557 /**
558 * Before a container renders: start output buffering for custom_layout mode.
559 *
560 * We buffer the entire container HTML (wrapper + children) so we can
561 * extract children and inject them into the selected template.
562 */
563 public function before_container_render( $element ): void {
564 // Read DTE layout type; fall back to mapping from legacy container_type for backwards compat.
565 $ecs_type = $element->get_settings( 'ecs_container_type' ) ?: '';
566 if ( ! $ecs_type || 'flex' === $ecs_type ) {
567 $old_type = $element->get_settings( 'container_type' ) ?: 'flex';
568 if ( 'ecs-slider' === $old_type ) {
569 $ecs_type = 'slider';
570 } elseif ( 'ecs-custom' === $old_type ) {
571 $ecs_type = 'custom';
572 } else {
573 $ecs_type = $ecs_type ?: 'flex';
574 }
575 }
576 $mode = $ecs_type;
577 $layout_id = absint( $element->get_settings( 'ecs_custom_layout_id' ) );
578
579 $frame = [
580 'mode' => $mode,
581 'layout_id' => $layout_id,
582 'id' => $element->get_id(),
583 'buffering' => false,
584 'filter_removed' => false,
585 ];
586
587 if ( 'slider' === $mode ) {
588 // Skip buffering in the editor — JS handles preview there (like ecs-custom).
589 $is_edit = \Elementor\Plugin::$instance->editor->is_edit_mode();
590 if ( ! $is_edit ) {
591 $frame['filter_removed'] = has_filter( 'elementor/element/should_render_shortcode', '__return_true' );
592 if ( $frame['filter_removed'] ) {
593 remove_filter( 'elementor/element/should_render_shortcode', '__return_true' );
594 }
595 ob_start();
596 $frame['buffering'] = true;
597 }
598 } elseif ( 'custom' === $mode ) {
599 // Skip buffering in the editor — the JS preview handles it there.
600 $is_edit = \Elementor\Plugin::$instance->editor->is_edit_mode();
601
602 $will_buffer = ! $is_edit
603 && $layout_id > 0
604 && ! ECS_Container_Placeholder_Widget::is_rendering( $element->get_id() );
605
606 if ( $will_buffer ) {
607 // Remove the shortcode-rendering filter so ALL children render as HTML
608 // into the buffer. Without this, widgets with is_dynamic_content()=true
609 // (e.g. Text Editor) emit [elementor-element] shortcodes during
610 // Elementor's cache-build pass, and split_children() misses them.
611 $frame['filter_removed'] = has_filter( 'elementor/element/should_render_shortcode', '__return_true' );
612 if ( $frame['filter_removed'] ) {
613 remove_filter( 'elementor/element/should_render_shortcode', '__return_true' );
614 }
615 ob_start();
616 $frame['buffering'] = true;
617 }
618 } elseif ( in_array( $mode, [ 'flex', 'grid' ], true ) ) {
619 // Desktop is flex/grid but tablet or mobile may need custom layout / slider.
620 // Buffer the container so we can append the responsive alternative versions.
621 $tab_type = $element->get_settings( 'ecs_container_type_tablet' ) ?: '';
622 $mob_type = $element->get_settings( 'ecs_container_type_mobile' ) ?: '';
623 if ( ( in_array( 'custom', [ $tab_type, $mob_type ], true )
624 || in_array( 'slider', [ $tab_type, $mob_type ], true ) )
625 && ! \Elementor\Plugin::$instance->editor->is_edit_mode()
626 && ! ECS_Container_Placeholder_Widget::is_rendering( $element->get_id() )
627 ) {
628 $frame['filter_removed'] = has_filter( 'elementor/element/should_render_shortcode', '__return_true' );
629 if ( $frame['filter_removed'] ) {
630 remove_filter( 'elementor/element/should_render_shortcode', '__return_true' );
631 }
632 ob_start();
633 $frame['buffering'] = true;
634 $frame['mode'] = 'flex_responsive';
635 $frame['tab_type'] = $tab_type;
636 $frame['mob_type'] = $mob_type;
637 }
638 }
639
640 $this->render_stack[] = $frame;
641 }
642
643 /**
644 * After a container renders: replace buffered output with template + children.
645 *
646 * Flow:
647 * 1. ob_get_clean() captures the full container HTML
648 * 2. Extract children from .e-con-inner / Full Width wrapper and split into elements
649 * 3. Pass ordered batch to ECS_Container_Placeholder_Widget via static context
650 * 4. Render the selected DTE Custom Layout template (repeat for each overflow batch)
651 * – each placeholder consumes the next child in sequence
652 * – if children_count < placeholders_count → extra placeholders stay empty
653 * – if children_count > placeholders_count → template re-renders with overflow children
654 * 5. If no placeholder was found in the template, show fallback error
655 */
656 public function after_container_render( $element ): void {
657 $frame = array_pop( $this->render_stack );
658
659 if ( empty( $frame['buffering'] ) ) {
660 return;
661 }
662
663 $container_html = ob_get_clean();
664
665 if ( 'slider' === $frame['mode'] ) {
666 $this->render_slider( $element, $container_html, $frame );
667 return;
668 }
669
670 if ( 'flex_responsive' === $frame['mode'] ) {
671 $this->render_flex_with_responsive( $element, $container_html, $frame );
672 return;
673 }
674
675 $layout_id = $frame['layout_id'];
676 $container_id = $frame['id'];
677
678 // Safety: verify the template post exists and is published.
679 if ( ! $layout_id || 'publish' !== get_post_status( $layout_id ) ) {
680 echo $container_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
681 return;
682 }
683
684 // Preserve outer container opening tag — keeps all Elementor + ECS responsive classes.
685 preg_match( '/<div\b[^>]*>/i', $container_html, $outer_m );
686 $outer_tag = $outer_m[0] ?? '<div>';
687 echo $outer_tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
688
689 // Split children HTML into individual top-level elements.
690 $inner_html = $this->extract_container_children( $container_html );
691 $children_list = $this->split_children( $inner_html );
692
693 // Responsive overrides — used to decide whether to emit a second HTML version.
694 $tablet_type = $element->get_settings( 'ecs_container_type_tablet' ) ?: '';
695 $mobile_type = $element->get_settings( 'ecs_container_type_mobile' ) ?: '';
696 $has_responsive = $tablet_type || $mobile_type;
697
698 // ── Desktop version: template with children injected into slots ───────────
699
700 ECS_Container_Placeholder_Widget::mark_rendering( $container_id );
701
702 // Inline template CSS before first render.
703 $css_path = WP_CONTENT_DIR . '/uploads/elementor/css/post-' . $layout_id . '.css';
704 if ( file_exists( $css_path ) ) {
705 echo '<style id="elementor-post-' . esc_attr( $layout_id ) . '-css">' . file_get_contents( $css_path ) . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
706 }
707
708 $hide_empty = ( 'hide-empty-slots' === $element->get_settings( 'ecs_hide_empty_slots' ) );
709 $direction = $element->get_settings( 'ecs_direction' ) ?: 'row';
710 $batch = $children_list;
711 $first_pass = true;
712
713 $filter_was_active = has_filter( 'elementor/element/should_render_shortcode', '__return_true' );
714 if ( $filter_was_active ) {
715 remove_filter( 'elementor/element/should_render_shortcode', '__return_true' );
716 }
717
718 // When responsive overrides exist, wrap the desktop version so CSS can hide it.
719 if ( $has_responsive ) {
720 echo '<div class="ecs-custom-version">';
721 }
722
723 $wrapper_classes = 'ecs-custom-layout-wrap' . ( $hide_empty ? ' e-ecs-hide-empty-slots' : '' );
724 echo '<div class="' . esc_attr( $wrapper_classes ) . '" style="display:flex;flex-direction:' . esc_attr( $direction ) . ';">';
725
726 do {
727 ECS_Container_Placeholder_Widget::set_pending_children( $batch );
728 $output = \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $layout_id, true );
729 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
730
731 if ( $first_pass && ! ECS_Container_Placeholder_Widget::any_consumed() && ! empty( $children_list ) ) {
732 ECS_Container_Placeholder_Widget::reset_pending_children();
733 echo '<div class="ecs-missing-placeholder">' . implode( '', $children_list ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
734 break;
735 }
736
737 $batch = ECS_Container_Placeholder_Widget::get_overflow_children();
738 $first_pass = false;
739 ECS_Container_Placeholder_Widget::reset_pending_children();
740 } while ( ! empty( $batch ) );
741
742 echo '</div>'; // .ecs-custom-layout-wrap
743
744 if ( $has_responsive ) {
745 echo '</div>'; // .ecs-custom-version
746 }
747
748 if ( $filter_was_active ) {
749 add_filter( 'elementor/element/should_render_shortcode', '__return_true' );
750 }
751
752 ECS_Container_Placeholder_Widget::unmark_rendering( $container_id );
753
754 // ── Responsive version: children directly as grid or slider ───────────────
755
756 if ( $has_responsive && ! empty( $children_list ) ) {
757 $needs_slider = in_array( 'slider', [ $tablet_type, $mobile_type ], true );
758
759 echo '<div class="ecs-responsive-version">';
760
761 if ( $needs_slider ) {
762 // Build Swiper — breakpoints enable/disable slider per device.
763 $cols_desktop = (int) ( $element->get_settings( 'ecs_slider_columns' ) ?: 1 );
764 $cols_tablet = (int) $element->get_settings( 'ecs_slider_columns_tablet' ) ?: $cols_desktop;
765 $cols_mobile = (int) $element->get_settings( 'ecs_slider_columns_mobile' ) ?: $cols_tablet;
766 $navigation = $element->get_settings( 'ecs_navigation' ) ?: 'arrows';
767 $show_arrows = in_array( $navigation, [ 'arrows', 'both' ], true );
768 $show_dots = in_array( $navigation, [ 'dots', 'both' ], true );
769
770 $swiper_cfg = [
771 'slidesPerView' => $cols_desktop,
772 'loop' => 'yes' === $element->get_settings( 'ecs_loop' ),
773 'speed' => (int) ( $element->get_settings( 'ecs_speed' ) ?: 500 ),
774 'spaceBetween' => (int) $element->get_settings( 'ecs_space_between' ),
775 'breakpoints' => [
776 0 => [
777 'slidesPerView' => $cols_mobile,
778 'enabled' => 'slider' === $mobile_type,
779 ],
780 768 => [
781 'slidesPerView' => $cols_tablet,
782 'enabled' => 'slider' === $tablet_type,
783 ],
784 1025 => [ 'enabled' => false ], // desktop uses custom-version
785 ],
786 ];
787
788 if ( $show_arrows ) { $swiper_cfg['navigation'] = true; }
789 if ( $show_dots ) { $swiper_cfg['pagination'] = [ 'clickable' => true ]; }
790
791 if ( 'yes' === $element->get_settings( 'ecs_autoplay' ) ) {
792 $swiper_cfg['autoplay'] = [
793 'delay' => (int) ( $element->get_settings( 'ecs_autoplay_speed' ) ?: 3000 ),
794 'pauseOnMouseEnter' => 'yes' === $element->get_settings( 'ecs_pause_on_hover' ),
795 'disableOnInteraction' => false,
796 ];
797 }
798
799 echo '<div class="swiper ecs-swiper" data-ecs-slider-settings="' . esc_attr( wp_json_encode( $swiper_cfg ) ) . '">';
800 echo '<div class="swiper-wrapper">';
801 foreach ( $children_list as $child ) {
802 echo '<div class="swiper-slide">' . $child . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
803 }
804 echo '</div>'; // .swiper-wrapper
805
806 if ( $show_arrows ) {
807 echo '<div class="elementor-swiper-button elementor-swiper-button-prev" role="button" tabindex="0">'
808 . '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><path d="M15.5 5 8.5 12.5 15.5 20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'
809 . '</div>';
810 echo '<div class="elementor-swiper-button elementor-swiper-button-next" role="button" tabindex="0">'
811 . '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><path d="M9.5 5 16.5 12.5 9.5 20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'
812 . '</div>';
813 }
814 if ( $show_dots ) {
815 echo '<div class="swiper-pagination"></div>';
816 }
817
818 echo '</div>'; // .ecs-swiper
819
820 } else {
821 // Grid or flex — plain children container; CSS applies layout.
822 echo '<div class="ecs-resp-children">';
823 foreach ( $children_list as $child ) {
824 echo $child; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
825 }
826 echo '</div>'; // .ecs-resp-children
827 }
828
829 echo '</div>'; // .ecs-responsive-version
830 }
831
832 echo '</div>'; // outer .e-con
833
834 // Restore the filter removed at buffer start.
835 if ( ! empty( $frame['filter_removed'] ) ) {
836 add_filter( 'elementor/element/should_render_shortcode', '__return_true' );
837 }
838 }
839
840 /**
841 * Rebuild buffered container HTML as a Swiper slider.
842 *
843 * Preserves the outer container div (with all Elementor classes), wraps
844 * each direct child in a .swiper-slide, and appends navigation elements.
845 * Swiper is initialised by ecs-slider.js on the frontend.
846 */
847 private function render_slider( $element, string $container_html, array $frame ): void {
848 // Restore filter removed during buffering.
849 if ( ! empty( $frame['filter_removed'] ) ) {
850 add_filter( 'elementor/element/should_render_shortcode', '__return_true' );
851 }
852
853 // 1. Preserve outer opening tag (with all Elementor classes/attributes).
854 preg_match( '/<div\b[^>]*>/i', $container_html, $outer_m );
855 $outer_tag = $outer_m[0] ?? '<div>';
856
857 // 2. Extract and split children.
858 $inner_html = $this->extract_container_children( $container_html );
859 $children_list = $this->split_children( $inner_html );
860
861 // 3. Build Swiper settings from element controls.
862 $navigation = $element->get_settings( 'ecs_navigation' ) ?: 'arrows';
863 $show_arrows = in_array( $navigation, [ 'arrows', 'both' ], true );
864 $show_dots = in_array( $navigation, [ 'dots', 'both' ], true );
865 $autoplay_on = 'yes' === $element->get_settings( 'ecs_autoplay' );
866
867 // Responsive columns ('' = inherit from larger breakpoint).
868 $cols_desktop = (int) ( $element->get_settings( 'ecs_slider_columns' ) ?: 1 );
869 $cols_tablet = (int) $element->get_settings( 'ecs_slider_columns_tablet' );
870 $cols_mobile = (int) $element->get_settings( 'ecs_slider_columns_mobile' );
871 $cols_tablet = $cols_tablet ?: $cols_desktop;
872 $cols_mobile = $cols_mobile ?: $cols_tablet;
873
874 // Layout overrides ('' = inherit; 'flex'|'grid' = disable Swiper at that breakpoint).
875 $layout_tablet = $element->get_settings( 'ecs_container_type_tablet' ) ?: '';
876 $layout_mobile = $element->get_settings( 'ecs_container_type_mobile' ) ?: '';
877
878 $swiper_settings = [
879 'slidesPerView' => $cols_desktop,
880 'loop' => 'yes' === $element->get_settings( 'ecs_loop' ),
881 'speed' => (int) ( $element->get_settings( 'ecs_speed' ) ?: 500 ),
882 'spaceBetween' => (int) $element->get_settings( 'ecs_space_between' ),
883 'breakpoints' => [
884 0 => [
885 'slidesPerView' => $cols_mobile,
886 'enabled' => ! in_array( $layout_mobile, [ 'flex', 'grid' ], true ),
887 ],
888 768 => [
889 'slidesPerView' => $cols_tablet,
890 'enabled' => ! in_array( $layout_tablet, [ 'flex', 'grid' ], true ),
891 ],
892 1025 => [
893 'slidesPerView' => $cols_desktop,
894 'enabled' => true,
895 ],
896 ],
897 ];
898
899 if ( $autoplay_on ) {
900 $swiper_settings['autoplay'] = [
901 'delay' => (int) ( $element->get_settings( 'ecs_autoplay_speed' ) ?: 3000 ),
902 'pauseOnMouseEnter' => 'yes' === $element->get_settings( 'ecs_pause_on_hover' ),
903 'disableOnInteraction' => false,
904 ];
905 }
906
907 if ( $show_arrows ) {
908 $swiper_settings['navigation'] = true;
909 }
910
911 if ( $show_dots ) {
912 $swiper_settings['pagination'] = [ 'clickable' => true ];
913 }
914
915 // 4. Output HTML.
916 echo $outer_tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
917 echo '<div class="swiper ecs-swiper" data-ecs-slider-settings="' . esc_attr( wp_json_encode( $swiper_settings ) ) . '">';
918 echo '<div class="swiper-wrapper">';
919
920 foreach ( $children_list as $child ) {
921 echo '<div class="swiper-slide">' . $child . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
922 }
923
924 echo '</div>'; // .swiper-wrapper
925
926 if ( $show_arrows ) {
927 echo '<div class="elementor-swiper-button elementor-swiper-button-prev" role="button" tabindex="0">'
928 . '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><path d="M15.5 5 8.5 12.5 15.5 20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'
929 . '</div>';
930 echo '<div class="elementor-swiper-button elementor-swiper-button-next" role="button" tabindex="0">'
931 . '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><path d="M9.5 5 16.5 12.5 9.5 20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'
932 . '</div>';
933 }
934
935 if ( $show_dots ) {
936 echo '<div class="swiper-pagination"></div>';
937 }
938
939 echo '</div>'; // .swiper
940 echo '</div>'; // outer container
941 }
942
943 /**
944 * Desktop=flex/grid with tablet/mobile=custom|slider.
945 * Re-emits the buffered container as-is for desktop, then appends one or two
946 * responsive-version blocks that CSS shows at the appropriate breakpoints.
947 */
948 private function render_flex_with_responsive( $element, string $container_html, array $frame ): void {
949 if ( ! empty( $frame['filter_removed'] ) ) {
950 add_filter( 'elementor/element/should_render_shortcode', '__return_true' );
951 }
952
953 $tab_type = $frame['tab_type'] ?? '';
954 $mob_type = $frame['mob_type'] ?? '';
955 $layout_id = absint( $element->get_settings( 'ecs_custom_layout_id' ) );
956 $container_id = $frame['id'];
957
958 // Outer opening tag.
959 preg_match( '/<div\b[^>]*>/i', $container_html, $outer_m, PREG_OFFSET_CAPTURE );
960 $outer_tag = $outer_m[0][0] ?? '<div>';
961 $after_outer_pos = (int) $outer_m[0][1] + strlen( $outer_tag );
962
963 // Desktop inner: everything between the outer opening tag and the last </div>.
964 $last_close_pos = strrpos( $container_html, '</div>' );
965 $desktop_inner = ( false !== $last_close_pos )
966 ? substr( $container_html, $after_outer_pos, $last_close_pos - $after_outer_pos )
967 : substr( $container_html, $after_outer_pos );
968
969 // Full Width containers have no .e-con-inner — wrap in a hide-able div.
970 if ( ! preg_match( '/<div\b[^>]*\be-con-inner\b/', $desktop_inner ) ) {
971 $desktop_inner = '<div class="ecs-desktop-inner">' . $desktop_inner . '</div>';
972 }
973
974 $inner_html = $this->extract_container_children( $container_html );
975 $children_list = $this->split_children( $inner_html );
976
977 echo $outer_tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
978 echo $desktop_inner; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
979
980 // When tablet and mobile share the same type (or mobile inherits), one block suffices.
981 $mob_resolved = $mob_type ?: $tab_type;
982 $same_type = ( '' === $mob_type || $mob_type === $tab_type );
983
984 if ( $same_type ) {
985 echo '<div class="ecs-responsive-version">';
986 if ( 'custom' === $tab_type ) {
987 $this->echo_custom_layout_content( $element, $children_list, $layout_id, $container_id );
988 } elseif ( 'slider' === $tab_type ) {
989 $this->echo_swiper_html( $element, $children_list, $tab_type, $mob_resolved );
990 }
991 echo '</div>';
992 } else {
993 // Different types for tablet vs mobile — separate blocks.
994 if ( 'custom' === $tab_type || 'slider' === $tab_type ) {
995 echo '<div class="ecs-tablet-version">';
996 if ( 'custom' === $tab_type ) {
997 $this->echo_custom_layout_content( $element, $children_list, $layout_id, $container_id );
998 } else {
999 $this->echo_swiper_html( $element, $children_list, 'slider', '' );
1000 }
1001 echo '</div>';
1002 }
1003 if ( 'custom' === $mob_type || 'slider' === $mob_type ) {
1004 echo '<div class="ecs-mobile-version">';
1005 if ( 'custom' === $mob_type ) {
1006 $this->echo_custom_layout_content( $element, $children_list, $layout_id, $container_id );
1007 } else {
1008 $this->echo_swiper_html( $element, $children_list, '', 'slider' );
1009 }
1010 echo '</div>';
1011 }
1012 }
1013
1014 echo '</div>'; // close outer .e-con
1015 }
1016
1017 /**
1018 * Render a custom layout template with children injected into placeholder slots.
1019 * Used by both after_container_render (desktop=custom) and render_flex_with_responsive.
1020 *
1021 * @param array<string> $children_list Rendered HTML of each child element.
1022 */
1023 private function echo_custom_layout_content( $element, array $children_list, int $layout_id, string $container_id ): void {
1024 if ( ! $layout_id || 'publish' !== get_post_status( $layout_id ) ) {
1025 foreach ( $children_list as $child ) {
1026 echo $child; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1027 }
1028 return;
1029 }
1030
1031 if ( ! class_exists( 'ECS_Container_Placeholder_Widget', false ) ) {
1032 require_once $this->module_path() . 'widgets/class-ecs-container-placeholder-widget.php';
1033 }
1034
1035 $hide_empty = ( 'hide-empty-slots' === $element->get_settings( 'ecs_hide_empty_slots' ) );
1036 $direction = $element->get_settings( 'ecs_direction' ) ?: 'row';
1037
1038 $css_path = WP_CONTENT_DIR . '/uploads/elementor/css/post-' . $layout_id . '.css';
1039 if ( file_exists( $css_path ) ) {
1040 echo '<style id="elementor-post-' . esc_attr( $layout_id ) . '-css">'
1041 . file_get_contents( $css_path ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,WordPress.Security.EscapeOutput.OutputNotEscaped
1042 . '</style>';
1043 }
1044
1045 ECS_Container_Placeholder_Widget::mark_rendering( $container_id );
1046
1047 $filter_was_active = has_filter( 'elementor/element/should_render_shortcode', '__return_true' );
1048 if ( $filter_was_active ) {
1049 remove_filter( 'elementor/element/should_render_shortcode', '__return_true' );
1050 }
1051
1052 $wrapper_classes = 'ecs-custom-layout-wrap' . ( $hide_empty ? ' e-ecs-hide-empty-slots' : '' );
1053 echo '<div class="' . esc_attr( $wrapper_classes ) . '" style="display:flex;flex-direction:' . esc_attr( $direction ) . ';">';
1054
1055 $batch = $children_list;
1056 $first_pass = true;
1057 do {
1058 ECS_Container_Placeholder_Widget::set_pending_children( $batch );
1059 $output = \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $layout_id, true );
1060 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1061
1062 if ( $first_pass && ! ECS_Container_Placeholder_Widget::any_consumed() && ! empty( $children_list ) ) {
1063 ECS_Container_Placeholder_Widget::reset_pending_children();
1064 echo '<div class="ecs-missing-placeholder">' . implode( '', $children_list ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1065 break;
1066 }
1067 $batch = ECS_Container_Placeholder_Widget::get_overflow_children();
1068 $first_pass = false;
1069 ECS_Container_Placeholder_Widget::reset_pending_children();
1070 } while ( ! empty( $batch ) );
1071
1072 echo '</div>'; // .ecs-custom-layout-wrap
1073
1074 if ( $filter_was_active ) {
1075 add_filter( 'elementor/element/should_render_shortcode', '__return_true' );
1076 }
1077
1078 ECS_Container_Placeholder_Widget::unmark_rendering( $container_id );
1079 }
1080
1081 /**
1082 * Output the Swiper HTML for responsive slider versions.
1083 * Breakpoints control enabled/disabled per device; CSS hides the wrapper block entirely.
1084 *
1085 * @param string $tab_type 'slider'|'' — type resolved for tablet
1086 * @param string $mob_type 'slider'|'' — type resolved for mobile ('' = inherit tab_type)
1087 */
1088 private function echo_swiper_html( $element, array $children_list, string $tab_type, string $mob_type ): void {
1089 $mob_resolved = $mob_type ?: $tab_type;
1090 $cols_desktop = (int) ( $element->get_settings( 'ecs_slider_columns' ) ?: 1 );
1091 $cols_tablet = (int) $element->get_settings( 'ecs_slider_columns_tablet' ) ?: $cols_desktop;
1092 $cols_mobile = (int) $element->get_settings( 'ecs_slider_columns_mobile' ) ?: $cols_tablet;
1093 $navigation = $element->get_settings( 'ecs_navigation' ) ?: 'arrows';
1094 $show_arrows = in_array( $navigation, [ 'arrows', 'both' ], true );
1095 $show_dots = in_array( $navigation, [ 'dots', 'both' ], true );
1096
1097 $swiper_cfg = [
1098 'slidesPerView' => $cols_desktop,
1099 'loop' => 'yes' === $element->get_settings( 'ecs_loop' ),
1100 'speed' => (int) ( $element->get_settings( 'ecs_speed' ) ?: 500 ),
1101 'spaceBetween' => (int) $element->get_settings( 'ecs_space_between' ),
1102 'breakpoints' => [
1103 0 => [
1104 'slidesPerView' => $cols_mobile,
1105 'enabled' => 'slider' === $mob_resolved,
1106 ],
1107 768 => [
1108 'slidesPerView' => $cols_tablet,
1109 'enabled' => 'slider' === $tab_type,
1110 ],
1111 1025 => [ 'enabled' => false ],
1112 ],
1113 ];
1114
1115 if ( 'yes' === $element->get_settings( 'ecs_autoplay' ) ) {
1116 $swiper_cfg['autoplay'] = [
1117 'delay' => (int) ( $element->get_settings( 'ecs_autoplay_speed' ) ?: 3000 ),
1118 'pauseOnMouseEnter' => 'yes' === $element->get_settings( 'ecs_pause_on_hover' ),
1119 'disableOnInteraction' => false,
1120 ];
1121 }
1122 if ( $show_arrows ) { $swiper_cfg['navigation'] = true; }
1123 if ( $show_dots ) { $swiper_cfg['pagination'] = [ 'clickable' => true ]; }
1124
1125 echo '<div class="swiper ecs-swiper" data-ecs-slider-settings="' . esc_attr( wp_json_encode( $swiper_cfg ) ) . '">'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1126 echo '<div class="swiper-wrapper">';
1127 foreach ( $children_list as $child ) {
1128 echo '<div class="swiper-slide">' . $child . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1129 }
1130 echo '</div>'; // .swiper-wrapper
1131
1132 if ( $show_arrows ) {
1133 echo '<div class="elementor-swiper-button elementor-swiper-button-prev" role="button" tabindex="0">'
1134 . '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><path d="M15.5 5 8.5 12.5 15.5 20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'
1135 . '</div>';
1136 echo '<div class="elementor-swiper-button elementor-swiper-button-next" role="button" tabindex="0">'
1137 . '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><path d="M9.5 5 16.5 12.5 9.5 20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>'
1138 . '</div>';
1139 }
1140 if ( $show_dots ) {
1141 echo '<div class="swiper-pagination"></div>';
1142 }
1143 echo '</div>'; // .ecs-swiper
1144 }
1145
1146 /**
1147 * Extract the direct-children HTML from a rendered container.
1148 *
1149 * Handles two Elementor layout modes:
1150 * - Boxed (Content Width = Boxed): children are inside .e-con-inner
1151 * - Full Width (Content Width = Full Width): children are direct children of .e-con
1152 *
1153 * @param string $html Full container HTML from output buffer.
1154 * @return string HTML fragment containing the direct child elements.
1155 */
1156 private function extract_container_children( string $html ): string {
1157 // Boxed mode: children are inside .e-con-inner.
1158 if ( preg_match(
1159 '/<div\b[^>]*\bclass=["\'][^"\']*\be-con-inner\b[^"\']*["\'][^>]*>/i',
1160 $html,
1161 $match,
1162 PREG_OFFSET_CAPTURE
1163 ) ) {
1164 return $this->extract_e_con_inner( $html );
1165 }
1166
1167 // Full Width mode: no .e-con-inner — children are direct children of the
1168 // outermost <div> in the buffer. Skip the outer wrapper and return its content.
1169 if ( ! preg_match( '/<div\b[^>]*>/i', $html, $outer_m, PREG_OFFSET_CAPTURE ) ) {
1170 return $html;
1171 }
1172
1173 $after_open = (int) $outer_m[0][1] + strlen( $outer_m[0][0] );
1174 $inner = substr( $html, $after_open );
1175
1176 preg_match_all( '/<div\b|<\/div>/i', $inner, $tokens, PREG_OFFSET_CAPTURE );
1177
1178 $depth = 1;
1179 foreach ( $tokens[0] as [ $token, $pos ] ) {
1180 if ( 0 === stripos( $token, '<div' ) ) {
1181 $depth++;
1182 } else {
1183 $depth--;
1184 if ( 0 === $depth ) {
1185 return substr( $inner, 0, $pos );
1186 }
1187 }
1188 }
1189
1190 return $inner;
1191 }
1192
1193 /**
1194 * Extract the inner HTML of the first .e-con-inner element.
1195 *
1196 * Uses a stack-based approach (count opening/closing <div> tags) to
1197 * reliably find the matching closing tag regardless of nesting depth.
1198 * Avoids DOMDocument which modifies encoding and adds DOCTYPE wrappers.
1199 *
1200 * @param string $html Full container HTML from output buffer.
1201 * @return string Inner HTML of .e-con-inner, or full $html as fallback.
1202 */
1203 private function extract_e_con_inner( string $html ): string {
1204 // Find the opening <div ... class="... e-con-inner ...">
1205 if ( ! preg_match(
1206 '/<div\b[^>]*\bclass=["\'][^"\']*\be-con-inner\b[^"\']*["\'][^>]*>/i',
1207 $html,
1208 $match,
1209 PREG_OFFSET_CAPTURE
1210 ) ) {
1211 return $html; // No e-con-inner found — return full HTML as fallback
1212 }
1213
1214 $after_open = (int) $match[0][1] + strlen( $match[0][0] );
1215 $inner = substr( $html, $after_open );
1216
1217 // Walk all <div and </div> tokens using \b to avoid matching <divider> etc.
1218 preg_match_all( '/<div\b|<\/div>/i', $inner, $tokens, PREG_OFFSET_CAPTURE );
1219
1220 $depth = 1;
1221 foreach ( $tokens[0] as [ $token, $pos ] ) {
1222 if ( 0 === stripos( $token, '<div' ) ) {
1223 $depth++;
1224 } else {
1225 $depth--;
1226 if ( 0 === $depth ) {
1227 return substr( $inner, 0, $pos );
1228 }
1229 }
1230 }
1231
1232 return $inner; // Unmatched tags — return inner content as fallback
1233 }
1234
1235 /**
1236 * Split a block of HTML into individual top-level <div> elements.
1237 *
1238 * Each Elementor child element is a <div>. We find each one at depth-0
1239 * using the same stack-based approach as extract_e_con_inner(), returning
1240 * an ordered array: [child_0_html, child_1_html, ...].
1241 *
1242 * @param string $html Inner HTML from .e-con-inner.
1243 * @return list<string> Ordered array of individual child HTML strings.
1244 */
1245 private function split_children( string $html ): array {
1246 $elements = [];
1247 $remaining = ltrim( $html );
1248
1249 while ( '' !== $remaining ) {
1250 // Find the next opening <div tag.
1251 if ( ! preg_match( '/<div\b[^>]*>/i', $remaining, $open_m, PREG_OFFSET_CAPTURE ) ) {
1252 break;
1253 }
1254
1255 $open_start = (int) $open_m[0][1];
1256 $open_len = strlen( $open_m[0][0] );
1257
1258 // Walk <div and </div> tokens after the opening tag to find the matching close.
1259 $after_open = substr( $remaining, $open_start + $open_len );
1260 preg_match_all( '/<div\b|<\/div>/i', $after_open, $tokens, PREG_OFFSET_CAPTURE );
1261
1262 $depth = 1;
1263 $close_pos = null;
1264 foreach ( $tokens[0] as [ $token, $pos ] ) {
1265 if ( 0 === stripos( $token, '<div' ) ) {
1266 $depth++;
1267 } else {
1268 $depth--;
1269 if ( 0 === $depth ) {
1270 $close_pos = (int) $pos;
1271 break;
1272 }
1273 }
1274 }
1275
1276 if ( null === $close_pos ) {
1277 // Unmatched — treat everything remaining as one element.
1278 $elements[] = trim( substr( $remaining, $open_start ) );
1279 break;
1280 }
1281
1282 $close_tag_len = 6; // strlen( '</div>' )
1283 $element_end = $open_start + $open_len + $close_pos + $close_tag_len;
1284 $elements[] = substr( $remaining, $open_start, $element_end - $open_start );
1285 $remaining = ltrim( substr( $remaining, $element_end ) );
1286 }
1287
1288 return array_values( array_filter( $elements, fn( $e ) => '' !== trim( $e ) ) );
1289 }
1290
1291 // ── Assets ────────────────────────────────────────────────────────────────
1292
1293 /**
1294 * AJAX handler: render a DTE Custom Layout template with caller-supplied
1295 * children HTML strings so the Elementor editor canvas can display a live
1296 * preview of the injection result.
1297 *
1298 * Security: nonce + capability check. Only logged-in users with edit_posts
1299 * can reach this handler (wp_ajax_ prefix requires authentication).
1300 */
1301 public function ajax_preview_layout(): void {
1302 check_ajax_referer( 'ecs-preview-layout', 'nonce' );
1303
1304 if ( ! current_user_can( 'edit_posts' ) ) {
1305 wp_send_json_error( [ 'message' => 'Unauthorized' ] );
1306 return;
1307 }
1308
1309 $layout_id = absint( $_POST['layout_id'] ?? 0 );
1310 $children = isset( $_POST['children'] ) && is_array( $_POST['children'] )
1311 ? array_values( wp_unslash( $_POST['children'] ) )
1312 : [];
1313
1314 if ( ! $layout_id || 'publish' !== get_post_status( $layout_id ) ) {
1315 wp_send_json_error( [ 'message' => 'Invalid template' ] );
1316 return;
1317 }
1318
1319 // Same filter guard as in after_container_render: prevent shortcode
1320 // re-emission when the element cache builder filter is active.
1321 $filter_was_active = has_filter( 'elementor/element/should_render_shortcode', '__return_true' );
1322 if ( $filter_was_active ) {
1323 remove_filter( 'elementor/element/should_render_shortcode', '__return_true' );
1324 }
1325
1326 // The widget class is loaded lazily via elementor/widgets/register, which
1327 // fires inside get_builder_content_for_display(). Since we need static
1328 // methods before that call, ensure the file is loaded explicitly here.
1329 if ( ! class_exists( 'ECS_Container_Placeholder_Widget', false ) ) {
1330 require_once $this->module_path() . 'widgets/class-ecs-container-placeholder-widget.php';
1331 }
1332
1333 // Cycle through children like after_container_render: each pass fills one
1334 // template instance; overflow children feed the next pass.
1335 $batch = $children;
1336 $first_pass = true;
1337 $output = '';
1338
1339 do {
1340 ECS_Container_Placeholder_Widget::set_pending_children( $batch );
1341
1342 $batch_html = \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $layout_id, false );
1343
1344 if ( $first_pass && ! ECS_Container_Placeholder_Widget::any_consumed() && ! empty( $children ) ) {
1345 // Template has no DTE Placeholder widget — send error fallback HTML.
1346 ECS_Container_Placeholder_Widget::reset_pending_children();
1347 $output = '<div class="ecs-missing-placeholder">' . implode( '', $children ) . '</div>';
1348 break;
1349 }
1350
1351 $output .= $batch_html;
1352 $batch = ECS_Container_Placeholder_Widget::get_overflow_children();
1353 $first_pass = false;
1354 ECS_Container_Placeholder_Widget::reset_pending_children();
1355
1356 } while ( ! empty( $batch ) );
1357
1358 if ( $filter_was_active ) {
1359 add_filter( 'elementor/element/should_render_shortcode', '__return_true' );
1360 }
1361
1362 wp_send_json_success( [ 'html' => $output ] );
1363 }
1364
1365 public function enqueue_frontend_assets(): void {
1366 wp_enqueue_style(
1367 'ecs-container-layout',
1368 $this->module_asset_url( 'assets/css/ecs-container-layout.css' ),
1369 [],
1370 ECS_VERSION
1371 );
1372
1373 // Swiper — bundled by Elementor, just enqueue the registered handles.
1374 wp_enqueue_script( 'swiper' );
1375 wp_enqueue_style( 'swiper' );
1376 wp_enqueue_style( 'e-swiper' );
1377
1378 wp_enqueue_script(
1379 'ecs-slider',
1380 $this->module_asset_url( 'assets/js/ecs-slider.js' ),
1381 [ 'swiper', 'elementor-frontend' ],
1382 ECS_VERSION,
1383 [ 'in_footer' => true ]
1384 );
1385 }
1386
1387 public function enqueue_editor_assets(): void {
1388 wp_enqueue_style(
1389 'ecs-container-layout-editor',
1390 $this->module_asset_url( 'assets/css/ecs-container-layout.css' ),
1391 [],
1392 ECS_VERSION
1393 );
1394
1395 wp_enqueue_script(
1396 'ecs-editor-preview',
1397 $this->module_asset_url( 'assets/js/ecs-editor-preview.js' ),
1398 [ 'elementor-editor' ],
1399 ECS_VERSION,
1400 true
1401 );
1402
1403 wp_localize_script(
1404 'ecs-editor-preview',
1405 'ecsEditorPreview',
1406 [
1407 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
1408 'nonce' => wp_create_nonce( 'ecs-preview-layout' ),
1409 ]
1410 );
1411
1412 // When the Responsive Container Layout module is NOT active this module
1413 // registers a simple (non-responsive) ecs_container_type control in
1414 // maybe_add_fallback_type_control(). We need to hide the native control and
1415 // wire a simple grid-sync here as well.
1416 if ( ! ECS_Core::instance()->modules()->is_active( 'container_responsive' ) ) {
1417 wp_add_inline_style(
1418 'ecs-container-layout-editor',
1419 '.elementor-control-container_type { display: none !important; }'
1420 );
1421
1422 // Minimal sync: keeps native container_type in step with ecs_container_type
1423 // so Elementor shows its grid controls when the user picks Grid.
1424 wp_add_inline_script( 'ecs-editor-preview', '
1425 ( function () {
1426 "use strict";
1427 elementor.hooks.addAction( "panel/open_editor/container", function ( mgr, model ) {
1428 var settings = model && model.get && model.get( "settings" );
1429 if ( ! settings ) { return; }
1430 function syncNative() {
1431 var dteCt = settings.get( "ecs_container_type" ) || "flex";
1432 var nat = ( "grid" === dteCt ) ? "grid" : "flex";
1433 if ( settings.get( "container_type" ) !== nat ) { settings.set( "container_type", nat ); }
1434 }
1435 if ( settings.get( "ecs_container_type" ) ) {
1436 syncNative();
1437 } else {
1438 settings.set( "ecs_container_type", settings.get( "container_type" ) || "flex", { silent: true } );
1439 }
1440 settings.on( "change:ecs_container_type", syncNative );
1441 } );
1442 } )();
1443 ' );
1444 }
1445 }
1446 }
1447