Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7bb47d1
fix: resolve slide translation and Block Tree selection issues with w…
theMasudRana Mar 2, 2026
4c87580
feat: add hooks for observing DOM mutations and resize events in caro…
theMasudRana Mar 3, 2026
c714069
fix: adjust full and wide alignment styles for carousel container
theMasudRana Mar 3, 2026
022d87c
fix: clarify comment on full alignment breaking out of container
theMasudRana Mar 4, 2026
8fdb47b
fix: refactor slide count change detection logic in useEmblaQueryLoop…
theMasudRana Mar 10, 2026
6cd85f3
Merge branch 'develop' of https://github.com/rtCamp/carousel-kit into…
theMasudRana Mar 10, 2026
e0c9f73
fix: improve slide count detection and optimize viewport scroll handling
theMasudRana Mar 10, 2026
63f428e
fix: add BlockEditorSelectors interface for improved type safety in e…
theMasudRana Mar 10, 2026
7598dc9
fix: ensure viewportEl is set only when node is not null to avoid sta…
theMasudRana Mar 10, 2026
403721f
fix: add box-sizing property to ensure no horizontal scroll bar in ed…
theMasudRana Mar 10, 2026
71ac3f0
fix: improve resize observer logic to prevent unnecessary reinitializ…
theMasudRana Mar 10, 2026
1df925c
fix: refactor viewportEl state management to prevent unnecessary rein…
theMasudRana Mar 10, 2026
cf6541a
fix: update documentation for Embla observers to clarify initializati…
theMasudRana Mar 11, 2026
c1d48a2
fix: enhance resize observer to track column size changes
theMasudRana Mar 11, 2026
976e923
fix: improve first slide observation logic to prevent unnecessary re-…
theMasudRana Mar 11, 2026
6e726b2
fix: consolidate resize and mutation observers into a unified hook fo…
theMasudRana Mar 12, 2026
d4af80c
fix: update comment to reflect change from manual debounced ResizeObs…
theMasudRana Mar 12, 2026
f6bc32d
fix: handle empty template case by destroying Embla instance to preve…
theMasudRana Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/blocks/carousel/editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
157 changes: 157 additions & 0 deletions src/blocks/carousel/hooks/useCarouselObservers.ts
Original file line number Diff line number Diff line change
@@ -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<EmblaCarouselType | undefined>} 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<EmblaCarouselType | undefined>,
initEmblaRef: React.RefObject<( () => void ) | undefined>,
) {
useEffect( () => {
if ( ! viewportEl ) {
return;
}

let resizeTimer: ReturnType<typeof setTimeout> | undefined;
let mutationTimer: ReturnType<typeof setTimeout> | 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<Element, number>();
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;
}
Comment on lines +107 to +112
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the Query Loop becomes empty (currentCount === 0), this hook destroys emblaRef.current and sets it to undefined, but it doesn’t also clear the Embla instance stored on the viewport element (via Symbol.for('carousel-system.carousel')) or update editor state (e.g., canScrollPrev/Next, context emblaApi). That can leave the controls reading a destroyed/stale instance and keep nav state enabled even though there are no slides. Consider centralizing Embla teardown in the viewport init effect (so it can also delete the DOM key and reset nav/context state), or pass callbacks/viewport cleanup helpers into the hook so the destroy path fully cleans up all references/state.

Copilot uses AI. Check for mistakes.

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 ] );
}
5 changes: 5 additions & 0 deletions src/blocks/carousel/styles/_core.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
10 changes: 10 additions & 0 deletions src/blocks/carousel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ export type CarouselSlideAttributes = Record<string, never>;
export type CarouselControlsAttributes = Record<string, never>;
export type CarouselDotsAttributes = Record<string, never>;

/**
* 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';
Expand Down
Loading
Loading