PluginProbe ʕ •ᴥ•ʔ
FrontBlocks for Gutenberg/GeneratePress / 1.5.2
FrontBlocks for Gutenberg/GeneratePress v1.5.2
1.5.2 1.5.1 1.4.0 1.5.0 trunk 0.2.0 0.2.1 0.2.2 0.2.3 0.2.4 0.2.5 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.2.0 1.2.1 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 ci-artifacts
frontblocks / assets / carousel / frontblocks-advanced-option.jsx
frontblocks / assets / carousel Last commit date
frontblocks-advanced-option.js 2 months ago frontblocks-advanced-option.jsx 2 months ago frontblocks-carousel-editor.css 5 months ago frontblocks-carousel.css 2 months ago frontblocks-carousel.js 2 months ago glide.min.js 10 months ago
frontblocks-advanced-option.jsx
515 lines
1 // Add custom controls to the Advanced panel of GenerateBlocks Grid block
2 const { addFilter } = wp.hooks;
3 const { Fragment, useEffect, useRef } = wp.element;
4 const { InspectorControls, PanelColorSettings, MediaUpload } = wp.blockEditor;
5 const { SelectControl, TextControl, PanelBody, ToggleControl, Button } = wp.components;
6 const { __ } = wp.i18n;
7
8 /**
9 * Returns the document that renders block content.
10 * WordPress 6.x uses an <iframe> for the editor canvas.
11 */
12 function getEditorDocument() {
13 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' );
14 return ( iframe && iframe.contentDocument && iframe.contentDocument.body )
15 ? iframe.contentDocument
16 : document;
17 }
18
19 const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-/;
20
21 /**
22 * Walk an element's subtree to find the container whose DIRECT children
23 * are actual blocks (UUID data-block). Stops at depth 4.
24 */
25 function findSlidesParent( el, depth ) {
26 if ( depth === 0 ) return null;
27 if ( Array.from( el.children ).some( ( c ) => UUID_RE.test( c.dataset.block || '' ) ) ) {
28 return el;
29 }
30 for ( const child of el.children ) {
31 const found = findSlidesParent( child, depth - 1 );
32 if ( found ) return found;
33 }
34 return null;
35 }
36
37 /**
38 * Find the grid DOM element for a given block.
39 *
40 * For generateblocks/element: [data-block][data-type] IS the gb-element.
41 * Do NOT search inside – that returns a child item.
42 * For generateblocks/grid: look for .gb-grid-wrapper inside.
43 * For core/group: walk the subtree to find the element whose children
44 * are the real blocks, because WP nests them differently across versions.
45 */
46 function findGridEl( blockEl, name ) {
47 if ( name === 'generateblocks/element' ) {
48 return blockEl;
49 }
50 if ( name === 'generateblocks/grid' ) {
51 if ( blockEl.classList.contains( 'gb-grid-wrapper' ) ) return blockEl;
52 const grid = blockEl.querySelector( '.gb-grid-wrapper' );
53 return grid || null;
54 }
55 if ( name === 'core/group' ) {
56 // Walk up to 4 levels deep to find where the child blocks live.
57 return findSlidesParent( blockEl, 4 ) || blockEl;
58 }
59 if ( name === 'core/query' ) {
60 return blockEl.querySelector( '.wp-block-post-template' ) || null;
61 }
62 return null;
63 }
64
65 function addCustomCarouselPanel( BlockEdit ) {
66 return ( props ) => {
67 if (
68 props.name !== 'generateblocks/grid' &&
69 props.name !== 'generateblocks/element' &&
70 props.name !== 'core/group' &&
71 props.name !== 'core/query'
72 ) {
73 return <BlockEdit { ...props } />;
74 }
75
76 if ( props.name === 'generateblocks/element' ) {
77 const styles = props.attributes.styles || {};
78 if ( styles.display !== 'grid' ) {
79 return <BlockEdit { ...props } />;
80 }
81 }
82
83 if ( props.name === 'core/group' ) {
84 const layout = props.attributes.layout || {};
85 if ( layout.type !== 'grid' ) {
86 return <BlockEdit { ...props } />;
87 }
88 }
89
90 const {
91 frblGridOption = 'none',
92 frblItemsToView = '4',
93 frblLaptopToView = '3',
94 frblTabletToView = '2',
95 frblResponsiveToView = '1',
96 frblAutoplay = '',
97 frblGap = '20',
98 frblButtons = 'arrows',
99 frblRewind = true,
100 frblButtonColor,
101 frblButtonBgColor,
102 frblButtonsPosition = 'side',
103 frblArrowLeftUrl = '',
104 frblArrowRightUrl = '',
105 frblDisableOnDesktop = false,
106 } = props.attributes;
107
108 // ── Editor carousel preview ──────────────────────────────────────────
109 const stateRef = useRef( null );
110
111 useEffect( () => {
112 let mounted = true;
113 let timer = null;
114
115 function cleanup() {
116 if ( ! stateRef.current ) return;
117 const { gridEl, slides, spacer, prevBtn, nextBtn, resizeObs, scrollFn } = stateRef.current;
118
119 // Disconnect observer and scroll listener.
120 if ( resizeObs ) resizeObs.disconnect();
121 const editorDoc = getEditorDocument();
122 const editorScrollEl = editorDoc.documentElement || editorDoc.body;
123 if ( scrollFn ) editorScrollEl.removeEventListener( 'scroll', scrollFn, true );
124
125 // Remove arrows from outer window body (completely outside React).
126 [ prevBtn, nextBtn ].forEach( ( btn ) => {
127 if ( btn && btn.parentNode ) btn.parentNode.removeChild( btn );
128 } );
129
130 // Remove right spacer element.
131 if ( spacer && spacer.parentNode ) spacer.parentNode.removeChild( spacer );
132
133 // Restore gridEl styles.
134 try {
135 const { preventScroll } = stateRef.current;
136 if ( preventScroll ) {
137 gridEl.removeEventListener( 'wheel', preventScroll );
138 gridEl.removeEventListener( 'touchstart', preventScroll );
139 gridEl.removeEventListener( 'touchmove', preventScroll );
140 }
141 gridEl.classList.remove( 'frbl-carousel-noscrollbar' );
142 [
143 'display', 'flex-wrap', 'gap',
144 'overflow-x', 'scroll-behavior',
145 'padding-left', 'padding-right',
146 'max-width', 'grid-template-columns',
147 ].forEach( ( p ) => gridEl.style.removeProperty( p ) );
148
149 slides.forEach( ( slide ) => {
150 [ 'flex-shrink', 'width', 'min-width',
151 'margin-left', 'margin-right', 'grid-column', 'grid-row',
152 ].forEach( ( p ) => slide.style.removeProperty( p ) );
153 } );
154 } catch ( e ) {}
155
156 stateRef.current = null;
157 }
158
159 function init() {
160 if ( ! mounted ) return;
161 cleanup();
162
163 const editorDoc = getEditorDocument();
164
165 // GB 2.x wraps blocks in a root element that shares the same data-block UUID.
166 // Use data-type to select the correct inner element; fall back for native blocks.
167 let blockEl = editorDoc.querySelector(
168 `[data-block="${ props.clientId }"][data-type="${ props.name }"]`
169 );
170 if ( ! blockEl ) {
171 blockEl = editorDoc.querySelector( `[data-block="${ props.clientId }"]` );
172 }
173 if ( ! blockEl ) return;
174
175 const gridEl = findGridEl( blockEl, props.name );
176 if ( ! gridEl ) return;
177
178 // For core/query, <li> items don't have data-block UUIDs (server-rendered).
179 // For other blocks, filter by UUID data-block attribute.
180 const isQueryLoop = props.name === 'core/query';
181 const slides = isQueryLoop
182 ? Array.from( gridEl.children ).filter( ( el ) => el.tagName === 'LI' )
183 : Array.from( gridEl.children ).filter( ( el ) => UUID_RE.test( el.dataset.block || '' ) );
184 if ( slides.length === 0 ) return;
185
186 const perView = Math.max( 1, parseInt( frblItemsToView ) || 4 );
187 const gap = Math.max( 0, parseInt( frblGap ) || 20 );
188 const btnColor = frblButtonColor || '#fff';
189 const btnBg = frblButtonBgColor || 'rgba(0,0,0,0.45)';
190
191 // For core/query, force flex via setProperty to beat WP's grid CSS.
192 if ( isQueryLoop ) {
193 gridEl.style.setProperty( 'display', 'flex', 'important' );
194 gridEl.style.setProperty( 'flex-wrap', 'nowrap', 'important' );
195 gridEl.style.setProperty( 'max-width', 'none', 'important' );
196 gridEl.style.setProperty( 'grid-template-columns', 'none', 'important' );
197 gridEl.style.setProperty( 'list-style', 'none', 'important' );
198 gridEl.style.setProperty( 'padding-left', '0', 'important' );
199 }
200
201 // Measure content-box width before any style changes.
202 const editorWin = editorDoc.defaultView || window;
203 const ARROW_W = 40; // px reserved on each side for arrow buttons.
204 const cs = editorWin.getComputedStyle( gridEl );
205 const innerW = gridEl.getBoundingClientRect().width
206 - ( parseFloat( cs.paddingLeft ) || 0 )
207 - ( parseFloat( cs.paddingRight ) || 0 );
208 const totalWidth = Math.round( innerW ) || 600;
209 // Content area after reserving space for both arrow buttons.
210 const contentW = totalWidth - ARROW_W * 2;
211 const slideWidth = Math.round( ( contentW - gap * ( perView - 1 ) ) / perView );
212 const step = slideWidth + gap;
213
214 // ── Apply scroll-based layout directly to gridEl ─────────────
215 // No DOM restructuring → no React reconciliation conflicts.
216 gridEl.classList.add( 'frbl-carousel-noscrollbar' );
217 if ( ! isQueryLoop ) {
218 gridEl.style.display = 'flex';
219 gridEl.style.flexWrap = 'nowrap';
220 gridEl.style.paddingLeft = ARROW_W + 'px';
221 } else {
222 // paddingLeft already set via setProperty above; override with arrow offset.
223 gridEl.style.setProperty( 'padding-left', ARROW_W + 'px', 'important' );
224 }
225 gridEl.style.gap = gap + 'px';
226 gridEl.style.overflowX = 'scroll';
227 gridEl.style.scrollBehavior = 'smooth';
228 gridEl.style.paddingRight = '0';
229
230 slides.forEach( ( slide ) => {
231 slide.style.flexShrink = '0';
232 slide.style.width = slideWidth + 'px';
233 slide.style.minWidth = slideWidth + 'px';
234 slide.style.marginLeft = '0';
235 slide.style.marginRight = '0';
236 slide.style.gridColumn = 'unset';
237 slide.style.gridRow = 'unset';
238 } );
239
240 // Right-side spacer: Chrome does not include padding-right in horizontal
241 // scroll extent, so we use a real flex child to guarantee right space.
242 const appender = gridEl.querySelector( '.block-list-appender' );
243 const spacer = editorDoc.createElement( 'span' );
244 spacer.setAttribute( 'data-frbl-spacer', '1' );
245 spacer.style.cssText = `display:block;flex-shrink:0;width:${ ARROW_W }px;min-width:${ ARROW_W }px;`;
246 gridEl.insertBefore( spacer, appender || null );
247
248 // Prevent manual scroll — only arrows drive navigation.
249 const preventScroll = ( e ) => e.preventDefault();
250 gridEl.addEventListener( 'wheel', preventScroll, { passive: false } );
251 gridEl.addEventListener( 'touchstart', preventScroll, { passive: false } );
252 gridEl.addEventListener( 'touchmove', preventScroll, { passive: false } );
253
254 // ── Navigation (scrollLeft, item-by-item) ────────────────────
255 let idx = 0;
256 const lastIdx = Math.max( 0, slides.length - perView );
257
258 function scrollTo( n ) {
259 if ( n > lastIdx ) {
260 // Wrap forward to start.
261 gridEl.style.scrollBehavior = 'auto';
262 gridEl.scrollLeft = 0;
263 void gridEl.offsetWidth;
264 gridEl.style.scrollBehavior = 'smooth';
265 idx = 0;
266 return;
267 }
268 if ( n < 0 ) {
269 // Wrap back to end.
270 gridEl.style.scrollBehavior = 'auto';
271 gridEl.scrollLeft = Math.round( lastIdx * step );
272 void gridEl.offsetWidth;
273 gridEl.style.scrollBehavior = 'smooth';
274 idx = lastIdx;
275 return;
276 }
277 idx = n;
278 gridEl.scrollLeft = Math.round( idx * step );
279 }
280
281 // ── Arrows in OUTER document body ────────────────────────────
282 // Placing them outside the iframe means React never touches them.
283 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' );
284
285 function updateArrowPos() {
286 if ( ! stateRef.current ) return;
287 const ifrRect = iframe ? iframe.getBoundingClientRect() : { top: 0, left: 0 };
288 const rect = gridEl.getBoundingClientRect(); // coords in iframe viewport
289 const midY = Math.round( ifrRect.top + rect.top + rect.height / 2 - 16 );
290
291 prevBtn.style.top = midY + 'px';
292 prevBtn.style.left = Math.round( ifrRect.left + rect.left + 8 ) + 'px';
293 nextBtn.style.top = midY + 'px';
294 nextBtn.style.right = Math.round( window.innerWidth - ( ifrRect.left + rect.right ) + 8 ) + 'px';
295 }
296
297 function makeArrow( dir ) {
298 const btn = document.createElement( 'button' );
299 btn.type = 'button';
300 btn.className = `frbl-editor-arrow frbl-editor-arrow-${ dir }`;
301 btn.setAttribute( 'aria-label', dir === 'prev' ? 'Previous slide' : 'Next slide' );
302 btn.style.cssText = `position:fixed;z-index:99999;background-color:${ btnBg };`;
303 const d = dir === 'prev' ? 'M6 1L1 6L6 11' : 'M1 11L6 6L1 1';
304 btn.innerHTML = `<svg width="7" height="12" viewBox="0 0 7 12" fill="none"><path d="${ d }" stroke="${ btnColor }" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
305 btn.addEventListener( 'click', ( e ) => {
306 e.stopPropagation();
307 scrollTo( dir === 'prev' ? idx - 1 : idx + 1 );
308 } );
309 document.body.appendChild( btn );
310 return btn;
311 }
312
313 const prevBtn = makeArrow( 'prev' );
314 const nextBtn = makeArrow( 'next' );
315 updateArrowPos();
316
317 // Keep arrows positioned correctly when the editor scrolls or resizes.
318 const resizeObs = new ResizeObserver( updateArrowPos );
319 resizeObs.observe( gridEl );
320
321 const editorScrollEl = editorDoc.documentElement || editorDoc.body;
322 const scrollFn = updateArrowPos;
323 editorScrollEl.addEventListener( 'scroll', scrollFn, { passive: true, capture: true } );
324
325 stateRef.current = { gridEl, slides, spacer, prevBtn, nextBtn, resizeObs, scrollFn, preventScroll };
326 }
327
328 if ( frblGridOption !== 'none' ) {
329 timer = setTimeout( init, 300 );
330 } else {
331 cleanup();
332 }
333
334 return () => {
335 mounted = false;
336 if ( timer ) clearTimeout( timer );
337 cleanup();
338 };
339 }, [
340 frblGridOption, frblItemsToView, frblGap,
341 frblButtonColor, frblButtonBgColor, props.clientId,
342 ] );
343 // ── Inspector panel ──────────────────────────────────────────────────
344
345 return (
346 <Fragment>
347 <BlockEdit { ...props } />
348 <InspectorControls>
349 <PanelBody
350 title={ __( 'FrontBlocks - Carousel', 'frontblocks' ) }
351 initialOpen={ true }
352 >
353 <SelectControl
354 label={ __( 'FrontBlocks Grid Option', 'frontblocks' ) }
355 value={ frblGridOption }
356 options={ [
357 { label: __( 'None', 'frontblocks' ), value: 'none' },
358 { label: __( 'Carousel', 'frontblocks' ), value: 'carousel' },
359 { label: __( 'Slider', 'frontblocks' ), value: 'slider' },
360 ] }
361 onChange={ ( value ) => props.setAttributes( { frblGridOption: value } ) }
362 help={ __( 'This option gives the option to make carousel in your grid block.', 'frontblocks' ) }
363 />
364 { frblGridOption !== 'none' && (
365 <>
366 <TextControl
367 label={ __( 'Items to view (Desktop)', 'frontblocks' ) }
368 value={ frblItemsToView }
369 onChange={ ( value ) => props.setAttributes( { frblItemsToView: value } ) }
370 help={ __( 'Number of items to show on desktop (>1200px)', 'frontblocks' ) }
371 />
372 <TextControl
373 label={ __( 'Items to view (Laptop)', 'frontblocks' ) }
374 value={ frblLaptopToView }
375 onChange={ ( value ) => props.setAttributes( { frblLaptopToView: value } ) }
376 help={ __( 'Number of items to show on laptop (992px-1199px)', 'frontblocks' ) }
377 />
378 <TextControl
379 label={ __( 'Items to view (Tablet)', 'frontblocks' ) }
380 value={ frblTabletToView }
381 onChange={ ( value ) => props.setAttributes( { frblTabletToView: value } ) }
382 help={ __( 'Number of items to show on tablet (768px-991px)', 'frontblocks' ) }
383 />
384 <TextControl
385 label={ __( 'Items to view (Mobile)', 'frontblocks' ) }
386 value={ frblResponsiveToView }
387 onChange={ ( value ) => props.setAttributes( { frblResponsiveToView: value } ) }
388 help={ __( 'Number of items to show on mobile (<768px)', 'frontblocks' ) }
389 />
390 <TextControl
391 label={ __( 'Autoplay (seconds)', 'frontblocks' ) }
392 value={ frblAutoplay }
393 onChange={ ( value ) => props.setAttributes( { frblAutoplay: value } ) }
394 />
395 <TextControl
396 label={ __( 'Gap (px)', 'frontblocks' ) }
397 value={ frblGap }
398 onChange={ ( value ) => props.setAttributes( { frblGap: value } ) }
399 help={ __( 'Space between slides in pixels. Leave empty for 20.', 'frontblocks' ) }
400 />
401 { frblGridOption === 'slider' && (
402 <ToggleControl
403 label={ __( 'Rewind', 'frontblocks' ) }
404 checked={ frblRewind }
405 onChange={ ( value ) => props.setAttributes( { frblRewind: value } ) }
406 />
407 ) }
408 <SelectControl
409 label={ __( 'Buttons', 'frontblocks' ) }
410 value={ frblButtons }
411 options={ [
412 { label: __( 'None', 'frontblocks' ), value: 'none' },
413 { label: __( 'Bullets', 'frontblocks' ), value: 'bullets' },
414 { label: __( 'Arrows', 'frontblocks' ), value: 'arrows' },
415 ] }
416 onChange={ ( value ) => props.setAttributes( { frblButtons: value } ) }
417 />
418 { frblButtons === 'arrows' && (
419 <>
420 <SelectControl
421 label={ __( 'Buttons Position', 'frontblocks' ) }
422 value={ frblButtonsPosition }
423 options={ [
424 { label: __( 'Side (left & right)', 'frontblocks' ), value: 'side' },
425 { label: __( 'Bottom left', 'frontblocks' ), value: 'bottom-left' },
426 { label: __( 'Bottom right', 'frontblocks' ), value: 'bottom-right' },
427 { label: __( 'Top left', 'frontblocks' ), value: 'top-left' },
428 { label: __( 'Top right', 'frontblocks' ), value: 'top-right' },
429 ] }
430 onChange={ ( value ) => props.setAttributes( { frblButtonsPosition: value } ) }
431 />
432 <div style={ { marginBottom: '12px' } }>
433 <p style={ { marginBottom: '4px', fontWeight: 500 } }>{ __( 'Left arrow icon', 'frontblocks' ) }</p>
434 { frblArrowLeftUrl && (
435 <img src={ frblArrowLeftUrl } alt="" style={ { display: 'block', width: '32px', height: '32px', marginBottom: '6px', objectFit: 'contain' } } />
436 ) }
437 <MediaUpload
438 onSelect={ ( media ) => props.setAttributes( { frblArrowLeftUrl: media.url } ) }
439 allowedTypes={ [ 'image' ] }
440 value={ frblArrowLeftUrl }
441 render={ ( { open } ) => (
442 <div style={ { display: 'flex', gap: '6px', flexWrap: 'wrap' } }>
443 <Button onClick={ open } variant="secondary" size="small">
444 { frblArrowLeftUrl ? __( 'Change', 'frontblocks' ) : __( 'Select SVG', 'frontblocks' ) }
445 </Button>
446 { frblArrowLeftUrl && (
447 <Button onClick={ () => props.setAttributes( { frblArrowLeftUrl: '' } ) } variant="link" isDestructive size="small">
448 { __( 'Remove', 'frontblocks' ) }
449 </Button>
450 ) }
451 </div>
452 ) }
453 />
454 </div>
455 <div style={ { marginBottom: '12px' } }>
456 <p style={ { marginBottom: '4px', fontWeight: 500 } }>{ __( 'Right arrow icon', 'frontblocks' ) }</p>
457 { frblArrowRightUrl && (
458 <img src={ frblArrowRightUrl } alt="" style={ { display: 'block', width: '32px', height: '32px', marginBottom: '6px', objectFit: 'contain' } } />
459 ) }
460 <MediaUpload
461 onSelect={ ( media ) => props.setAttributes( { frblArrowRightUrl: media.url } ) }
462 allowedTypes={ [ 'image' ] }
463 value={ frblArrowRightUrl }
464 render={ ( { open } ) => (
465 <div style={ { display: 'flex', gap: '6px', flexWrap: 'wrap' } }>
466 <Button onClick={ open } variant="secondary" size="small">
467 { frblArrowRightUrl ? __( 'Change', 'frontblocks' ) : __( 'Select SVG', 'frontblocks' ) }
468 </Button>
469 { frblArrowRightUrl && (
470 <Button onClick={ () => props.setAttributes( { frblArrowRightUrl: '' } ) } variant="link" isDestructive size="small">
471 { __( 'Remove', 'frontblocks' ) }
472 </Button>
473 ) }
474 </div>
475 ) }
476 />
477 </div>
478 </>
479 ) }
480 <PanelColorSettings
481 title={ __( 'Button Colors', 'frontblocks' ) }
482 colorSettings={ [
483 {
484 value: frblButtonColor,
485 onChange: ( color ) => props.setAttributes( { frblButtonColor: color } ),
486 label: __( 'Color button', 'frontblocks' ),
487 },
488 {
489 value: frblButtonBgColor,
490 onChange: ( color ) => props.setAttributes( { frblButtonBgColor: color } ),
491 label: __( 'Color background button', 'frontblocks' ),
492 },
493 ] }
494 />
495 <ToggleControl
496 label={ __( 'Disable on Desktop', 'frontblocks' ) }
497 checked={ frblDisableOnDesktop }
498 onChange={ ( value ) => props.setAttributes( { frblDisableOnDesktop: value } ) }
499 help={ __( 'If enabled, carousel/slider will only work on mobile devices.', 'frontblocks' ) }
500 />
501 </>
502 ) }
503 </PanelBody>
504 </InspectorControls>
505 </Fragment>
506 );
507 };
508 }
509
510 addFilter(
511 'editor.BlockEdit',
512 'frontblocks/gb-grid-carousel-panel',
513 addCustomCarouselPanel
514 );
515