PluginProbe
Slider Block by Sliderberg – WordPress Slider & Carousel Plugin for Gutenberg / 1.2.3
Slider Block by Sliderberg – WordPress Slider & Carousel Plugin for Gutenberg v1.2.3
1.2.3 1.2.2 1.2.0 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.7 1.0.8 1.0.9
sliderberg / src / blocks / slider / edit.tsx

edit.tsx in Slider Block by Sliderberg – WordPress Slider & Carousel Plugin for Gutenberg 1.2.3, at src/blocks/slider/edit.tsx

658 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Slider block editor component.
3 * Initializes Swiper on a ref, using useInnerBlocksProps as the swiper-wrapper.
4 * Each slide block adds 'swiper-slide' class via its own useBlockProps.
5 */
6
7 import { useRef, useEffect, useMemo } from "@wordpress/element";
8 import {
9 useBlockProps,
10 useInnerBlocksProps,
11 BlockControls,
12 store as blockEditorStore,
13 } from "@wordpress/block-editor";
14 import { ToolbarGroup, ToolbarButton } from "@wordpress/components";
15 import { plus } from "@wordpress/icons";
16 import { useSelect, useDispatch } from "@wordpress/data";
17 import { createBlock } from "@wordpress/blocks";
18 import { __ } from "@wordpress/i18n";
19 import { applyFilters } from "@wordpress/hooks";
20 import SwiperCore from "swiper";
21 import { Navigation, Pagination, A11y } from "swiper/modules";
22
23 import "swiper/css";
24 import "swiper/css/navigation";
25 import "swiper/css/pagination";
26
27 import SliderInspector from "./inspector";
28 import LayoutChooser from "./layout-chooser";
29 import { buildSwiperConfig } from "./constants";
30 import type { SliderAttributes } from "./constants";
31
32 import "./editor.scss";
33
34 /**
35 * Convert a WordPress spacing value to a CSS value.
36 * Handles preset format: "var:preset|spacing|30" → "var(--wp--preset--spacing--30)"
37 * and plain values like "20px".
38 */
39 function spacingValueToCSS(value: string): string {
40 if (value.startsWith("var:")) {
41 return `var(--wp--preset--spacing--${value.split("|").pop()})`;
42 }
43 return value;
44 }
45
46 function getEditorBreakpointParams(
47 config: ReturnType<typeof buildSwiperConfig>,
48 container: HTMLElement,
49 ) {
50 if (!config.breakpoints) {
51 return {};
52 }
53
54 const viewportWidth =
55 container.ownerDocument.defaultView?.innerWidth ?? container.clientWidth;
56
57 return Object.entries(config.breakpoints)
58 .map(([breakpoint, params]) => [Number(breakpoint), params] as const)
59 .filter(([breakpoint]) => Number.isFinite(breakpoint))
60 .sort(([first], [second]) => second - first)
61 .find(([breakpoint]) => viewportWidth >= breakpoint)?.[1] ?? {};
62 }
63
64 interface EditProps {
65 attributes: SliderAttributes;
66 setAttributes: (attrs: Partial<SliderAttributes>) => void;
67 clientId: string;
68 }
69
70 const SWIPER_CONTAINER_STATE_CLASSES = [
71 "swiper-initialized",
72 "swiper-horizontal",
73 "swiper-vertical",
74 "swiper-backface-hidden",
75 "swiper-css-mode",
76 "swiper-autoheight",
77 ] as const;
78
79 const SWIPER_SLIDE_STATE_CLASSES = [
80 "swiper-slide-active",
81 "swiper-slide-next",
82 "swiper-slide-prev",
83 "swiper-slide-visible",
84 "swiper-slide-fully-visible",
85 "swiper-slide-duplicate-active",
86 "swiper-slide-duplicate-next",
87 "swiper-slide-duplicate-prev",
88 ] as const;
89
90 const SWIPER_SLIDE_INJECTED_CLASSES = [
91 "swiper-slide-duplicate",
92 "swiper-slide-blank",
93 ] as const;
94
95 const SWIPER_PAGINATION_STATE_CLASSES = [
96 "swiper-pagination-horizontal",
97 "swiper-pagination-vertical",
98 "swiper-pagination-lock",
99 "swiper-pagination-clickable",
100 "swiper-pagination-bullets",
101 "swiper-pagination-bullets-dynamic",
102 "swiper-pagination-fraction",
103 "swiper-pagination-progressbar",
104 "swiper-pagination-progressbar-opposite",
105 ] as const;
106
107 const SWIPER_NAV_STATE_CLASSES = [
108 "swiper-button-disabled",
109 "swiper-button-lock",
110 "swiper-button-hidden",
111 ] as const;
112
113 function removeSwiperInjectedSlides(container: HTMLElement): void {
114 const wrapper = container.querySelector<HTMLElement>(".swiper-wrapper");
115
116 if (!wrapper) {
117 return;
118 }
119
120 Array.from(wrapper.children).forEach((child) => {
121 if (!(child instanceof HTMLElement)) {
122 return;
123 }
124
125 if (
126 SWIPER_SLIDE_INJECTED_CLASSES.some((className) =>
127 child.classList.contains(className),
128 )
129 ) {
130 child.remove();
131 return;
132 }
133
134 child.classList.remove(...SWIPER_SLIDE_STATE_CLASSES);
135 child.removeAttribute("data-swiper-slide-index");
136 });
137 }
138
139 function resetSwiperEditorDom(
140 container: HTMLElement,
141 prevButton: HTMLElement | null,
142 nextButton: HTMLElement | null,
143 pagination: HTMLElement | null,
144 ): void {
145 removeSwiperInjectedSlides(container);
146 container.classList.remove(...SWIPER_CONTAINER_STATE_CLASSES);
147
148 const wrapper = container.querySelector<HTMLElement>(".swiper-wrapper");
149 if (wrapper) {
150 wrapper.style.transform = "";
151 wrapper.style.transitionDuration = "";
152 wrapper.style.transitionDelay = "";
153 wrapper.style.width = "";
154 wrapper.style.height = "";
155 wrapper.style.marginLeft = "";
156 wrapper.style.marginRight = "";
157 wrapper.style.marginTop = "";
158 wrapper.style.willChange = "";
159 }
160
161 container.querySelectorAll<HTMLElement>(".swiper-slide").forEach((slide) => {
162 slide.classList.remove(...SWIPER_SLIDE_STATE_CLASSES);
163 slide.style.height = "";
164 slide.style.width = "";
165 slide.style.marginRight = "";
166 slide.style.marginBottom = "";
167 slide.style.marginLeft = "";
168 slide.style.transform = "";
169 slide.style.transitionDuration = "";
170 });
171
172 [prevButton, nextButton].forEach((button) => {
173 if (!button) return;
174 button.classList.remove(...SWIPER_NAV_STATE_CLASSES);
175 button.removeAttribute("aria-disabled");
176 button.removeAttribute("aria-label");
177 button.removeAttribute("aria-controls");
178 button.removeAttribute("tabindex");
179 });
180
181 if (pagination) {
182 pagination.classList.remove(...SWIPER_PAGINATION_STATE_CLASSES);
183 pagination.style.left = "";
184 pagination.style.right = "";
185 pagination.style.top = "";
186 pagination.style.bottom = "";
187 pagination.style.width = "";
188 pagination.style.height = "";
189 pagination.style.marginTop = "";
190 pagination.style.transform = "";
191 pagination.innerHTML = "";
192 }
193 }
194
195 export default function Edit({
196 attributes,
197 setAttributes,
198 clientId,
199 }: EditProps) {
200 const {
201 mode,
202 minHeight,
203 widthPreset,
204 customWidth,
205 widthUnit,
206 hideNavigation,
207 hideDots,
208 navigationColor,
209 navigationBgColor,
210 navigationOpacity,
211 navigationShape,
212 navigationSize,
213 navigationSizeValue,
214 navHorizontal,
215 navVertical,
216 dotsHorizontal,
217 dotsVertical,
218 navOutside,
219 navFreePosition,
220 prevFreeX,
221 prevFreeY,
222 nextFreeX,
223 nextFreeY,
224 dotsFreeX,
225 dotsFreeY,
226 transitionEasing,
227 dotColor,
228 dotActiveColor,
229 } = attributes;
230
231 const swiperContainerRef = useRef<HTMLDivElement>(null);
232 const swiperInstanceRef = useRef<SwiperCore | null>(null);
233 const paginationRef = useRef<HTMLDivElement>(null);
234 const prevRef = useRef<HTMLDivElement>(null);
235 const nextRef = useRef<HTMLDivElement>(null);
236 const pendingSlideIndexRef = useRef<number | null>(null);
237 const isVerticalDirection = attributes.sliderDirection === "vertical";
238
239 // Track child slide order so add/remove/reorder all trigger the same sync path.
240 const slideBlockIds = useSelect(
241 (select) => {
242 const { getBlockOrder } = select(blockEditorStore) as any;
243 return getBlockOrder(clientId) ?? [];
244 },
245 [clientId],
246 );
247 const slideCount = slideBlockIds.length;
248 const slideBlockOrderKey = slideBlockIds.join(",");
249
250 // Content saved before `mode` existed has real slides but no `mode` (and
251 // no `isCarouselMode` either, if it was ever plain Slider mode — see
252 // sliderberg_resolve_mode() in slider-renderer.php for why WordPress
253 // never serialized that). Real slide content already existing is proof
254 // this can't be a fresh, unpicked chooser block, so treat it as Slider
255 // immediately — effectiveMode avoids a one-frame chooser flash while the
256 // healing effect below persists the real attribute.
257 const effectiveMode = mode || (slideCount > 0 ? "slider" : "");
258
259 useEffect(() => {
260 if (!mode && slideCount > 0) {
261 setAttributes({ mode: "slider" });
262 }
263 }, [mode, slideCount, setAttributes]);
264
265 const { insertBlock } = useDispatch(blockEditorStore) as any;
266
267 const syncSwiperSlides = () => {
268 const container = swiperContainerRef.current;
269 const swiper = swiperInstanceRef.current;
270
271 if (!container || !container.isConnected || !swiper) {
272 return;
273 }
274
275 removeSwiperInjectedSlides(container);
276
277 swiper.updateSize();
278 swiper.updateSlides();
279
280 const targetIndex = Math.max(
281 0,
282 Math.min(
283 pendingSlideIndexRef.current ?? swiper.activeIndex ?? 0,
284 Math.max(slideCount - 1, 0),
285 ),
286 );
287
288 swiper.slideTo(targetIndex, 0, false);
289 swiper.updateProgress();
290 swiper.updateSlidesClasses();
291 swiper.update();
292
293 pendingSlideIndexRef.current = null;
294 };
295
296 // Build Swiper config from attributes
297 const swiperConfig = useMemo(() => {
298 const baseConfig = buildSwiperConfig(attributes);
299
300 const config = {
301 ...applyFilters("sliderberg.editorSwiperConfig", baseConfig, attributes),
302 } as typeof baseConfig;
303
304 // Disable autoplay in editor
305 delete config.autoplay;
306 // Disable loop in editor — Swiper clones DOM nodes which corrupts
307 // Gutenberg's React-managed inner blocks
308 config.loop = false;
309 // Force slide effect in editor — Fade/Zoom use absolute positioning
310 // and pointer-events:none on inactive slides, making them uneditable.
311 // The chosen effect is saved in attributes and works on the frontend.
312 config.effect = "slide";
313 delete config.fadeEffect;
314 delete config.creativeEffect;
315 delete config.coverflowEffect;
316 delete config.flipEffect;
317 delete config.cubeEffect;
318 delete config.parallax;
319 // Disable all drag/swipe interaction in editor so mouse events are not
320 // intercepted by Swiper — this allows Gutenberg to handle block selection.
321 config.allowTouchMove = false;
322 config.simulateTouch = false;
323 // Do NOT use Swiper's MutationObserver in the editor — it conflicts with
324 // Gutenberg's React-managed DOM and causes double-update races when slides
325 // are added. Slide count changes are handled via the slideCount effect below.
326 config.observer = false;
327 config.observeParents = false;
328 config.observeSlideChildren = false;
329 // Never lock/hide navigation or pagination in the editor, even when
330 // slidesToShow >= slideCount (which is common in carousel mode with few slides).
331 config.watchOverflow = false;
332
333 return config;
334 }, [attributes]);
335 const swiperConfigKey = JSON.stringify(swiperConfig);
336
337 // Initialize Swiper on the container ref
338 // Use requestAnimationFrame to ensure DOM is fully painted before init
339 useEffect(() => {
340 if (!swiperContainerRef.current) return;
341
342 let rafId = 0;
343 let initRafId = 0;
344 let resizeRafId = 0;
345 let resizeObserver: ResizeObserver | null = null;
346
347 // Destroy previous instance (cleanStyles=false to preserve Gutenberg-
348 // managed inline styles like background-color on slides)
349 if (swiperInstanceRef.current) {
350 swiperInstanceRef.current.destroy(true, false);
351 swiperInstanceRef.current = null;
352 }
353
354 const container = swiperContainerRef.current;
355 resetSwiperEditorDom(
356 container,
357 prevRef.current,
358 nextRef.current,
359 paginationRef.current,
360 );
361
362 rafId = requestAnimationFrame(() => {
363 initRafId = requestAnimationFrame(() => {
364 if (!container.isConnected) {
365 return;
366 }
367
368 resetSwiperEditorDom(
369 container,
370 prevRef.current,
371 nextRef.current,
372 paginationRef.current,
373 );
374
375 void container.offsetHeight;
376
377 const editorSwiperConfig = {
378 ...swiperConfig,
379 ...getEditorBreakpointParams(swiperConfig, container),
380 };
381
382 // Swiper's window-based breakpoint resolver sees the parent wp-admin
383 // window because the block editor renders into its iframe via a portal.
384 // Resolve the iframe breakpoint above and prevent Swiper from replacing
385 // it with the parent-window breakpoint during update().
386 delete editorSwiperConfig.breakpoints;
387
388 swiperInstanceRef.current = new SwiperCore(container, {
389 modules: [Navigation, Pagination, A11y],
390 ...editorSwiperConfig,
391 navigation: !hideNavigation
392 ? {
393 nextEl: nextRef.current,
394 prevEl: prevRef.current,
395 }
396 : false,
397 pagination:
398 !hideDots && paginationRef.current
399 ? {
400 ...(typeof swiperConfig.pagination === "object"
401 ? swiperConfig.pagination
402 : {}),
403 el: paginationRef.current,
404 }
405 : false,
406 });
407
408 syncSwiperSlides();
409
410 // Gutenberg changes the editor iframe width when switching between
411 // Desktop, Tablet, and Mobile previews. That does not reliably reach
412 // Swiper's window resize handler, so observe the actual container and
413 // force breakpoint/slide measurements to refresh when its width moves.
414 let previousWidth = container.clientWidth;
415 resizeObserver = new ResizeObserver((entries) => {
416 const nextWidth = entries[0]?.contentRect.width ?? container.clientWidth;
417
418 if (Math.abs(nextWidth - previousWidth) < 0.5) {
419 return;
420 }
421
422 previousWidth = nextWidth;
423 cancelAnimationFrame(resizeRafId);
424 resizeRafId = requestAnimationFrame(() => {
425 const swiper = swiperInstanceRef.current;
426 if (!swiper || swiper.destroyed || !container.isConnected) {
427 return;
428 }
429
430 Object.assign(
431 swiper.params,
432 getEditorBreakpointParams(swiperConfig, container),
433 );
434 swiper.update();
435 });
436 });
437 resizeObserver.observe(container);
438 });
439 });
440
441 return () => {
442 cancelAnimationFrame(rafId);
443 cancelAnimationFrame(initRafId);
444 cancelAnimationFrame(resizeRafId);
445 resizeObserver?.disconnect();
446 if (swiperInstanceRef.current) {
447 swiperInstanceRef.current.destroy(true, false);
448 swiperInstanceRef.current = null;
449 }
450
451 if (swiperContainerRef.current) {
452 resetSwiperEditorDom(
453 swiperContainerRef.current,
454 prevRef.current,
455 nextRef.current,
456 paginationRef.current,
457 );
458 }
459 };
460 }, [swiperConfigKey, hideNavigation, hideDots]);
461
462 useEffect(() => {
463 if (!swiperInstanceRef.current) {
464 return;
465 }
466
467 let syncRafId = 0;
468 let syncInitRafId = 0;
469
470 syncRafId = requestAnimationFrame(() => {
471 syncInitRafId = requestAnimationFrame(() => {
472 syncSwiperSlides();
473 });
474 });
475
476 return () => {
477 cancelAnimationFrame(syncRafId);
478 cancelAnimationFrame(syncInitRafId);
479 };
480 }, [slideBlockOrderKey]);
481
482 // Compute width styles
483 const widthStyle: React.CSSProperties = {};
484 if (widthPreset === "custom" && customWidth) {
485 widthStyle.maxWidth = `${customWidth}${widthUnit}`;
486 widthStyle.marginLeft = "auto";
487 widthStyle.marginRight = "auto";
488 }
489
490 const baseCssVars = {
491 "--swiper-navigation-color": navigationColor,
492 "--swiper-navigation-background-color": navigationBgColor,
493 "--sliderberg-nav-opacity": navigationOpacity,
494 "--swiper-pagination-color": dotActiveColor,
495 "--swiper-pagination-bullet-inactive-color": dotColor,
496 "--swiper-pagination-bullet-inactive-opacity": "1",
497 "--sliderberg-min-height": `${minHeight}px`,
498 "--sliderberg-easing": transitionEasing,
499 ...(navFreePosition
500 ? {
501 "--sliderberg-prev-free-x": `${prevFreeX}%`,
502 "--sliderberg-prev-free-y": `${prevFreeY}%`,
503 "--sliderberg-next-free-x": `${nextFreeX}%`,
504 "--sliderberg-next-free-y": `${nextFreeY}%`,
505 "--sliderberg-dots-free-x": `${dotsFreeX}%`,
506 "--sliderberg-dots-free-y": `${dotsFreeY}%`,
507 }
508 : {}),
509 ...(navigationSizeValue?.all
510 ? {
511 "--sliderberg-nav-btn-size": spacingValueToCSS(
512 navigationSizeValue.all,
513 ),
514 }
515 : {}),
516 } as React.CSSProperties;
517
518 const cssVars = applyFilters(
519 "sliderberg.editorCssVars",
520 baseCssVars,
521 attributes,
522 ) as React.CSSProperties;
523
524 const navClasses = (
525 applyFilters(
526 "sliderberg.editorWrapperClasses",
527 [
528 "sliderberg-editor-wrapper",
529 `sliderberg-nav-${navigationShape}`,
530 !navigationSizeValue?.all ? `sliderberg-nav-${navigationSize}` : "",
531 navigationSizeValue?.all ? "sliderberg-nav-custom-size" : "",
532 navFreePosition
533 ? "sliderberg-nav-free"
534 : isVerticalDirection
535 ? ""
536 : `sliderberg-nav-h-${navHorizontal} sliderberg-nav-v-${navVertical} sliderberg-dots-h-${dotsHorizontal} sliderberg-dots-v-${dotsVertical}`,
537 navOutside && !navFreePosition ? "sliderberg-nav-outside" : "",
538 isVerticalDirection ? "sliderberg-vertical" : "",
539 ],
540 attributes,
541 ) as string[]
542 )
543 .filter(Boolean)
544 .join(" ");
545
546 const blockProps = useBlockProps({
547 className: navClasses,
548 style: {
549 ...widthStyle,
550 ...cssVars,
551 },
552 });
553
554 // Inner blocks rendered inside swiper-wrapper
555 const innerBlocksProps = useInnerBlocksProps(
556 {
557 className: "swiper-wrapper",
558 },
559 {
560 allowedBlocks: ["sliderberg/slide"],
561 // No template while a layout hasn't been picked yet — the chooser UI
562 // is shown instead of this InnerBlocks area, and slides should only
563 // get auto-inserted once the block actually swaps to Slider/Carousel.
564 template: effectiveMode
565 ? [
566 ["sliderberg/slide", {}],
567 ["sliderberg/slide", {}],
568 ["sliderberg/slide", {}],
569 ]
570 : undefined,
571 orientation: (isVerticalDirection ? "vertical" : "horizontal") as any,
572 renderAppender: false,
573 },
574 );
575
576 const addSlide = () => {
577 pendingSlideIndexRef.current = slideCount;
578 const newSlide = createBlock("sliderberg/slide", {});
579 insertBlock(newSlide, slideCount, clientId);
580 };
581
582 // No layout picked yet — show the chooser instead of the Swiper editor.
583 // Picking a layout just sets `mode` on this same block instance.
584 if (!effectiveMode) {
585 return (
586 <div {...blockProps}>
587 <LayoutChooser
588 onSelect={(pickedMode) => setAttributes({ mode: pickedMode })}
589 />
590 </div>
591 );
592 }
593
594 return (
595 <div {...blockProps}>
596 <SliderInspector attributes={attributes} setAttributes={setAttributes} />
597 <BlockControls>
598 <ToolbarGroup>
599 <ToolbarButton
600 icon={plus}
601 label={__("Add Slide", "sliderberg")}
602 onClick={addSlide}
603 />
604 </ToolbarGroup>
605 </BlockControls>
606
607 <div className="sliderberg-swiper-wrap">
608 <div
609 ref={swiperContainerRef}
610 className="swiper sliderberg-swiper-editor"
611 style={
612 isVerticalDirection
613 ? { height: `${minHeight}px`, overflow: "hidden" }
614 : undefined
615 }
616 >
617 <div {...innerBlocksProps} />
618 </div>
619 {!hideNavigation && (
620 <>
621 <div
622 ref={prevRef}
623 className="swiper-button-prev"
624 style={
625 isVerticalDirection
626 ? {
627 left: "50%",
628 right: "auto",
629 top: "2%",
630 bottom: "auto",
631 marginTop: 0,
632 }
633 : undefined
634 }
635 />
636 <div
637 ref={nextRef}
638 className="swiper-button-next"
639 style={
640 isVerticalDirection
641 ? {
642 left: "50%",
643 right: "auto",
644 top: "auto",
645 bottom: "2%",
646 marginTop: 0,
647 }
648 : undefined
649 }
650 />
651 </>
652 )}
653 {!hideDots && <div ref={paginationRef} className="swiper-pagination" />}
654 </div>
655 </div>
656 );
657 }
658