PluginProbe ʕ •ᴥ•ʔ
ECS – Ele Custom Skin for Elementor / 4.3.9
ECS – Ele Custom Skin for Elementor v4.3.9
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 / assets / js / ecs-loop-custom-layout-editor.js
ele-custom-skin / modules / loop-custom-layout / assets / js Last commit date
ecs-loop-custom-layout-editor.js 4 months ago
ecs-loop-custom-layout-editor.js
463 lines
1 /**
2 * ECS Loop Custom Layout — Editor Preview
3 *
4 * Runs in the Elementor editor parent frame (not the preview iframe).
5 *
6 * When a Loop Grid widget has ecs_use_custom_layout=yes:
7 * 1. Find it in the preview iframe.
8 * 2. Extract the rendered .e-loop-item elements.
9 * 3. POST items HTML + template_id to our AJAX handler.
10 * 4. Keep the original loop content hidden in .ecs-loop-original.
11 * 5. Show the server-rendered custom layout in .ecs-loop-injection.
12 * 6. Re-attach EPro's "Edit Template" document handle button.
13 * 7. Toggle between original / injection when in-place editing mode fires.
14 *
15 * State tracking:
16 * widget.dataset.ecsLoopState — djb2(templateId + items HTML)
17 * storedItemsMap — WeakMap caching items per widget so re-injection
18 * works even after innerHTML was replaced.
19 */
20
21 /* global ecsLoopPreview */
22 ( function () {
23 'use strict';
24
25 var iframeDoc = null;
26 var refreshTimer = null;
27 var childObserver = null; // childList observer on preview body
28 var classObserver = null; // attributes/class observer on loop-grid widgets
29 var suppressObserver = false;
30
31 /** @type {WeakMap<Element, {templateId: number, items: string[]}>} */
32 var storedItemsMap = new WeakMap();
33
34 // ── Bootstrap ─────────────────────────────────────────────────────────────
35
36 var initialized = false;
37 var channelBound = false;
38 var pollTimer = null;
39
40 function initListeners() {
41 if ( initialized || ! window.elementor ) {
42 return;
43 }
44 initialized = true;
45 clearInterval( pollTimer );
46
47 elementor.on( 'preview:loaded', onPreviewLoaded );
48
49 var iframe = elementor.$preview && elementor.$preview[ 0 ];
50 if ( iframe && iframe.contentDocument && iframe.contentDocument.body &&
51 iframe.contentDocument.body.children.length ) {
52 onPreviewLoaded();
53 }
54 }
55
56 if ( window.jQuery ) {
57 jQuery( window ).on( 'elementor:init', initListeners );
58 }
59 window.addEventListener( 'elementor:init', initListeners );
60 pollTimer = setInterval( function () {
61 if ( window.elementor ) {
62 initListeners();
63 }
64 }, 100 );
65
66 function onPreviewLoaded() {
67 var iframe = elementor.$preview && elementor.$preview[ 0 ];
68 if ( ! iframe ) {
69 return;
70 }
71 iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
72 if ( ! iframeDoc ) {
73 return;
74 }
75
76 // Loop Grid renders its items via its own server-side AJAX — wait for it.
77 setTimeout( injectAll, 1200 );
78
79 if ( ! channelBound ) {
80 channelBound = true;
81 elementor.channels.data.on( 'command:after', scheduleRefresh );
82 elementor.channels.editor.on( 'change', scheduleRefresh );
83 }
84
85 bindObservers();
86 }
87
88 // ── Observers ─────────────────────────────────────────────────────────────
89
90 /**
91 * childList observer: detect new loop items being inserted (EPro AJAX re-render).
92 * class observer: detect elementor-in-place-template-editable toggling.
93 */
94 function bindObservers() {
95 var Win = iframeDoc.defaultView;
96
97 if ( childObserver ) {
98 childObserver.disconnect();
99 }
100 childObserver = new Win.MutationObserver( function ( mutations ) {
101 if ( suppressObserver ) {
102 return;
103 }
104 var needRefresh = false;
105 mutations.forEach( function ( mutation ) {
106 if ( mutation.type !== 'childList' || ! mutation.addedNodes.length ) {
107 return;
108 }
109 var target = mutation.target;
110 var inLoopGrid = target.closest && target.closest( '.elementor-widget-loop-grid' );
111 if ( ! inLoopGrid ) {
112 return;
113 }
114 mutation.addedNodes.forEach( function ( node ) {
115 if ( node.nodeType === 1 ) {
116 needRefresh = true;
117 }
118 } );
119 } );
120 if ( needRefresh ) {
121 scheduleRefresh();
122 }
123 } );
124 childObserver.observe( iframeDoc.body, { childList: true, subtree: true } );
125
126 // Watch for elementor-in-place-template-editable class changes
127 // only on .elementor-widget-loop-grid elements.
128 if ( classObserver ) {
129 classObserver.disconnect();
130 }
131 classObserver = new Win.MutationObserver( function ( mutations ) {
132 mutations.forEach( function ( mutation ) {
133 if ( mutation.type !== 'attributes' || mutation.attributeName !== 'class' ) {
134 return;
135 }
136 var el = mutation.target;
137 if ( el.classList && el.classList.contains( 'elementor-widget-loop-grid' ) ) {
138 handleInPlaceEditToggle( el );
139 }
140 } );
141 } );
142 // Observe entire body but attribute changes only — attributeFilter limits cost.
143 classObserver.observe( iframeDoc.body, {
144 subtree: true,
145 attributes: true,
146 attributeFilter: [ 'class' ],
147 } );
148 }
149
150 // ── In-place editing toggle ───────────────────────────────────────────────
151
152 /**
153 * When EPro's "Edit Template" is clicked, it adds elementor-in-place-template-editable
154 * to the widget element. We switch to showing the original loop output so the
155 * in-place editing mode works normally. When the class is removed (edit done),
156 * we restore the custom layout injection.
157 */
158 function handleInPlaceEditToggle( widget ) {
159 if ( ! widget.classList.contains( 'ecs-loop-custom-preview-active' ) ) {
160 return;
161 }
162
163 var widgetContainer = widget.querySelector( ':scope > .elementor-widget-container' );
164 if ( ! widgetContainer ) {
165 return;
166 }
167 var origWrapper = widgetContainer.querySelector( ':scope > .ecs-loop-original' );
168 var injWrapper = widgetContainer.querySelector( ':scope > .ecs-loop-injection' );
169 if ( ! origWrapper || ! injWrapper ) {
170 return;
171 }
172
173 if ( widget.classList.contains( 'elementor-in-place-template-editable' ) ) {
174 // Entering in-place edit: show original loop items, hide our injection.
175 origWrapper.style.display = '';
176 injWrapper.style.display = 'none';
177 } else {
178 // Exiting in-place edit: invalidate state and re-inject.
179 origWrapper.style.display = 'none';
180 injWrapper.style.display = '';
181 // Invalidate so re-injection fetches fresh items (template may have changed).
182 delete widget.dataset.ecsLoopState;
183 storedItemsMap.delete( widget );
184 scheduleRefresh();
185 }
186 }
187
188 // ── Debounce ──────────────────────────────────────────────────────────────
189
190 function scheduleRefresh() {
191 clearTimeout( refreshTimer );
192 refreshTimer = setTimeout( injectAll, 400 );
193 }
194
195 // ── Injection orchestration ────────────────────────────────────────────────
196
197 function injectAll() {
198 if ( ! iframeDoc ) {
199 return;
200 }
201
202 // Clean up widgets that no longer have our setting enabled.
203 iframeDoc.querySelectorAll( '.ecs-loop-custom-preview-active' ).forEach( function ( widget ) {
204 if ( ! getTemplateId( widget ) ) {
205 cleanupWidget( widget );
206 }
207 } );
208
209 iframeDoc.querySelectorAll( '.elementor-widget-loop-grid' ).forEach( function ( widget ) {
210 var templateId = getTemplateId( widget );
211 if ( templateId ) {
212 injectWidget( widget, templateId );
213 } else if ( widget.classList.contains( 'ecs-loop-custom-preview-active' ) ) {
214 cleanupWidget( widget );
215 }
216 } );
217 }
218
219 // ── Per-widget injection ───────────────────────────────────────────────────
220
221 function injectWidget( widget, templateId ) {
222 // Skip if currently in in-place editing mode.
223 if ( widget.classList.contains( 'elementor-in-place-template-editable' ) ) {
224 return;
225 }
226
227 // Prefer live .e-loop-item nodes rendered by EPro directly in the widget
228 // (i.e. not inside our own wrappers, which would cause infinite re-injection).
229 var liveItems = Array.from( widget.querySelectorAll( '.e-loop-item' ) )
230 .filter( function ( el ) {
231 return ! el.closest( '.ecs-loop-injection' ) &&
232 ! el.closest( '.ecs-loop-original' );
233 } );
234
235 var items, itemsChanged;
236
237 if ( liveItems.length > 0 ) {
238 // Strip any .elementor-document-handle buttons from item HTML so we
239 // don't bake stale (event-listener-less) handle elements into the
240 // PHP-rendered template output.
241 items = liveItems.map( function ( el ) {
242 var clone = el.cloneNode( true );
243 clone.querySelectorAll( '.elementor-document-handle' ).forEach( function ( h ) {
244 h.remove();
245 } );
246 return clone.outerHTML;
247 } );
248 itemsChanged = true;
249 storedItemsMap.set( widget, { templateId: templateId, items: items } );
250 } else {
251 var stored = storedItemsMap.get( widget );
252 if ( ! stored ) {
253 return; // Widget not yet rendered.
254 }
255 items = stored.items;
256 itemsChanged = ( stored.templateId !== templateId );
257 if ( itemsChanged ) {
258 storedItemsMap.set( widget, { templateId: templateId, items: items } );
259 }
260 }
261
262 var stateKey = templateId + ':' + djb2( items.join( '' ) );
263 if ( ! itemsChanged && widget.dataset.ecsLoopState === stateKey ) {
264 return;
265 }
266 widget.dataset.ecsLoopState = stateKey;
267
268 var formData = new FormData();
269 formData.append( 'action', 'ecs_loop_custom_layout_preview' );
270 formData.append( 'nonce', ecsLoopPreview.nonce );
271 formData.append( 'template_id', templateId );
272 items.forEach( function ( html ) {
273 formData.append( 'items[]', html );
274 } );
275
276 fetch( ecsLoopPreview.ajaxUrl, { method: 'POST', body: formData } )
277 .then( function ( r ) { return r.json(); } )
278 .then( function ( resp ) {
279 if ( widget.dataset.ecsLoopState !== stateKey ) {
280 return;
281 }
282 if ( ! resp.success ) {
283 return;
284 }
285
286 var widgetContainer = widget.querySelector( ':scope > .elementor-widget-container' );
287 if ( ! widgetContainer ) {
288 return;
289 }
290
291 suppressObserver = true;
292
293 // ── Dual-content structure ─────────────────────────────────────
294 //
295 // .elementor-widget-container
296 // └─ .ecs-loop-original (display:none — preserves DOM for EPro)
297 // └─ .ecs-loop-injection (visible — our custom layout)
298
299 var origWrapper = widgetContainer.querySelector( ':scope > .ecs-loop-original' );
300 var injWrapper = widgetContainer.querySelector( ':scope > .ecs-loop-injection' );
301 var doc = widgetContainer.ownerDocument;
302
303 if ( ! origWrapper ) {
304 // First injection: wrap all existing children into .ecs-loop-original.
305 origWrapper = doc.createElement( 'div' );
306 origWrapper.className = 'ecs-loop-original';
307 while ( widgetContainer.firstChild ) {
308 origWrapper.appendChild( widgetContainer.firstChild );
309 }
310 widgetContainer.appendChild( origWrapper );
311 }
312 origWrapper.style.display = 'none';
313
314 if ( ! injWrapper ) {
315 injWrapper = doc.createElement( 'div' );
316 injWrapper.className = 'ecs-loop-injection';
317 widgetContainer.appendChild( injWrapper );
318 }
319 injWrapper.innerHTML = resp.data.html;
320 injWrapper.style.display = '';
321
322 widget.classList.add( 'ecs-loop-custom-preview-active' );
323
324 // Re-attach EPro's "Edit Template" document handle button.
325 // attachEditDocumentHandle() looks for the first loop item inside
326 // this.$element — which now finds items nested in our injection.
327 reattachDocumentHandle( widget );
328
329 Promise.resolve().then( function () {
330 suppressObserver = false;
331 } );
332 } )
333 .catch( function () {
334 if ( widget.dataset.ecsLoopState === stateKey ) {
335 delete widget.dataset.ecsLoopState;
336 }
337 } );
338 }
339
340 // ── Cleanup ───────────────────────────────────────────────────────────────
341
342 function cleanupWidget( widget ) {
343 var widgetContainer = widget.querySelector( ':scope > .elementor-widget-container' );
344 if ( widgetContainer ) {
345 var origWrapper = widgetContainer.querySelector( ':scope > .ecs-loop-original' );
346 var injWrapper = widgetContainer.querySelector( ':scope > .ecs-loop-injection' );
347
348 if ( origWrapper ) {
349 // Move original children back and remove the wrapper.
350 while ( origWrapper.firstChild ) {
351 widgetContainer.insertBefore( origWrapper.firstChild, origWrapper );
352 }
353 origWrapper.remove();
354 }
355 if ( injWrapper ) {
356 injWrapper.remove();
357 }
358 }
359
360 widget.classList.remove( 'ecs-loop-custom-preview-active' );
361 delete widget.dataset.ecsLoopState;
362 storedItemsMap.delete( widget );
363 }
364
365 // ── Helpers ───────────────────────────────────────────────────────────────
366
367 /**
368 * Re-attach EPro's "Edit Template" document handle button after injection.
369 *
370 * Strategy:
371 * 1. Remove any existing (stale) handles — prevents hasHandle() guard from
372 * blocking re-attachment.
373 * 2. Call iframeFrontend.elementsHandler.runReadyTrigger(widget) — this
374 * re-fires 'frontend/element_ready/loop-grid.*' which creates a new
375 * handler instance and calls attachEditDocumentHandle(). The handle
376 * lands on the first [data-elementor-type="loop-item"] found in the
377 * subtree, which is inside hidden .ecs-loop-original.
378 * 3. Move that handle (with its live click listeners intact) to the first
379 * loop item inside visible .ecs-loop-injection.
380 */
381 function reattachDocumentHandle( widget ) {
382 try {
383 var iframeFrontend = iframeDoc.defaultView.elementorFrontend;
384 if ( ! iframeFrontend ) {
385 return;
386 }
387
388 // Step 1 — clear stale handles so hasHandle() guard doesn't block re-attachment.
389 widget.querySelectorAll( '.elementor-document-handle' ).forEach( function ( h ) {
390 h.remove();
391 } );
392
393 // Step 2 — re-trigger element_ready. EPro's attachEditDocumentHandle()
394 // runs but attaches the handle asynchronously (~10 ms later), so we
395 // must defer step 3.
396 iframeFrontend.elementsHandler.runReadyTrigger( widget );
397
398 // Step 3 (deferred) — move handle + data-editable-elementor-document
399 // attribute into the visible injection area.
400 //
401 // EPro sets data-editable-elementor-document on the element it prepends
402 // the handle to. The preview.css rule
403 // *[data-editable-elementor-document] { position: relative }
404 // *[data-editable-elementor-document] .elementor-document-handle { position: absolute ... }
405 // relies on that attribute being on the SAME element as the handle's
406 // parent. We must move it together with the handle node.
407 setTimeout( function () {
408 try {
409 var handle = widget.querySelector( '.elementor-document-handle' );
410 var targetItem = widget.querySelector( '.ecs-loop-injection [data-elementor-type="loop-item"]' );
411 if ( handle && targetItem && ! targetItem.contains( handle ) ) {
412 // Transfer the attribute from the source to the target.
413 var sourceItem = handle.parentElement;
414 if ( sourceItem && sourceItem.dataset.editableElementorDocument ) {
415 targetItem.dataset.editableElementorDocument = sourceItem.dataset.editableElementorDocument;
416 delete sourceItem.dataset.editableElementorDocument;
417 }
418 targetItem.prepend( handle );
419 }
420 } catch ( e ) { /* non-critical */ }
421 }, 100 );
422
423 } catch ( e ) {
424 // Non-critical — Edit Template button may be absent.
425 }
426 }
427
428 /**
429 * Return the selected template ID for a loop-grid widget, or 0 if not enabled.
430 */
431 function getTemplateId( widget ) {
432 var elementId = widget.dataset.id;
433 if ( ! elementId ) {
434 return 0;
435 }
436 try {
437 var container = window.elementor.getContainer( elementId );
438 if ( ! container || ! container.settings ) {
439 return 0;
440 }
441 if ( container.settings.get( 'ecs_use_custom_layout' ) !== 'yes' ) {
442 return 0;
443 }
444 var val = container.settings.get( 'ecs_loop_layout_id' );
445 return val ? ( parseInt( val, 10 ) || 0 ) : 0;
446 } catch ( e ) {
447 return 0;
448 }
449 }
450
451 /**
452 * djb2 hash — fast, good distribution for change-detection fingerprints.
453 */
454 function djb2( str ) {
455 var h = 5381;
456 for ( var i = 0; i < str.length; i++ ) {
457 h = ( ( h << 5 ) + h + str.charCodeAt( i ) ) | 0;
458 }
459 return ( h >>> 0 ).toString( 36 );
460 }
461
462 } )();
463