diff --git a/src/blocks/carousel/editor.scss b/src/blocks/carousel/editor.scss index bcb99fa..f70e3a1 100644 --- a/src/blocks/carousel/editor.scss +++ b/src/blocks/carousel/editor.scss @@ -48,6 +48,7 @@ /* Ensure selectable area */ padding: 0.625rem; border: 1px dashed #ccc; + box-sizing: border-box; &.is-selected { border-color: var(--wp-admin-theme-color); diff --git a/src/blocks/carousel/hooks/useCarouselObservers.ts b/src/blocks/carousel/hooks/useCarouselObservers.ts new file mode 100644 index 0000000..7cfef8d --- /dev/null +++ b/src/blocks/carousel/hooks/useCarouselObservers.ts @@ -0,0 +1,157 @@ +import { useEffect } from '@wordpress/element'; +import type { EmblaCarouselType } from 'embla-carousel'; + +const RESIZE_DEBOUNCE_MS = 200; +const MUTATION_DEBOUNCE_MS = 150; + +/** + * Unified observer hook that handles both resize detection and Query Loop + * DOM mutations through a single coordinated MutationObserver. + * + * **Resize detection** (viewport + first slide width changes): + * Uses `reInit()` because resize only affects measurements — the DOM structure + * (container + slides) remains unchanged, so Embla's cached references stay valid. + * + * **Query Loop detection** (slide count changes): + * Uses full destroy/recreate via `initEmblaRef` because Query Loop changes can + * replace the `.wp-block-post-template` element or swap out its children entirely. + * Embla caches references to container and slide elements, so when those DOM + * nodes are replaced, a fresh instance is required. + * + * @param {HTMLDivElement | null} viewportEl - The carousel viewport element to observe + * @param {React.MutableRefObject} emblaRef - Ref to the Embla instance for calling reInit() + * @param {React.RefObject<(() => void) | undefined>} initEmblaRef - Ref to the init function for full Embla recreate + */ +export function useCarouselObservers( + viewportEl: HTMLDivElement | null, + emblaRef: React.MutableRefObject, + initEmblaRef: React.RefObject<( () => void ) | undefined>, +) { + useEffect( () => { + if ( ! viewportEl ) { + return; + } + + let resizeTimer: ReturnType | undefined; + let mutationTimer: ReturnType | undefined; + + // When a full init is in progress, suppress any resize-triggered reInits + // that fire due to DOM churn during the init itself. + let fullInitPending = false; + + const lastWidths = new WeakMap(); + let lastSlideCount = 0; + let observedSlide: Element | null = null; + + const resizeObserver = new ResizeObserver( ( entries ) => { + if ( fullInitPending ) { + return; + } + + let shouldReInit = false; + + for ( const entry of entries ) { + const el = entry.target; + const newWidth = entry.contentRect.width; + const previousWidth = lastWidths.get( el ); + + lastWidths.set( el, newWidth ); + + // Skip first observation — establish baseline width. + if ( previousWidth === undefined ) { + continue; + } + + if ( Math.abs( newWidth - previousWidth ) > 1 ) { + shouldReInit = true; + } + } + + if ( shouldReInit ) { + clearTimeout( resizeTimer ); + resizeTimer = setTimeout( () => { + emblaRef.current?.reInit(); + }, RESIZE_DEBOUNCE_MS ); + } + } ); + + resizeObserver.observe( viewportEl ); + + const updateSlideObservation = () => { + const container = viewportEl.querySelector( '.embla__container, .wp-block-post-template' ); + const firstSlide = container?.querySelector( '.embla__slide, .wp-block-post' ) ?? null; + + if ( firstSlide === observedSlide ) { + return; + } + + if ( observedSlide ) { + resizeObserver.unobserve( observedSlide ); + } + + if ( firstSlide ) { + observedSlide = firstSlide; + resizeObserver.observe( firstSlide ); + } else { + observedSlide = null; + } + }; + + const checkQueryLoopChanges = (): boolean => { + const postTemplate = viewportEl.querySelector( '.wp-block-post-template' ); + const currentCount = postTemplate ? postTemplate.children.length : 0; + + const changed = currentCount !== lastSlideCount; + lastSlideCount = currentCount; + + if ( changed && currentCount === 0 ) { + // Template removed or emptied — destroy to avoid stale references. + emblaRef.current?.destroy(); + emblaRef.current = undefined; + return false; + } + + return changed && currentCount > 0; + }; + + const processMutations = () => { + const needsFullInit = checkQueryLoopChanges(); + + if ( needsFullInit ) { + clearTimeout( resizeTimer ); + fullInitPending = true; + + initEmblaRef.current?.(); + + // Keep the flag set for the full resize debounce window so any + // ResizeObserver callbacks from the init DOM churn are suppressed. + // Reuses resizeTimer so the cleanup in the return handles it automatically. + resizeTimer = setTimeout( () => { + fullInitPending = false; + }, RESIZE_DEBOUNCE_MS ); + } + + updateSlideObservation(); + }; + + const mutationObserver = new MutationObserver( () => { + clearTimeout( mutationTimer ); + mutationTimer = setTimeout( processMutations, MUTATION_DEBOUNCE_MS ); + } ); + + mutationObserver.observe( viewportEl, { childList: true, subtree: true } ); + + // Seed the initial slide count so the first mutation doesn't trigger a spurious init. + const initialTemplate = viewportEl.querySelector( '.wp-block-post-template' ); + lastSlideCount = initialTemplate ? initialTemplate.children.length : 0; + + updateSlideObservation(); + + return () => { + clearTimeout( resizeTimer ); + clearTimeout( mutationTimer ); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [ viewportEl, emblaRef, initEmblaRef ] ); +} diff --git a/src/blocks/carousel/styles/_core.scss b/src/blocks/carousel/styles/_core.scss index e17eea1..80603f0 100644 --- a/src/blocks/carousel/styles/_core.scss +++ b/src/blocks/carousel/styles/_core.scss @@ -4,6 +4,11 @@ --carousel-kit-slide-width: 100%; } +/* Full alignment needs to break out of the container */ +:where(.carousel-kit.alignfull) { + width: 100vw; +} + :where(.carousel-kit) .embla { overflow: hidden; } diff --git a/src/blocks/carousel/types.ts b/src/blocks/carousel/types.ts index fe34731..c59eba4 100644 --- a/src/blocks/carousel/types.ts +++ b/src/blocks/carousel/types.ts @@ -24,6 +24,16 @@ export type CarouselSlideAttributes = Record; export type CarouselControlsAttributes = Record; export type CarouselDotsAttributes = Record; +/** + * Typed subset of the block editor store selectors used in this plugin. + * This avoids `as any` casts while keeping dot-notation and type safety. + */ +export interface BlockEditorSelectors { + getBlocks: ( clientId: string ) => Array<{ clientId: string }>; + getSelectedBlockClientId: () => string | null; + getBlockParents: ( clientId: string ) => string[]; +} + export type CarouselContext = { options: EmblaOptionsType & { slidesToScroll?: number | 'auto'; diff --git a/src/blocks/carousel/viewport/edit.tsx b/src/blocks/carousel/viewport/edit.tsx index 8ff0563..f584c06 100644 --- a/src/blocks/carousel/viewport/edit.tsx +++ b/src/blocks/carousel/viewport/edit.tsx @@ -9,11 +9,12 @@ import { createBlock } from '@wordpress/blocks'; import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { plus } from '@wordpress/icons'; -import type { CarouselViewportAttributes } from '../types'; -import { useContext, useEffect, useRef, useCallback } from '@wordpress/element'; +import type { CarouselViewportAttributes, BlockEditorSelectors } from '../types'; +import { useContext, useEffect, useRef, useCallback, useState } from '@wordpress/element'; import { useMergeRefs } from '@wordpress/compose'; import { EditorCarouselContext } from '../editor-context'; import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel'; +import { useCarouselObservers } from '../hooks/useCarouselObservers'; const EMBLA_KEY = Symbol.for( 'carousel-system.carousel' ); @@ -34,19 +35,59 @@ export default function Edit( { }, } ); - const slideCount = useSelect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ( select ) => ( select( 'core/block-editor' ) as any ).getBlockCount( clientId ) as number, + /** + * Single store subscription for slide count, IDs, and which slide (if any) + * is currently selected — including nested child-block selection. + */ + const { slideCount, selectedSlideIndex } = useSelect( + ( select ) => { + const blockEditor = select( 'core/block-editor' ) as BlockEditorSelectors; + const childBlocks = blockEditor.getBlocks( clientId ); + const slideClientIds = childBlocks.map( ( block ) => block.clientId ); + const count = slideClientIds.length; + + const selectedBlockId = blockEditor.getSelectedBlockClientId(); + let index = -1; + if ( selectedBlockId ) { + index = slideClientIds.indexOf( selectedBlockId ); + if ( index === -1 ) { + const ancestorIds = blockEditor.getBlockParents( selectedBlockId ); + const parentSlideId = ancestorIds.find( ( id ) => slideClientIds.includes( id ) ); + if ( parentSlideId ) { + index = slideClientIds.indexOf( parentSlideId ); + } + } + } + + return { slideCount: count, selectedSlideIndex: index }; + }, [ clientId ], ); const hasSlides = slideCount > 0; const emblaRef = useRef( null ); - const ref = useMergeRefs( [ emblaRef, blockProps.ref ] ); + const emblaApiRef = useRef(); + const initEmblaRef = useRef<() => void>(); + + // viewportEl is state so it triggers hook setup after the DOM mounts. + // initEmblaRef is a ref so the MutationObserver callback always reads + // the latest init function without re-subscribing. + const [ viewportEl, setViewportEl ] = useState( null ); + + // Set viewportEl once on mount. Skips null to avoid state updates during unmount. + const viewportCallbackRef = useCallback( ( node: HTMLDivElement | null ) => { + if ( node ) { + setViewportEl( node ); + } + }, [] ); + + const ref = useMergeRefs( [ emblaRef, blockProps.ref, viewportCallbackRef ] ); const { insertBlock } = useDispatch( 'core/block-editor' ); + useCarouselObservers( viewportEl, emblaApiRef, initEmblaRef ); + const addSlide = useCallback( () => { const block = createBlock( 'carousel-kit/carousel-slide' ); insertBlock( block, undefined, clientId ); @@ -80,35 +121,62 @@ export default function Edit( { ); useEffect( () => { - const api = emblaRef.current - ? ( emblaRef.current as { [ EMBLA_KEY ]?: EmblaCarouselType } )[ EMBLA_KEY ] - : null; - if ( api ) { - setTimeout( () => api.reInit(), 10 ); + if ( ! emblaApiRef.current ) { + return; } + // Defer until after React's commit phase so the new slide DOM is ready. + const timerId = setTimeout( () => emblaApiRef.current?.reInit(), 0 ); + return () => clearTimeout( timerId ); }, [ slideCount ] ); + /** + * Scroll Embla to the selected slide when the user picks a slide from the + * Block Tree (List View) or when a block inside a slide is selected. + * + * Deferred with rAF because Gutenberg's own scrollIntoView fires + * synchronously on selection, setting native scrollLeft on the viewport. + * Our scroll-reset listener (see main init effect) clears that, and then + * this rAF fires Embla's transform-based scroll. + */ + useEffect( () => { + if ( selectedSlideIndex < 0 ) { + return; + } + const id = requestAnimationFrame( () => { + const api = emblaApiRef.current; + if ( api && api.selectedScrollSnap() !== selectedSlideIndex ) { + api.scrollTo( selectedSlideIndex ); + } + } ); + return () => cancelAnimationFrame( id ); + }, [ selectedSlideIndex ] ); + + /** + * Core Embla initialisation effect. + * Observer logic (resize + mutation) has been moved to dedicated hooks + * to keep this effect focused on Embla lifecycle only. + */ useEffect( () => { if ( ! emblaRef.current ) { return; } - const viewportEl = emblaRef.current; + const viewport = emblaRef.current; let embla: EmblaCarouselType | undefined; - const initEmbla = () => { + const init = () => { if ( embla ) { embla.destroy(); } - const queryLoopContainer = viewportEl.querySelector( + const queryLoopContainer = viewport.querySelector( '.wp-block-post-template', ) as HTMLElement; // eslint-disable-next-line @typescript-eslint/no-explicit-any const options = carouselOptions as any; - embla = EmblaCarousel( viewportEl, { + embla = EmblaCarousel( viewport, { loop: options?.loop ?? false, dragFree: options?.dragFree ?? false, containScroll: options?.containScroll || 'trimSnaps', @@ -119,16 +187,15 @@ export default function Edit( { container: queryLoopContainer || undefined, watchDrag: false, // Clicks in slide gaps must not trigger Embla scroll in the editor. watchSlides: false, // Gutenberg injects block UI nodes into .embla__container; Embla's built-in MutationObserver would call reInit() on those, corrupting slide order and transforms. - watchResize: false, // Block toolbar appearing on selection can cause a layout shift that triggers an unwanted reInit. + watchResize: false, // Replaced by a manual debounced ResizeObserver in useCarouselObservers. } ); - ( viewportEl as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ] = embla; + ( viewport as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ] = embla; + emblaApiRef.current = embla; const onSelect = () => { - const canPrev = embla!.canScrollPrev(); - const canNext = embla!.canScrollNext(); - setCanScrollPrev( canPrev ); - setCanScrollNext( canNext ); + setCanScrollPrev( embla!.canScrollPrev() ); + setCanScrollNext( embla!.canScrollNext() ); }; embla.on( 'select', onSelect ); @@ -141,51 +208,47 @@ export default function Edit( { setEmblaApi( embla ); }; - initEmbla(); + // Run initial setup. + init(); - const observer = new MutationObserver( ( mutations ) => { - let shouldReInit = false; + // Keep ref in sync so observer hooks always call the latest init. + initEmblaRef.current = init; - for ( const mutation of mutations ) { - const target = mutation.target as HTMLElement; - - if ( target.classList.contains( 'wp-block-post-template' ) ) { - shouldReInit = true; - break; + /** + * Prevent native scroll offsets from corrupting Embla transforms. + * Gutenberg's scrollIntoView (triggered by List View / Block Tree + * selection) sets scrollLeft/scrollTop on the overflow:hidden viewport. + * Embla assumes these are always 0, so we reset them immediately. + * + * Uses a passive listener and defers DOM writes to rAF to avoid + * blocking the compositor thread and forcing synchronous reflow. + */ + let scrollResetRafId: number | undefined; + const resetNativeScroll = () => { + if ( scrollResetRafId ) { + return; // Already scheduled + } + scrollResetRafId = requestAnimationFrame( () => { + scrollResetRafId = undefined; + if ( viewport.scrollLeft !== 0 ) { + viewport.scrollLeft = 0; } - - if ( - mutation.addedNodes.length > 0 && - ( target.querySelector( '.wp-block-post-template' ) || - Array.from( mutation.addedNodes ).some( - ( node ) => - node instanceof HTMLElement && - node.classList.contains( 'wp-block-post-template' ), - ) ) - ) { - shouldReInit = true; - break; + if ( viewport.scrollTop !== 0 ) { + viewport.scrollTop = 0; } - } - - if ( shouldReInit ) { - setTimeout( initEmbla, 10 ); - } - } ); + } ); + }; - observer.observe( viewportEl, { - childList: true, - subtree: true, - } ); + viewport.addEventListener( 'scroll', resetNativeScroll, { passive: true } ); return () => { - if ( embla ) { - embla.destroy(); - } - if ( observer ) { - observer.disconnect(); + if ( scrollResetRafId ) { + cancelAnimationFrame( scrollResetRafId ); } - delete ( viewportEl as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ]; + viewport.removeEventListener( 'scroll', resetNativeScroll ); + embla?.destroy(); + emblaApiRef.current = undefined; + delete ( viewport as { [EMBLA_KEY]?: typeof embla } )[ EMBLA_KEY ]; }; }, [ setEmblaApi, setCanScrollPrev, setCanScrollNext, carouselOptions ] );