Interactivity API: implement the new `store()` API (https://github.com/woocommerce/woocommerce-blocks/pull/11071)

* Sync Interactivity API code with Gutenberg

* New store() API

* Store raw actions

* Update wc-interactivity-store implementation

* Replace `wc_store` with `wc_initial_state`

* Parse and populate initial state

* Allow store parts in `store()`

* Accept namespaces in directive paths

* Add $$namespace to directives' object values

* Make namespace parsing more robust

* Use DeepPartial type for store parts

* Do not pass `rawStore` to `afterLoad` callbacks

* Simplify `store()` a bit

* Implement `privateStore()`

* Sync context directive with Gutenberg

* Refactor scope and extract getters per scope

* Add namespace to getters and actions

* Remove current privateStore implementation

* Remove `afterLoad` option from `store`

* Use same proxy handlers for ns, getters and actions

* Set scope inside `evaluate`

* Refactor proxy handlers

* Improve types a bit

* Catch errors in async actions

* Implement stacks for scopes and namespaces

* Implement `getElement`

* Change directives object structure

* Remove unnecessary import

* Implement private stores

* Return value from sync actions

* Minor optimizations and improved comments

* Don't use async inside `data-wp-watch`

* Use a single Provider in context directive

* Remove DeepPartial type

* Do not check if element exists

* Add the `current` prop of state inside the scope

* Move getters outside scope

* Fix wc-key assignment

* Fix missing `navigate` in directives

* Fix namespace not being picked in the same element

* Deep merge raw stores instead of proxied ones

* Fix namespace assignment

* Allow forward slashes in namespaces

* Migration of Product Collection and Product Button blocks to the new `store()` API (https://github.com/woocommerce/woocommerce-blocks/pull/11558)

* Refactor Product Button with new store() API

* Use `wc_initial_state` in Product Button

* Fix namespace

* Remove unnecessary state

* Test namespaces in directive paths

* Add test context with namespace

* Simplify woo-test context

* Move addToCart and animations to a file

* Do not pass `rawStore` to `afterLoad` callbacks

* Move callbacks and actions back to the main file

Because the animation was broken.

* Remove selectors in favor of state

* Use default ns in `getContext` for state and actions

* Remove `afterLoad` callback

* Remove unnecessary ns

* Fix getContext in add-to-cart

* Replace namespace and delete unnecessary store

* Pass context types only once

* Use an alternative for requestIdleCallback

* Add previous react code for notices

* Add namespace to Product Collection block

* Replace getTextButton with getButtonText

* Add block name to the ProductCollection namespace

* fix style HTML code

* Remove circular deps error on the Interactivity API

* Product Gallery block: Migrate to new Interactivity API store (https://github.com/woocommerce/woocommerce-blocks/pull/11721)

* Migrate Product Gallery block to new Interactivity API store

* Fix some references

* Add missing data-wc-interactive

* Fix an additional namespace

* Remove unnecessary click handler

* Dialog working

* Refactor action names

* Reindex PHP array

There was some missing indexes, which turned the array into an object in JS.

* Remove unused event handlers

* Move next/previous logic to external function

* Move StorePart util to the types folder

* Rename namespace to `woocommerce/product-gallery`

* Undo product collection namespace renaming

* Remove unnecessary namespace

* Don't hide the large image on page load

* Minor refactorings

* Fix eslint error

* Fix php cs errors with spacing and double arrows alignment

* Disable no-use-before-define rule for eslint

* Disable @typescript-eslint/ban-types rule for eslint

* Fix parsed context error in e2e tests

* Fix context parser for Thumbnail image

* Move store to the top of the frontend file

* Add interactivity api utils to the @woocommerce/utils alias

* Replace deprecated event attribute

---------

Co-authored-by: Luis Herranz <luisherranz@gmail.com>
Co-authored-by: David Arenas <david.arenas@automattic.com>
Co-authored-by: roykho <roykho77@gmail.com>

---------

Co-authored-by: David Arenas <david.arenas@automattic.com>
Co-authored-by: Luigi Teschio <gigitux@gmail.com>
Co-authored-by: Alexandre Lara <allexandrelara@gmail.com>
Co-authored-by: roykho <roykho77@gmail.com>

* Fix error when closing product gallery dialog with keyboard escape key

* use wc_initial_state instead of wc_store

---------

Co-authored-by: Luis Herranz <luisherranz@gmail.com>
Co-authored-by: Luigi Teschio <gigitux@gmail.com>
Co-authored-by: Alexandre Lara <allexandrelara@gmail.com>
Co-authored-by: roykho <roykho77@gmail.com>
This commit is contained in:
David Arenas 2023-11-21 11:46:15 +01:00 committed by GitHub
parent 3a49ae4dd3
commit 9e9f0341e2
32 changed files with 1732 additions and 1169 deletions

View File

@ -1,25 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* External dependencies
*/
import { store, getContext as getContextFn } from '@woocommerce/interactivity';
import { select, subscribe, dispatch } from '@wordpress/data';
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { store as interactivityStore } from '@woocommerce/interactivity';
import { dispatch, select, subscribe } from '@wordpress/data';
import { Cart } from '@woocommerce/type-defs/cart';
import { createRoot } from '@wordpress/element';
import NoticeBanner from '@woocommerce/base-components/notice-banner';
type Context = {
woocommerce: {
isLoading: boolean;
addToCartText: string;
productId: number;
displayViewCart: boolean;
quantityToAdd: number;
temporaryNumberOfItems: number;
animationStatus: AnimationStatus;
};
};
interface Context {
isLoading: boolean;
addToCartText: string;
productId: number;
displayViewCart: boolean;
quantityToAdd: number;
temporaryNumberOfItems: number;
animationStatus: AnimationStatus;
}
enum AnimationStatus {
IDLE = 'IDLE',
@ -27,19 +24,26 @@ enum AnimationStatus {
SLIDE_IN = 'SLIDE-IN',
}
type State = {
woocommerce: {
cart: Cart | undefined;
inTheCartText: string;
interface Store {
state: {
cart?: Cart;
inTheCartText?: string;
numberOfItemsInTheCart: number;
hasCartLoaded: boolean;
slideInAnimation: boolean;
slideOutAnimation: boolean;
addToCartText: string;
displayViewCart: boolean;
};
};
type Store = {
state: State;
context: Context;
selectors: any;
ref: HTMLElement;
};
actions: {
addToCart: () => void;
handleAnimationEnd: ( event: AnimationEvent ) => void;
};
callbacks: {
startAnimation: () => void;
syncTemporaryNumberOfItemsOnLoad: () => void;
};
}
const storeNoticeClass = '.wc-block-store-notices';
@ -64,237 +68,174 @@ const injectNotice = ( domNode: Element, errorMessage: string ) => {
} );
};
// RequestIdleCallback is not available in Safari, so we use setTimeout as an alternative.
const callIdleCallback =
window.requestIdleCallback || ( ( cb ) => setTimeout( cb, 100 ) );
const getProductById = ( cartState: Cart | undefined, productId: number ) => {
return cartState?.items.find( ( item ) => item.id === productId );
};
const getTextButton = ( {
addToCartText,
inTheCartText,
numberOfItems,
}: {
addToCartText: string;
inTheCartText: string;
numberOfItems: number;
} ) => {
if ( numberOfItems === 0 ) {
return addToCartText;
}
return inTheCartText.replace( '###', numberOfItems.toString() );
const getButtonText = (
addToCart: string,
inTheCart: string,
numberOfItems: number
): string => {
if ( numberOfItems === 0 ) return addToCart;
return inTheCart.replace( '###', numberOfItems.toString() );
};
const productButtonSelectors = {
woocommerce: {
addToCartText: ( store: Store ) => {
const { context, state, selectors } = store;
// The `getContextFn` function is wrapped just to avoid prettier issues.
const getContext = ( ns?: string ) => getContextFn< Context >( ns );
const { state } = store< Store >( 'woocommerce/product-button', {
state: {
get slideInAnimation() {
const { animationStatus } = getContext();
return animationStatus === AnimationStatus.SLIDE_IN;
},
get slideOutAnimation() {
const { animationStatus } = getContext();
return animationStatus === AnimationStatus.SLIDE_OUT;
},
get numberOfItemsInTheCart() {
const { productId } = getContext();
const product = getProductById( state.cart, productId );
return product?.quantity || 0;
},
get hasCartLoaded(): boolean {
return !! state.cart;
},
get addToCartText(): string {
const context = getContext();
// We use the temporary number of items when there's no animation, or the
// second part of the animation hasn't started.
if (
context.woocommerce.animationStatus === AnimationStatus.IDLE ||
context.woocommerce.animationStatus ===
AnimationStatus.SLIDE_OUT
context.animationStatus === AnimationStatus.IDLE ||
context.animationStatus === AnimationStatus.SLIDE_OUT
) {
return getTextButton( {
addToCartText: context.woocommerce.addToCartText,
inTheCartText: state.woocommerce.inTheCartText,
numberOfItems: context.woocommerce.temporaryNumberOfItems,
} );
return getButtonText(
context.addToCartText,
state.inTheCartText!,
context.temporaryNumberOfItems
);
}
return getTextButton( {
addToCartText: context.woocommerce.addToCartText,
inTheCartText: state.woocommerce.inTheCartText,
numberOfItems:
selectors.woocommerce.numberOfItemsInTheCart( store ),
} );
},
displayViewCart: ( store: Store ) => {
const { context, selectors } = store;
if ( ! context.woocommerce.displayViewCart ) return false;
if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
return context.woocommerce.temporaryNumberOfItems > 0;
}
return selectors.woocommerce.numberOfItemsInTheCart( store ) > 0;
},
hasCartLoaded: ( { state }: { state: State } ) => {
return state.woocommerce.cart !== undefined;
},
numberOfItemsInTheCart: ( { state, context }: Store ) => {
const product = getProductById(
state.woocommerce.cart,
context.woocommerce.productId
return getButtonText(
context.addToCartText,
state.inTheCartText!,
state.numberOfItemsInTheCart
);
return product?.quantity || 0;
},
slideOutAnimation: ( { context }: Store ) =>
context.woocommerce.animationStatus === AnimationStatus.SLIDE_OUT,
slideInAnimation: ( { context }: Store ) =>
context.woocommerce.animationStatus === AnimationStatus.SLIDE_IN,
},
};
interactivityStore(
// @ts-expect-error: Store function isn't typed.
{
selectors: productButtonSelectors,
actions: {
woocommerce: {
addToCart: async ( store: Store ) => {
const { context, selectors, ref } = store;
if ( ! ref.classList.contains( 'ajax_add_to_cart' ) ) {
return;
}
context.woocommerce.isLoading = true;
// Allow 3rd parties to validate and quit early.
// https://github.com/woocommerce/woocommerce/blob/154dd236499d8a440edf3cde712511b56baa8e45/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js/#L74-L77
const event = new CustomEvent(
'should_send_ajax_request.adding_to_cart',
{ detail: [ ref ], cancelable: true }
);
const shouldSendRequest =
document.body.dispatchEvent( event );
if ( shouldSendRequest === false ) {
const ajaxNotSentEvent = new CustomEvent(
'ajax_request_not_sent.adding_to_cart',
{ detail: [ false, false, ref ] }
);
document.body.dispatchEvent( ajaxNotSentEvent );
return true;
}
try {
await dispatch( storeKey ).addItemToCart(
context.woocommerce.productId,
context.woocommerce.quantityToAdd
);
// After the cart has been updated, sync the temporary number of
// items again.
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
} catch ( error ) {
const storeNoticeBlock =
document.querySelector( storeNoticeClass );
if ( ! storeNoticeBlock ) {
document
.querySelector( '.entry-content' )
?.prepend( createNoticeContainer() );
}
const domNode =
storeNoticeBlock ??
document.querySelector( storeNoticeClass );
if ( domNode ) {
injectNotice( domNode, error.message );
}
// We don't care about errors blocking execution, but will
// console.error for troubleshooting.
// eslint-disable-next-line no-console
console.error( error );
} finally {
context.woocommerce.displayViewCart = true;
context.woocommerce.isLoading = false;
}
},
handleAnimationEnd: (
store: Store & { event: AnimationEvent }
) => {
const { event, context, selectors } = store;
if ( event.animationName === 'slideOut' ) {
// When the first part of the animation (slide-out) ends, we move
// to the second part (slide-in).
context.woocommerce.animationStatus =
AnimationStatus.SLIDE_IN;
} else if ( event.animationName === 'slideIn' ) {
// When the second part of the animation ends, we update the
// temporary number of items to sync it with the cart and reset the
// animation status so it can be triggered again.
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
context.woocommerce.animationStatus =
AnimationStatus.IDLE;
}
},
},
},
init: {
woocommerce: {
syncTemporaryNumberOfItemsOnLoad: ( store: Store ) => {
const { selectors, context } = store;
// If the cart has loaded when we instantiate this element, we sync
// the temporary number of items with the number of items in the cart
// to avoid triggering the animation. We do this only once, but we
// use useLayoutEffect to avoid the useEffect flickering.
if ( selectors.woocommerce.hasCartLoaded( store ) ) {
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
}
},
},
},
effects: {
woocommerce: {
startAnimation: ( store: Store ) => {
const { context, selectors } = store;
// We start the animation if the cart has loaded, the temporary number
// of items is out of sync with the number of items in the cart, the
// button is not loading (because that means the user started the
// interaction) and the animation hasn't started yet.
if (
selectors.woocommerce.hasCartLoaded( store ) &&
context.woocommerce.temporaryNumberOfItems !==
selectors.woocommerce.numberOfItemsInTheCart(
store
) &&
! context.woocommerce.isLoading &&
context.woocommerce.animationStatus ===
AnimationStatus.IDLE
) {
context.woocommerce.animationStatus =
AnimationStatus.SLIDE_OUT;
}
},
},
get displayViewCart(): boolean {
const { displayViewCart, temporaryNumberOfItems } = getContext();
if ( ! displayViewCart ) return false;
if ( ! state.hasCartLoaded ) {
return temporaryNumberOfItems > 0;
}
return state.numberOfItemsInTheCart > 0;
},
},
{
afterLoad: ( store: Store ) => {
const { state, selectors } = store;
// Subscribe to changes in Cart data.
subscribe( () => {
const cartData = select( storeKey ).getCartData();
const isResolutionFinished =
select( storeKey ).hasFinishedResolution( 'getCartData' );
if ( isResolutionFinished ) {
state.woocommerce.cart = cartData;
}
}, storeKey );
actions: {
*addToCart() {
const context = getContext();
const { productId, quantityToAdd } = context;
// This selector triggers a fetch of the Cart data. It is done in a
// `requestIdleCallback` to avoid potential performance issues.
callIdleCallback( () => {
if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
select( storeKey ).getCartData();
context.isLoading = true;
try {
yield dispatch( storeKey ).addItemToCart(
productId,
quantityToAdd
);
// After the cart is updated, sync the temporary number of items again.
context.temporaryNumberOfItems = state.numberOfItemsInTheCart;
} catch ( error ) {
const storeNoticeBlock =
document.querySelector( storeNoticeClass );
if ( ! storeNoticeBlock ) {
document
.querySelector( '.entry-content' )
?.prepend( createNoticeContainer() );
}
} );
const domNode =
storeNoticeBlock ??
document.querySelector( storeNoticeClass );
if ( domNode ) {
injectNotice( domNode, ( error as Error ).message );
}
// We don't care about errors blocking execution, but will
// console.error for troubleshooting.
// eslint-disable-next-line no-console
console.error( error );
} finally {
context.displayViewCart = true;
context.isLoading = false;
}
},
handleAnimationEnd: ( event: AnimationEvent ) => {
const context = getContext();
if ( event.animationName === 'slideOut' ) {
// When the first part of the animation (slide-out) ends, we move
// to the second part (slide-in).
context.animationStatus = AnimationStatus.SLIDE_IN;
} else if ( event.animationName === 'slideIn' ) {
// When the second part of the animation ends, we update the
// temporary number of items to sync it with the cart and reset the
// animation status so it can be triggered again.
context.temporaryNumberOfItems = state.numberOfItemsInTheCart;
context.animationStatus = AnimationStatus.IDLE;
}
},
},
callbacks: {
syncTemporaryNumberOfItemsOnLoad: () => {
const context = getContext();
// If the cart has loaded when we instantiate this element, we sync
// the temporary number of items with the number of items in the cart
// to avoid triggering the animation. We do this only once, but we
// use useLayoutEffect to avoid the useEffect flickering.
if ( state.hasCartLoaded ) {
context.temporaryNumberOfItems = state.numberOfItemsInTheCart;
}
},
startAnimation: () => {
const context = getContext();
// We start the animation if the cart has loaded, the temporary number
// of items is out of sync with the number of items in the cart, the
// button is not loading (because that means the user started the
// interaction) and the animation hasn't started yet.
if (
state.hasCartLoaded &&
context.temporaryNumberOfItems !==
state.numberOfItemsInTheCart &&
! context.isLoading &&
context.animationStatus === AnimationStatus.IDLE
) {
context.animationStatus = AnimationStatus.SLIDE_OUT;
}
},
},
} );
// Subscribe to changes in Cart data.
subscribe( () => {
const cartData = select( storeKey ).getCartData();
const isResolutionFinished =
select( storeKey ).hasFinishedResolution( 'getCartData' );
if ( isResolutionFinished ) {
state.cart = cartData;
}
);
}, storeKey );
// RequestIdleCallback is not available in Safari, so we use setTimeout as an alternative.
const callIdleCallback =
window.requestIdleCallback || ( ( cb ) => setTimeout( cb, 100 ) );
// This selector triggers a fetch of the Cart data. It is done in a
// `requestIdleCallback` to avoid potential performance issues.
callIdleCallback( () => {
if ( ! state.hasCartLoaded ) {
select( storeKey ).getCartData();
}
} );

View File

@ -1,233 +1,158 @@
/**
* External dependencies
*/
import { store as interactivityApiStore } from '@woocommerce/interactivity';
import { store, getContext as getContextFn } from '@woocommerce/interactivity';
import { StorePart } from '@woocommerce/utils';
interface State {
[ key: string ]: unknown;
export interface ProductGalleryContext {
selectedImage: string;
firstMainImageId: string;
imageId: string;
visibleImagesIds: string[];
dialogVisibleImagesIds: string[];
isDialogOpen: boolean;
productId: string;
}
export interface ProductGalleryInteractivityApiContext {
woocommerce: {
selectedImage: string;
firstMainImageId: string;
imageId: string;
visibleImagesIds: string[];
dialogVisibleImagesIds: string[];
isDialogOpen: boolean;
productId: string;
};
}
const getContext = ( ns?: string ) =>
getContextFn< ProductGalleryContext >( ns );
export interface ProductGallerySelectors {
woocommerce: {
isSelected: ( store: unknown ) => boolean;
pagerDotFillOpacity: ( store: SelectorsStore ) => number;
selectedImageIndex: ( store: SelectorsStore ) => number;
isDialogOpen: ( store: unknown ) => boolean;
};
}
type Store = typeof productGallery & StorePart< ProductGallery >;
const { state } = store< Store >( 'woocommerce/product-gallery' );
interface Actions {
woocommerce: {
thumbnails: {
handleClick: (
context: ProductGalleryInteractivityApiContext
) => void;
};
handlePreviousImageButtonClick: {
( store: Store ): void;
};
handleNextImageButtonClick: {
( store: Store ): void;
};
dialog: {
handleCloseButtonClick: {
( store: Store ): void;
};
};
};
}
const selectImage = (
context: ProductGalleryContext,
select: 'next' | 'previous'
) => {
const imagesIds =
context[
context.isDialogOpen ? 'dialogVisibleImagesIds' : 'visibleImagesIds'
];
const selectedImageIdIndex = imagesIds.indexOf( context.selectedImage );
const nextImageIndex =
select === 'next'
? Math.min( selectedImageIdIndex + 1, imagesIds.length - 1 )
: Math.max( selectedImageIdIndex - 1, 0 );
context.selectedImage = imagesIds[ nextImageIndex ];
};
interface Store {
state: State;
context: ProductGalleryInteractivityApiContext;
selectors: ProductGallerySelectors;
actions: Actions;
ref?: HTMLElement;
}
const closeDialog = ( context: ProductGalleryContext ) => {
context.isDialogOpen = false;
// Reset the main image.
context.selectedImage = context.firstMainImageId;
};
interface Event {
keyCode: number;
}
type SelectorsStore = Pick< Store, 'context' | 'selectors' | 'ref' >;
enum Keys {
ESC = 27,
LEFT_ARROW = 37,
RIGHT_ARROW = 39,
}
interactivityApiStore( {
state: {},
effects: {
woocommerce: {
watchForChangesOnAddToCartForm: ( store: Store ) => {
const variableProductCartForm = document.querySelector(
`form[data-product_id="${ store.context.woocommerce.productId }"]`
);
if ( ! variableProductCartForm ) {
return;
}
const observer = new MutationObserver( function ( mutations ) {
for ( const mutation of mutations ) {
const mutationTarget = mutation.target as HTMLElement;
const currentImageAttribute =
mutationTarget.getAttribute( 'current-image' );
if (
mutation.type === 'attributes' &&
currentImageAttribute &&
store.context.woocommerce.visibleImagesIds.includes(
currentImageAttribute
)
) {
store.context.woocommerce.selectedImage =
currentImageAttribute;
}
}
} );
observer.observe( variableProductCartForm, {
attributes: true,
} );
return () => {
observer.disconnect();
};
},
keyboardAccess: ( store: Store ) => {
const { context, actions } = store;
let allowNavigation = true;
const handleKeyEvents = ( event: Event ) => {
if (
! allowNavigation ||
! context.woocommerce?.isDialogOpen
) {
return;
}
// Disable navigation for a brief period to prevent spamming.
allowNavigation = false;
requestAnimationFrame( () => {
allowNavigation = true;
} );
// Check if the esc key is pressed.
if ( event.keyCode === Keys.ESC ) {
actions.woocommerce.dialog.handleCloseButtonClick(
store
);
}
// Check if left arrow key is pressed.
if ( event.keyCode === Keys.LEFT_ARROW ) {
actions.woocommerce.handlePreviousImageButtonClick(
store
);
}
// Check if right arrow key is pressed.
if ( event.keyCode === Keys.RIGHT_ARROW ) {
actions.woocommerce.handleNextImageButtonClick( store );
}
};
document.addEventListener( 'keydown', handleKeyEvents );
},
const productGallery = {
state: {
get isSelected() {
const { selectedImage, imageId } = getContext();
return selectedImage === imageId;
},
},
selectors: {
woocommerce: {
isSelected: ( { context }: Store ) => {
return (
context?.woocommerce.selectedImage ===
context?.woocommerce.imageId
);
},
pagerDotFillOpacity( store: SelectorsStore ) {
const { context } = store;
return context?.woocommerce.selectedImage ===
context?.woocommerce.imageId
? 1
: 0.2;
},
isDialogOpen: ( { context }: Store ) => {
return context.woocommerce.isDialogOpen;
},
get pagerDotFillOpacity(): number {
return state.isSelected ? 1 : 0.2;
},
},
actions: {
woocommerce: {
thumbnails: {
handleClick: ( { context }: Store ) => {
context.woocommerce.selectedImage =
context.woocommerce.imageId;
},
},
dialog: {
handleCloseButtonClick: ( { context }: Store ) => {
context.woocommerce.isDialogOpen = false;
// Reset the main image.
context.woocommerce.selectedImage =
context.woocommerce.firstMainImageId;
},
},
handleSelectImage: ( { context }: Store ) => {
context.woocommerce.selectedImage = context.woocommerce.imageId;
},
handleNextImageButtonClick: ( store: Store ) => {
const { context } = store;
const imagesIds =
context.woocommerce[
context.woocommerce.isDialogOpen
? 'dialogVisibleImagesIds'
: 'visibleImagesIds'
];
const selectedImageIdIndex = imagesIds.indexOf(
context.woocommerce.selectedImage
);
const nextImageIndex = Math.min(
selectedImageIdIndex + 1,
imagesIds.length - 1
);
context.woocommerce.selectedImage = imagesIds[ nextImageIndex ];
},
handlePreviousImageButtonClick: ( store: Store ) => {
const { context } = store;
const imagesIds =
context.woocommerce[
context.woocommerce.isDialogOpen
? 'dialogVisibleImagesIds'
: 'visibleImagesIds'
];
const selectedImageIdIndex = imagesIds.indexOf(
context.woocommerce.selectedImage
);
const previousImageIndex = Math.max(
selectedImageIdIndex - 1,
0
);
context.woocommerce.selectedImage =
imagesIds[ previousImageIndex ];
},
closeDialog: () => {
const context = getContext();
closeDialog( context );
},
openDialog: () => {
const context = getContext();
context.isDialogOpen = true;
},
selectImage: () => {
const context = getContext();
context.selectedImage = context.imageId;
},
selectNextImage: ( event: MouseEvent ) => {
event.stopPropagation();
const context = getContext();
selectImage( context, 'next' );
},
selectPreviousImage: ( event: MouseEvent ) => {
event.stopPropagation();
const context = getContext();
selectImage( context, 'previous' );
},
},
} );
callbacks: {
watchForChangesOnAddToCartForm: () => {
const context = getContext();
const variableProductCartForm = document.querySelector(
`form[data-product_id="${ context.productId }"]`
);
if ( ! variableProductCartForm ) {
return;
}
// TODO: Replace with an interactive block that calls `actions.selectImage`.
const observer = new MutationObserver( function ( mutations ) {
for ( const mutation of mutations ) {
const mutationTarget = mutation.target as HTMLElement;
const currentImageAttribute =
mutationTarget.getAttribute( 'current-image' );
if (
mutation.type === 'attributes' &&
currentImageAttribute &&
context.visibleImagesIds.includes(
currentImageAttribute
)
) {
context.selectedImage = currentImageAttribute;
}
}
} );
observer.observe( variableProductCartForm, {
attributes: true,
} );
return () => {
observer.disconnect();
};
},
keyboardAccess: () => {
const context = getContext();
let allowNavigation = true;
const handleKeyEvents = ( event: KeyboardEvent ) => {
if ( ! allowNavigation || ! context.isDialogOpen ) {
return;
}
// Disable navigation for a brief period to prevent spamming.
allowNavigation = false;
requestAnimationFrame( () => {
allowNavigation = true;
} );
// Check if the esc key is pressed.
if ( event.code === 'Escape' ) {
closeDialog( context );
}
// Check if left arrow key is pressed.
if ( event.code === 'ArrowLeft' ) {
selectImage( context, 'previous' );
}
// Check if right arrow key is pressed.
if ( event.code === 'ArrowRight' ) {
selectImage( context, 'next' );
}
};
document.addEventListener( 'keydown', handleKeyEvents );
return () =>
document.removeEventListener( 'keydown', handleKeyEvents );
},
},
};
store( 'woocommerce/product-gallery', productGallery );
export type ProductGallery = typeof productGallery;

View File

@ -1,173 +1,121 @@
/**
* External dependencies
*/
import { store as interactivityStore } from '@woocommerce/interactivity';
import {
store,
getContext as getContextFn,
getElement,
} from '@woocommerce/interactivity';
import { StorePart } from '@woocommerce/utils';
/**
* Internal dependencies
*/
import {
ProductGalleryInteractivityApiContext,
ProductGallerySelectors,
} from '../../frontend';
import type { ProductGalleryContext, ProductGallery } from '../../frontend';
type Context = {
woocommerce: {
styles:
| {
// eslint-disable-next-line @typescript-eslint/naming-convention
'transform-origin': string;
transform: string;
transition: string;
}
| undefined;
isDialogOpen: boolean;
styles: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'transform-origin': string;
transform: string;
transition: string;
};
} & ProductGalleryInteractivityApiContext;
} & ProductGalleryContext;
type Store = {
context: Context;
selectors: typeof productGalleryLargeImageSelectors &
ProductGallerySelectors;
ref: HTMLElement;
};
const getContext = ( ns?: string ) => getContextFn< Context >( ns );
const productGalleryLargeImageSelectors = {
woocommerce: {
productGalleryLargeImage: {
styles: ( { context }: Store ) => {
const { styles } = context.woocommerce;
type Store = typeof productGalleryLargeImage & StorePart< ProductGallery >;
const { state, actions } = store< Store >( 'woocommerce/product-gallery' );
return Object.entries( styles ?? [] ).reduce(
( acc, [ key, value ] ) => {
const style = `${ key }:${ value };`;
return acc.length > 0 ? `${ acc } ${ style }` : style;
},
''
);
},
let isDialogStatusChanged = false;
const productGalleryLargeImage = {
state: {
get styles() {
const { styles } = getContext();
return Object.entries( styles ?? [] ).reduce(
( acc, [ key, value ] ) => {
const style = `${ key }:${ value };`;
return acc.length > 0 ? `${ acc } ${ style }` : style;
},
''
);
},
},
actions: {
startZoom: ( event: MouseEvent ) => {
const target = event.target as HTMLElement;
const isMouseEventFromLargeImage = target.classList.contains(
'wc-block-woocommerce-product-gallery-large-image__image'
);
if ( ! isMouseEventFromLargeImage ) {
return actions.resetZoom();
}
const element = event.target as HTMLElement;
const percentageX = ( event.offsetX / element.clientWidth ) * 100;
const percentageY = ( event.offsetY / element.clientHeight ) * 100;
const { styles } = getContext();
if ( styles ) {
styles.transform = `scale(1.3)`;
styles[
'transform-origin'
] = `${ percentageX }% ${ percentageY }%`;
}
},
resetZoom: () => {
const context = getContext();
if ( context.styles ) {
context.styles.transform = `scale(1.0)`;
context.styles[ 'transform-origin' ] = '';
}
},
},
callbacks: {
scrollInto: () => {
if ( ! state.isSelected ) {
return;
}
const { isDialogOpen } = getContext();
const { ref } = getElement();
// Scroll to the selected image with a smooth animation.
if ( isDialogOpen === isDialogStatusChanged ) {
ref.scrollIntoView( {
behavior: 'smooth',
block: 'nearest',
inline: 'center',
} );
}
// Scroll to the selected image when the dialog is being opened without an animation.
if (
isDialogOpen &&
isDialogOpen !== isDialogStatusChanged &&
ref.closest( 'dialog' )
) {
ref.scrollIntoView( {
behavior: 'instant',
block: 'nearest',
inline: 'center',
} );
isDialogStatusChanged = isDialogOpen;
}
// Scroll to the selected image when the dialog is being closed without an animation.
if ( ! isDialogOpen && isDialogOpen !== isDialogStatusChanged ) {
ref.scrollIntoView( {
behavior: 'instant',
block: 'nearest',
inline: 'center',
} );
isDialogStatusChanged = isDialogOpen;
}
},
},
};
let isDialogStatusChanged = false;
const resetImageZoom = ( context: Context ) => {
if ( context.woocommerce.styles ) {
context.woocommerce.styles.transform = `scale(1.0)`;
context.woocommerce.styles[ 'transform-origin' ] = '';
}
};
interactivityStore(
// @ts-expect-error: Store function isn't typed.
{
selectors: productGalleryLargeImageSelectors,
actions: {
woocommerce: {
handleMouseMove: ( {
event,
context,
}: {
event: MouseEvent;
context: Context;
} ) => {
const target = event.target as HTMLElement;
const isMouseEventFromLargeImage =
target.classList.contains(
'wc-block-woocommerce-product-gallery-large-image__image'
);
if ( ! isMouseEventFromLargeImage ) {
resetImageZoom( context );
return;
}
const element = event.target as HTMLElement;
const percentageX =
( event.offsetX / element.clientWidth ) * 100;
const percentageY =
( event.offsetY / element.clientHeight ) * 100;
if ( context.woocommerce.styles ) {
context.woocommerce.styles.transform = `scale(1.3)`;
context.woocommerce.styles[
'transform-origin'
] = `${ percentageX }% ${ percentageY }%`;
}
},
handleMouseLeave: ( { context }: { context: Context } ) => {
resetImageZoom( context );
},
handleClick: ( {
context,
event,
}: {
context: Context;
event: Event;
} ) => {
if (
( event.target as HTMLElement ).classList.contains(
'wc-block-product-gallery-dialog-on-click'
)
) {
context.woocommerce.isDialogOpen = true;
}
},
},
},
effects: {
woocommerce: {
scrollInto: ( store: Store ) => {
if ( ! store.selectors.woocommerce.isSelected( store ) ) {
return;
}
// Scroll to the selected image with a smooth animation.
if (
store.context.woocommerce.isDialogOpen ===
isDialogStatusChanged
) {
store.ref.scrollIntoView( {
behavior: 'smooth',
block: 'nearest',
inline: 'center',
} );
}
// Scroll to the selected image when the dialog is being opened without an animation.
if (
store.context.woocommerce.isDialogOpen &&
store.context.woocommerce.isDialogOpen !==
isDialogStatusChanged &&
store.ref.closest( 'dialog' )
) {
store.ref.scrollIntoView( {
behavior: 'instant',
block: 'nearest',
inline: 'center',
} );
isDialogStatusChanged =
store.context.woocommerce.isDialogOpen;
}
// Scroll to the selected image when the dialog is being closed without an animation.
if (
! store.context.woocommerce.isDialogOpen &&
store.context.woocommerce.isDialogOpen !==
isDialogStatusChanged
) {
store.ref.scrollIntoView( {
behavior: 'instant',
block: 'nearest',
inline: 'center',
} );
isDialogStatusChanged =
store.context.woocommerce.isDialogOpen;
}
},
},
},
}
);
store< Store >( 'woocommerce/product-gallery', productGalleryLargeImage );

View File

@ -1,2 +1 @@
export const csnMetaTagItemprop = 'wc-client-side-navigation';
export const directivePrefix = 'wc';

View File

@ -1,14 +1,25 @@
// @ts-nocheck
/**
* External dependencies
*/
import {
useContext,
useMemo,
useEffect,
useLayoutEffect,
useRef,
useLayoutEffect,
} from 'preact/hooks';
import { deepSignal, peek } from 'deepsignal';
/**
* Internal dependencies
*/
import { createPortal } from './portals';
import { useSignalEffect } from './utils';
import { directive } from './hooks';
import { prefetch, navigate } from './router';
import { SlotProvider, Slot, Fill } from './slots';
import { navigate } from './router';
const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
@ -32,21 +43,24 @@ export default () => {
directive(
'context',
( {
directives: {
context: { default: newContext },
},
directives: { context },
props: { children },
context: inheritedContext,
} ) => {
const { Provider } = inheritedContext;
const inheritedValue = useContext( inheritedContext );
const currentValue = useRef( deepSignal( {} ) );
const passedValues = context.map( ( { value } ) => value );
currentValue.current = useMemo( () => {
const newValue = deepSignal( newContext );
const newValue = context
.map( ( c ) => deepSignal( { [ c.namespace ]: c.value } ) )
.reduceRight( mergeDeepSignals );
mergeDeepSignals( newValue, inheritedValue );
mergeDeepSignals( currentValue.current, newValue, true );
return currentValue.current;
}, [ newContext, inheritedValue ] );
}, [ inheritedValue, ...passedValues ] );
return (
<Provider value={ currentValue.current }>{ children }</Provider>
@ -55,57 +69,40 @@ export default () => {
{ priority: 5 }
);
// data-wc-effect--[name]
directive( 'effect', ( { directives: { effect }, context, evaluate } ) => {
const contextValue = useContext( context );
Object.values( effect ).forEach( ( path ) => {
useSignalEffect( () => {
return evaluate( path, { context: contextValue } );
} );
// data-wc-body
directive( 'body', ( { props: { children } } ) => {
return createPortal( children, document.body );
} );
// data-wc-watch--[name]
directive( 'watch', ( { directives: { watch }, evaluate } ) => {
watch.forEach( ( entry ) => {
useSignalEffect( () => evaluate( entry ) );
} );
} );
// data-wc-layout-init--[name]
directive(
'layout-init',
( {
directives: { 'layout-init': layoutInit },
context,
evaluate,
} ) => {
const contextValue = useContext( context );
Object.values( layoutInit ).forEach( ( path ) => {
useLayoutEffect( () => {
return evaluate( path, { context: contextValue } );
}, [] );
( { directives: { 'layout-init': layoutInit }, evaluate } ) => {
layoutInit.forEach( ( entry ) => {
useLayoutEffect( () => evaluate( entry ), [] );
} );
}
);
// data-wc-init--[name]
directive( 'init', ( { directives: { init }, context, evaluate } ) => {
const contextValue = useContext( context );
Object.values( init ).forEach( ( path ) => {
useEffect( () => {
return evaluate( path, { context: contextValue } );
}, [] );
directive( 'init', ( { directives: { init }, evaluate } ) => {
init.forEach( ( entry ) => {
useEffect( () => evaluate( entry ), [] );
} );
} );
// data-wc-on--[event]
directive( 'on', ( { directives: { on }, element, evaluate, context } ) => {
const contextValue = useContext( context );
const events = new Map();
Object.entries( on ).forEach( ( [ name, path ] ) => {
const event = name.split( '--' )[ 0 ];
if ( ! events.has( event ) ) events.set( event, new Set() );
events.get( event ).add( path );
} );
events.forEach( ( paths, event ) => {
element.props[ `on${ event }` ] = ( event ) => {
paths.forEach( ( path ) => {
evaluate( path, { event, context: contextValue } );
} );
directive( 'on', ( { directives: { on }, element, evaluate } ) => {
on.forEach( ( entry ) => {
element.props[ `on${ entry.suffix }` ] = ( event ) => {
evaluate( entry, event );
};
} );
} );
@ -113,20 +110,12 @@ export default () => {
// data-wc-class--[classname]
directive(
'class',
( {
directives: { class: className },
element,
evaluate,
context,
} ) => {
const contextValue = useContext( context );
Object.keys( className )
.filter( ( n ) => n !== 'default' )
.forEach( ( name ) => {
const result = evaluate( className[ name ], {
className: name,
context: contextValue,
} );
( { directives: { class: className }, element, evaluate } ) => {
className
.filter( ( { suffix } ) => suffix !== 'default' )
.forEach( ( entry ) => {
const name = entry.suffix;
const result = evaluate( entry, { className: name } );
const currentClass = element.props.class || '';
const classFinder = new RegExp(
`(^|\\s)${ name }(\\s|$)`,
@ -142,9 +131,9 @@ export default () => {
: name;
useEffect( () => {
// This seems necessary because Preact doesn't change the class names
// on the hydration, so we have to do it manually. It doesn't need
// deps because it only needs to do it the first time.
// This seems necessary because Preact doesn't change the class
// names on the hydration, so we have to do it manually. It doesn't
// need deps because it only needs to do it the first time.
if ( ! result ) {
element.ref.current.classList.remove( name );
} else {
@ -155,56 +144,155 @@ export default () => {
}
);
// data-wc-bind--[attribute]
directive(
'bind',
( { directives: { bind }, element, context, evaluate } ) => {
const contextValue = useContext( context );
Object.entries( bind )
.filter( ( n ) => n !== 'default' )
.forEach( ( [ attribute, path ] ) => {
const result = evaluate( path, {
context: contextValue,
} );
element.props[ attribute ] = result;
const newRule =
/(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g;
const ruleClean = /\/\*[^]*?\*\/| +/g;
const ruleNewline = /\n+/g;
const empty = ' ';
// This seems necessary because Preact doesn't change the attributes
// on the hydration, so we have to do it manually. It doesn't need
// deps because it only needs to do it the first time.
useEffect( () => {
// aria- and data- attributes have no boolean representation.
// A `false` value is different from the attribute not being
// present, so we can't remove it.
// We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
if ( result === false && attribute[ 4 ] !== '-' ) {
element.ref.current.removeAttribute( attribute );
} else {
element.ref.current.setAttribute(
attribute,
result === true && attribute[ 4 ] !== '-'
? ''
: result
);
}
}, [] );
} );
/**
* Convert a css style string into a object.
*
* Made by Cristian Bote (@cristianbote) for Goober.
* https://unpkg.com/browse/goober@2.1.13/src/core/astish.js
*
* @param {string} val CSS string.
* @return {Object} CSS object.
*/
const cssStringToObject = ( val ) => {
const tree = [ {} ];
let block, left;
while ( ( block = newRule.exec( val.replace( ruleClean, '' ) ) ) ) {
if ( block[ 4 ] ) {
tree.shift();
} else if ( block[ 3 ] ) {
left = block[ 3 ].replace( ruleNewline, empty ).trim();
tree.unshift( ( tree[ 0 ][ left ] = tree[ 0 ][ left ] || {} ) );
} else {
tree[ 0 ][ block[ 1 ] ] = block[ 2 ]
.replace( ruleNewline, empty )
.trim();
}
}
);
return tree[ 0 ];
};
// data-wc-style--[style-key]
directive( 'style', ( { directives: { style }, element, evaluate } ) => {
style
.filter( ( { suffix } ) => suffix !== 'default' )
.forEach( ( entry ) => {
const key = entry.suffix;
const result = evaluate( entry, { key } );
element.props.style = element.props.style || {};
if ( typeof element.props.style === 'string' )
element.props.style = cssStringToObject(
element.props.style
);
if ( ! result ) delete element.props.style[ key ];
else element.props.style[ key ] = result;
useEffect( () => {
// This seems necessary because Preact doesn't change the styles on
// the hydration, so we have to do it manually. It doesn't need deps
// because it only needs to do it the first time.
if ( ! result ) {
element.ref.current.style.removeProperty( key );
} else {
element.ref.current.style[ key ] = result;
}
}, [] );
} );
} );
// data-wc-bind--[attribute]
directive( 'bind', ( { directives: { bind }, element, evaluate } ) => {
bind.filter( ( { suffix } ) => suffix !== 'default' ).forEach(
( entry ) => {
const attribute = entry.suffix;
const result = evaluate( entry );
element.props[ attribute ] = result;
// Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`.
// We need this workaround until the following issue is solved:
// https://github.com/preactjs/preact/issues/4136
useLayoutEffect( () => {
if (
attribute === 'role' &&
( result === null || result === undefined )
) {
element.ref.current.removeAttribute( attribute );
}
}, [ attribute, result ] );
// This seems necessary because Preact doesn't change the attributes
// on the hydration, so we have to do it manually. It doesn't need
// deps because it only needs to do it the first time.
useEffect( () => {
const el = element.ref.current;
// We set the value directly to the corresponding
// HTMLElement instance property excluding the following
// special cases.
// We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129
if (
attribute !== 'width' &&
attribute !== 'height' &&
attribute !== 'href' &&
attribute !== 'list' &&
attribute !== 'form' &&
// Default value in browsers is `-1` and an empty string is
// cast to `0` instead
attribute !== 'tabIndex' &&
attribute !== 'download' &&
attribute !== 'rowSpan' &&
attribute !== 'colSpan' &&
attribute !== 'role' &&
attribute in el
) {
try {
el[ attribute ] =
result === null || result === undefined
? ''
: result;
return;
} catch ( err ) {}
}
// aria- and data- attributes have no boolean representation.
// A `false` value is different from the attribute not being
// present, so we can't remove it.
// We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136
if (
result !== null &&
result !== undefined &&
( result !== false || attribute[ 4 ] === '-' )
) {
el.setAttribute( attribute, result );
} else {
el.removeAttribute( attribute );
}
}, [] );
}
);
} );
// data-wc-navigation-link
directive(
'navigation-link',
( {
directives: {
'navigation-link': { default: link },
},
directives: { 'navigation-link': navigationLink },
props: { href },
element,
} ) => {
const { value: link } = navigationLink.find(
( { suffix } ) => suffix === 'default'
);
useEffect( () => {
// Prefetch the page if it is in the directive options.
if ( link?.prefetch ) {
prefetch( href );
// prefetch( href );
}
} );
@ -231,26 +319,6 @@ export default () => {
}
);
// data-wc-show
directive(
'show',
( {
directives: {
show: { default: show },
},
element,
evaluate,
context,
} ) => {
const contextValue = useContext( context );
if ( ! evaluate( show, { context: contextValue } ) )
element.props.children = (
<template>{ element.props.children }</template>
);
}
);
// data-wc-ignore
directive(
'ignore',
@ -272,20 +340,66 @@ export default () => {
);
// data-wc-text
directive( 'text', ( { directives: { text }, element, evaluate } ) => {
const entry = text.find( ( { suffix } ) => suffix === 'default' );
element.props.children = evaluate( entry );
} );
// data-wc-slot
directive(
'text',
( {
directives: {
text: { default: text },
},
element,
evaluate,
context,
} ) => {
const contextValue = useContext( context );
element.props.children = evaluate( text, {
context: contextValue,
} );
}
'slot',
( { directives: { slot }, props: { children }, element } ) => {
const { value } = slot.find(
( { suffix } ) => suffix === 'default'
);
const name = typeof value === 'string' ? value : value.name;
const position = value.position || 'children';
if ( position === 'before' ) {
return (
<>
<Slot name={ name } />
{ children }
</>
);
}
if ( position === 'after' ) {
return (
<>
{ children }
<Slot name={ name } />
</>
);
}
if ( position === 'replace' ) {
return <Slot name={ name }>{ children }</Slot>;
}
if ( position === 'children' ) {
element.props.children = (
<Slot name={ name }>{ element.props.children }</Slot>
);
}
},
{ priority: 4 }
);
// data-wc-fill
directive(
'fill',
( { directives: { fill }, props: { children }, evaluate } ) => {
const entry = fill.find( ( { suffix } ) => suffix === 'default' );
const slot = evaluate( entry );
return <Fill slot={ slot }>{ children }</Fill>;
},
{ priority: 4 }
);
// data-wc-slot-provider
directive(
'slot-provider',
( { props: { children } } ) => (
<SlotProvider>{ children }</SlotProvider>
),
{ priority: 4 }
);
};

View File

@ -1,140 +0,0 @@
import { h, options, createContext, cloneElement } from 'preact';
import { useRef, useMemo } from 'preact/hooks';
import { rawStore as store } from './store';
// Main context.
const context = createContext( {} );
// WordPress Directives.
const directiveMap = {};
const directivePriorities = {};
export const directive = ( name, cb, { priority = 10 } = {} ) => {
directiveMap[ name ] = cb;
directivePriorities[ name ] = priority;
};
// Resolve the path to some property of the store object.
const resolve = ( path, ctx ) => {
let current = { ...store, context: ctx };
path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) );
return current;
};
// Generate the evaluate function.
const getEvaluate =
( { ref } = {} ) =>
( path, extraArgs = {} ) => {
// If path starts with !, remove it and save a flag.
const hasNegationOperator =
path[ 0 ] === '!' && !! ( path = path.slice( 1 ) );
const value = resolve( path, extraArgs.context );
const returnValue =
typeof value === 'function'
? value( {
ref: ref.current,
...store,
...extraArgs,
} )
: value;
return hasNegationOperator ? ! returnValue : returnValue;
};
// Separate directives by priority. The resulting array contains objects
// of directives grouped by same priority, and sorted in ascending order.
const usePriorityLevels = ( directives ) =>
useMemo( () => {
const byPriority = Object.entries( directives ).reduce(
( acc, [ name, values ] ) => {
const priority = directivePriorities[ name ];
if ( ! acc[ priority ] ) acc[ priority ] = {};
acc[ priority ][ name ] = values;
return acc;
},
{}
);
return Object.entries( byPriority )
.sort( ( [ p1 ], [ p2 ] ) => p1 - p2 )
.map( ( [ , obj ] ) => obj );
}, [ directives ] );
// Directive wrapper.
const Directive = ( { type, directives, props: originalProps } ) => {
const ref = useRef( null );
const element = h( type, { ...originalProps, ref } );
const evaluate = useMemo( () => getEvaluate( { ref } ), [] );
// Add wrappers recursively for each priority level.
const byPriorityLevel = usePriorityLevels( directives );
return (
<RecursivePriorityLevel
directives={ byPriorityLevel }
element={ element }
evaluate={ evaluate }
originalProps={ originalProps }
/>
);
};
// Priority level wrapper.
const RecursivePriorityLevel = ( {
directives: [ directives, ...rest ],
element,
evaluate,
originalProps,
} ) => {
// This element needs to be a fresh copy so we are not modifying an already
// rendered element with Preact's internal properties initialized. This
// prevents an error with changes in `element.props.children` not being
// reflected in `element.__k`.
element = cloneElement( element );
// Recursively render the wrapper for the next priority level.
//
// Note that, even though we're instantiating a vnode with a
// `RecursivePriorityLevel` here, its render function will not be executed
// just yet. Actually, it will be delayed until the current render function
// has finished. That ensures directives in the current priorty level have
// run (and thus modified the passed `element`) before the next level.
const children =
rest.length > 0 ? (
<RecursivePriorityLevel
directives={ rest }
element={ element }
evaluate={ evaluate }
originalProps={ originalProps }
/>
) : (
element
);
const props = { ...originalProps, children };
const directiveArgs = { directives, props, element, context, evaluate };
for ( const d in directives ) {
const wrapper = directiveMap[ d ]?.( directiveArgs );
if ( wrapper !== undefined ) props.children = wrapper;
}
return props.children;
};
// Preact Options Hook called each time a vnode is created.
const old = options.vnode;
options.vnode = ( vnode ) => {
if ( vnode.props.__directives ) {
const props = vnode.props;
const directives = props.__directives;
if ( directives.key ) vnode.props.key = directives.key.default;
delete props.__directives;
vnode.props = {
type: vnode.type,
directives,
props,
};
vnode.type = Directive;
}
if ( old ) old( vnode );
};

View File

@ -0,0 +1,301 @@
// @ts-nocheck
/**
* External dependencies
*/
import { h, options, createContext, cloneElement } from 'preact';
import { useRef, useCallback, useContext } from 'preact/hooks';
import { deepSignal } from 'deepsignal';
/**
* Internal dependencies
*/
import { stores } from './store';
/** @typedef {import('preact').VNode} VNode */
/** @typedef {typeof context} Context */
/** @typedef {ReturnType<typeof getEvaluate>} Evaluate */
/**
* @typedef {Object} DirectiveCallbackParams Callback parameters.
* @property {Object} directives Object map with the defined directives of the element being evaluated.
* @property {Object} props Props present in the current element.
* @property {VNode} element Virtual node representing the original element.
* @property {Context} context The inherited context.
* @property {Evaluate} evaluate Function that resolves a given path to a value either in the store or the context.
*/
/**
* @callback DirectiveCallback Callback that runs the directive logic.
* @param {DirectiveCallbackParams} params Callback parameters.
*/
/**
* @typedef DirectiveOptions Options object.
* @property {number} [priority=10] Value that specifies the priority to
* evaluate directives of this type. Lower
* numbers correspond with earlier execution.
* Default is `10`.
*/
// Main context.
const context = createContext( {} );
// Wrap the element props to prevent modifications.
const immutableMap = new WeakMap();
const immutableError = () => {
throw new Error(
'Please use `data-wp-bind` to modify the attributes of an element.'
);
};
const immutableHandlers = {
get( target, key, receiver ) {
const value = Reflect.get( target, key, receiver );
return !! value && typeof value === 'object'
? deepImmutable( value )
: value;
},
set: immutableError,
deleteProperty: immutableError,
};
const deepImmutable = < T extends Object = {} >( target: T ): T => {
if ( ! immutableMap.has( target ) )
immutableMap.set( target, new Proxy( target, immutableHandlers ) );
return immutableMap.get( target );
};
// Store stacks for the current scope and the default namespaces and export APIs
// to interact with them.
let scopeStack: any[] = [];
let namespaceStack: string[] = [];
export const getContext = < T extends object >( namespace?: string ): T =>
getScope()?.context[ namespace || namespaceStack.slice( -1 ) ];
export const getElement = () => {
if ( ! getScope() ) {
throw Error(
'Cannot call `getElement()` outside getters and actions used by directives.'
);
}
const { ref, state, props } = getScope();
return Object.freeze( {
ref: ref.current,
state: state,
props: deepImmutable( props ),
} );
};
export const getScope = () => scopeStack.slice( -1 )[ 0 ];
export const setScope = ( scope ) => {
scopeStack.push( scope );
};
export const resetScope = () => {
scopeStack.pop();
};
export const setNamespace = ( namespace: string ) => {
namespaceStack.push( namespace );
};
export const resetNamespace = () => {
namespaceStack.pop();
};
// WordPress Directives.
const directiveCallbacks = {};
const directivePriorities = {};
/**
* Register a new directive type in the Interactivity API runtime.
*
* @example
* ```js
* directive(
* 'alert', // Name without the `data-wp-` prefix.
* ( { directives: { alert }, element, evaluate }) => {
* element.props.onclick = () => {
* alert( evaluate( alert.default ) );
* }
* }
* )
* ```
*
* The previous code registers a custom directive type for displaying an alert
* message whenever an element using it is clicked. The message text is obtained
* from the store using `evaluate`.
*
* When the HTML is processed by the Interactivity API, any element containing
* the `data-wp-alert` directive will have the `onclick` event handler, e.g.,
*
* ```html
* <button data-wp-alert="state.messages.alert">Click me!</button>
* ```
* Note that, in the previous example, you access `alert.default` in order to
* retrieve the `state.messages.alert` value passed to the directive. You can
* also define custom names by appending `--` to the directive attribute,
* followed by a suffix, like in the following HTML snippet:
*
* ```html
* <button
* data-wp-color--text="state.theme.text"
* data-wp-color--background="state.theme.background"
* >Click me!</button>
* ```
*
* This could be an hypothetical implementation of the custom directive used in
* the snippet above.
*
* @example
* ```js
* directive(
* 'color', // Name without prefix and suffix.
* ( { directives: { color }, ref, evaluate }) => {
* if ( color.text ) {
* ref.style.setProperty(
* 'color',
* evaluate( color.text )
* );
* }
* if ( color.background ) {
* ref.style.setProperty(
* 'background-color',
* evaluate( color.background )
* );
* }
* }
* )
* ```
*
* @param {string} name Directive name, without the `data-wp-` prefix.
* @param {DirectiveCallback} callback Function that runs the directive logic.
* @param {DirectiveOptions=} options Options object.
*/
export const directive = ( name, callback, { priority = 10 } = {} ) => {
directiveCallbacks[ name ] = callback;
directivePriorities[ name ] = priority;
};
// Resolve the path to some property of the store object.
const resolve = ( path, namespace ) => {
let current = {
...stores.get( namespace ),
context: getScope().context[ namespace ],
};
path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) );
return current;
};
// Generate the evaluate function.
const getEvaluate =
( { scope } = {} ) =>
( entry, ...args ) => {
let { value: path, namespace } = entry;
// If path starts with !, remove it and save a flag.
const hasNegationOperator =
path[ 0 ] === '!' && !! ( path = path.slice( 1 ) );
setScope( scope );
const value = resolve( path, namespace );
const result = typeof value === 'function' ? value( ...args ) : value;
resetScope();
return hasNegationOperator ? ! result : result;
};
// Separate directives by priority. The resulting array contains objects
// of directives grouped by same priority, and sorted in ascending order.
const getPriorityLevels = ( directives ) => {
const byPriority = Object.keys( directives ).reduce( ( obj, name ) => {
if ( directiveCallbacks[ name ] ) {
const priority = directivePriorities[ name ];
( obj[ priority ] = obj[ priority ] || [] ).push( name );
}
return obj;
}, {} );
return Object.entries( byPriority )
.sort( ( [ p1 ], [ p2 ] ) => p1 - p2 )
.map( ( [ , arr ] ) => arr );
};
// Component that wraps each priority level of directives of an element.
const Directives = ( {
directives,
priorityLevels: [ currentPriorityLevel, ...nextPriorityLevels ],
element,
originalProps,
previousScope = {},
} ) => {
// Initialize the scope of this element. These scopes are different per each
// level because each level has a different context, but they share the same
// element ref, state and props.
const scope = useRef( {} ).current;
scope.evaluate = useCallback( getEvaluate( { scope } ), [] );
scope.context = useContext( context );
scope.ref = previousScope.ref || useRef( null );
scope.state = previousScope.state || useRef( deepSignal( {} ) ).current;
// Create a fresh copy of the vnode element and add the props to the scope.
element = cloneElement( element, { ref: scope.ref } );
scope.props = element.props;
// Recursively render the wrapper for the next priority level.
const children =
nextPriorityLevels.length > 0 ? (
<Directives
directives={ directives }
priorityLevels={ nextPriorityLevels }
element={ element }
originalProps={ originalProps }
previousScope={ scope }
/>
) : (
element
);
const props = { ...originalProps, children };
const directiveArgs = {
directives,
props,
element,
context,
evaluate: scope.evaluate,
};
setScope( scope );
for ( const directiveName of currentPriorityLevel ) {
const wrapper = directiveCallbacks[ directiveName ]?.( directiveArgs );
if ( wrapper !== undefined ) props.children = wrapper;
}
resetScope();
return props.children;
};
// Preact Options Hook called each time a vnode is created.
const old = options.vnode;
options.vnode = ( vnode ) => {
if ( vnode.props.__directives ) {
const props = vnode.props;
const directives = props.__directives;
if ( directives.key )
vnode.key = directives.key.find(
( { suffix } ) => suffix === 'default'
).value;
delete props.__directives;
const priorityLevels = getPriorityLevels( directives );
if ( priorityLevels.length > 0 ) {
vnode.props = {
directives,
priorityLevels,
originalProps: props,
type: vnode.type,
element: h( vnode.type, props ),
top: true,
};
vnode.type = Directives;
}
}
if ( old ) old( vnode );
};

View File

@ -1,15 +1,19 @@
import registerDirectives from './directives';
import { init } from './router';
import { rawStore, afterLoads } from './store';
export { navigate } from './router';
export { store } from './store';
// @ts-nocheck
/**
* Initialize the Interactivity API.
* Internal dependencies
*/
import registerDirectives from './directives';
import { init } from './router';
export { store } from './store';
export { directive, getContext, getElement } from './hooks';
export { navigate, prefetch } from './router';
export { h as createElement } from 'preact';
export { useEffect, useContext, useMemo } from 'preact/hooks';
export { deepSignal } from 'deepsignal';
document.addEventListener( 'DOMContentLoaded', async () => {
registerDirectives();
await init();
afterLoads.forEach( ( afterLoad ) => afterLoad( rawStore ) );
} );

View File

@ -0,0 +1,100 @@
// @ts-nocheck
/**
* External dependencies
*/
import { createElement, render } from 'preact';
/**
* @param {import('../../src/index').RenderableProps<{ context: any }>} props
*/
function ContextProvider( props ) {
this.getChildContext = () => props.context;
return props.children;
}
/**
* Portal component
*
* @this {import('./internal').Component}
* @param {object | null | undefined} props
*
* TODO: use createRoot() instead of fake root
*/
function Portal( props ) {
const _this = this;
const container = props._container;
_this.componentWillUnmount = function () {
render( null, _this._temp );
_this._temp = null;
_this._container = null;
};
// When we change container we should clear our old container and
// indicate a new mount.
if ( _this._container && _this._container !== container ) {
_this.componentWillUnmount();
}
// When props.vnode is undefined/false/null we are dealing with some kind of
// conditional vnode. This should not trigger a render.
if ( props._vnode ) {
if ( ! _this._temp ) {
_this._container = container;
// Create a fake DOM parent node that manages a subset of `container`'s children:
_this._temp = {
nodeType: 1,
parentNode: container,
childNodes: [],
appendChild( child ) {
this.childNodes.push( child );
_this._container.appendChild( child );
},
insertBefore( child ) {
this.childNodes.push( child );
_this._container.appendChild( child );
},
removeChild( child ) {
this.childNodes.splice(
// eslint-disable-next-line no-bitwise
this.childNodes.indexOf( child ) >>> 1,
1
);
_this._container.removeChild( child );
},
};
}
// Render our wrapping element into temp.
render(
createElement(
ContextProvider,
{ context: _this.context },
props._vnode
),
_this._temp
);
}
// When we come from a conditional render, on a mounted
// portal we should clear the DOM.
else if ( _this._temp ) {
_this.componentWillUnmount();
}
}
/**
* Create a `Portal` to continue rendering the vnode tree at a different DOM node
*
* @param {import('./internal').VNode} vnode The vnode to render
* @param {import('./internal').PreactElement} container The DOM node to continue rendering in to.
*/
export function createPortal( vnode, container ) {
const el = createElement( Portal, {
_vnode: vnode,
_container: container,
} );
el.containerInfo = container;
return el;
}

View File

@ -1,4 +1,12 @@
// @ts-nocheck
/**
* External dependencies
*/
import { hydrate, render } from 'preact';
/**
* Internal dependencies
*/
import { toVdom, hydratedIslands } from './vdom';
import { createRootFragment } from './utils';
import { directivePrefix } from './constants';
@ -26,18 +34,18 @@ const cleanUrl = ( url ) => {
};
// Fetch a new page and convert it to a static virtual DOM.
const fetchPage = async ( url ) => {
let dom;
const fetchPage = async ( url, { html } ) => {
try {
const res = await window.fetch( url );
if ( res.status !== 200 ) return false;
const html = await res.text();
dom = new window.DOMParser().parseFromString( html, 'text/html' );
if ( ! html ) {
const res = await window.fetch( url );
if ( res.status !== 200 ) return false;
html = await res.text();
}
const dom = new window.DOMParser().parseFromString( html, 'text/html' );
return regionsToVdom( dom );
} catch ( e ) {
return false;
}
return regionsToVdom( dom );
};
// Return an object with VDOM trees of those HTML regions marked with a
@ -55,10 +63,10 @@ const regionsToVdom = ( dom ) => {
// Prefetch a page. We store the promise to avoid triggering a second fetch for
// a page if a fetching has already started.
export const prefetch = ( url ) => {
export const prefetch = ( url, options = {} ) => {
url = cleanUrl( url );
if ( ! pages.has( url ) ) {
pages.set( url, fetchPage( url ) );
if ( options.force || ! pages.has( url ) ) {
pages.set( url, fetchPage( url, options ) );
}
};
@ -72,20 +80,38 @@ const renderRegions = ( page ) => {
} );
};
// Variable to store the current navigation.
let navigatingTo = '';
// Navigate to a new page.
export const navigate = async ( href, { replace = false } = {} ) => {
export const navigate = async ( href, options = {} ) => {
const url = cleanUrl( href );
prefetch( url );
const page = await pages.get( url );
navigatingTo = href;
prefetch( url, options );
// Create a promise that resolves when the specified timeout ends. The
// timeout value is 10 seconds by default.
const timeoutPromise = new Promise( ( resolve ) =>
setTimeout( resolve, options.timeout ?? 10000 )
);
const page = await Promise.race( [ pages.get( url ), timeoutPromise ] );
// Once the page is fetched, the destination URL could have changed (e.g.,
// by clicking another link in the meantime). If so, bail out, and let the
// newer execution to update the HTML.
if ( navigatingTo !== href ) return;
if ( page ) {
renderRegions( page );
window.history[ replace ? 'replaceState' : 'pushState' ](
window.history[ options.replace ? 'replaceState' : 'pushState' ](
{},
'',
href
);
} else {
window.location.assign( href );
await new Promise( () => {} );
}
};

View File

@ -0,0 +1,40 @@
// @ts-nocheck
/**
* External dependencies
*/
import { createContext } from 'preact';
import { useContext, useEffect } from 'preact/hooks';
import { signal } from '@preact/signals';
const slotsContext = createContext();
export const Fill = ( { slot, children } ) => {
const slots = useContext( slotsContext );
useEffect( () => {
if ( slot ) {
slots.value = { ...slots.value, [ slot ]: children };
return () => {
slots.value = { ...slots.value, [ slot ]: null };
};
}
}, [ slots, slot, children ] );
return !! slot ? null : children;
};
export const SlotProvider = ( { children } ) => {
return (
// TODO: We can change this to use deepsignal once this PR is merged.
// https://github.com/luisherranz/deepsignal/pull/38
<slotsContext.Provider value={ signal( {} ) }>
{ children }
</slotsContext.Provider>
);
};
export const Slot = ( { name, children } ) => {
const slots = useContext( slotsContext );
return slots.value[ name ] || children;
};

View File

@ -1,46 +0,0 @@
import { deepSignal } from 'deepsignal';
const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
export const deepMerge = ( target, source ) => {
if ( isObject( target ) && isObject( source ) ) {
for ( const key in source ) {
if ( isObject( source[ key ] ) ) {
if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } );
deepMerge( target[ key ], source[ key ] );
} else {
Object.assign( target, { [ key ]: source[ key ] } );
}
}
}
};
const getSerializedState = () => {
const storeTag = document.querySelector(
`script[type="application/json"]#wc-interactivity-store-data`
);
if ( ! storeTag ) return {};
try {
const { state } = JSON.parse( storeTag.textContent );
if ( isObject( state ) ) return state;
throw Error( 'Parsed state is not an object' );
} catch ( e ) {
// eslint-disable-next-line no-console
console.log( e );
}
return {};
};
export const afterLoads = new Set();
const rawState = getSerializedState();
export const rawStore = { state: deepSignal( rawState ) };
if ( typeof window !== 'undefined' ) window.store = rawStore;
export const store = ( { state, ...block }, { afterLoad } = {} ) => {
deepMerge( rawStore, block );
deepMerge( rawState, state );
if ( afterLoad ) afterLoads.add( afterLoad );
};

View File

@ -0,0 +1,285 @@
/**
* External dependencies
*/
import { deepSignal } from 'deepsignal';
import { computed } from '@preact/signals';
import {
getScope,
setScope,
resetScope,
setNamespace,
resetNamespace,
} from './hooks';
const isObject = ( item: unknown ): boolean =>
!! item && typeof item === 'object' && ! Array.isArray( item );
const deepMerge = ( target: any, source: any ) => {
if ( isObject( target ) && isObject( source ) ) {
for ( const key in source ) {
const getter = Object.getOwnPropertyDescriptor( source, key )?.get;
if ( typeof getter === 'function' ) {
Object.defineProperty( target, key, { get: getter } );
} else if ( isObject( source[ key ] ) ) {
if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } );
deepMerge( target[ key ], source[ key ] );
} else {
Object.assign( target, { [ key ]: source[ key ] } );
}
}
}
};
const parseInitialState = () => {
const storeTag = document.querySelector(
`script[type="application/json"]#wc-interactivity-initial-state`
);
if ( ! storeTag?.textContent ) return {};
try {
const initialState = JSON.parse( storeTag.textContent );
if ( isObject( initialState ) ) return initialState;
throw Error( 'Parsed state is not an object' );
} catch ( e ) {
// eslint-disable-next-line no-console
console.log( e );
}
return {};
};
export const stores = new Map();
const rawStores = new Map();
const storeLocks = new Map();
const objToProxy = new WeakMap();
const proxyToNs = new WeakMap();
const scopeToGetters = new WeakMap();
const proxify = ( obj: any, ns: string ) => {
if ( ! objToProxy.has( obj ) ) {
const proxy = new Proxy( obj, handlers );
objToProxy.set( obj, proxy );
proxyToNs.set( proxy, ns );
}
return objToProxy.get( obj );
};
const handlers = {
get: ( target: any, key: string | symbol, receiver: any ) => {
const ns = proxyToNs.get( receiver );
// Check if the property is a getter and we are inside an scope. If that is
// the case, we clone the getter to avoid overwriting the scoped
// dependencies of the computed each time that getter runs.
const getter = Object.getOwnPropertyDescriptor( target, key )?.get;
if ( getter ) {
const scope = getScope();
if ( scope ) {
const getters =
scopeToGetters.get( scope ) ||
scopeToGetters.set( scope, new Map() ).get( scope );
if ( ! getters.has( getter ) ) {
getters.set(
getter,
computed( () => {
setNamespace( ns );
setScope( scope );
try {
return getter.call( target );
} finally {
resetScope();
resetNamespace();
}
} )
);
}
return getters.get( getter ).value;
}
}
const result = Reflect.get( target, key, receiver );
// Check if the proxy is the store root and no key with that name exist. In
// that case, return an empty object for the requested key.
if ( typeof result === 'undefined' && receiver === stores.get( ns ) ) {
const obj = {};
Reflect.set( target, key, obj, receiver );
return proxify( obj, ns );
}
// Check if the property is a generator. If it is, we turn it into an
// asynchronous function where we restore the default namespace and scope
// each time it awaits/yields.
if ( result?.constructor?.name === 'GeneratorFunction' ) {
return async ( ...args: unknown[] ) => {
const scope = getScope();
const gen: Generator< any > = result( ...args );
let value: any;
let it: IteratorResult< any >;
while ( true ) {
setNamespace( ns );
setScope( scope );
try {
it = gen.next( value );
} finally {
resetScope();
resetNamespace();
}
try {
value = await it.value;
} catch ( e ) {
gen.throw( e );
}
if ( it.done ) break;
}
return value;
};
}
// Check if the property is a synchronous function. If it is, set the
// default namespace. Synchronous functions always run in the proper scope,
// which is set by the Directives component.
if ( typeof result === 'function' ) {
return ( ...args: unknown[] ) => {
setNamespace( ns );
try {
return result( ...args );
} finally {
resetNamespace();
}
};
}
// Check if the property is an object. If it is, proxyify it.
if ( isObject( result ) ) return proxify( result, ns );
return result;
},
};
/**
* @typedef StoreProps Properties object passed to `store`.
* @property {Object} state State to be added to the global store. All the
* properties included here become reactive.
*/
/**
* @typedef StoreOptions Options object.
*/
/**
* Extends the Interactivity API global store with the passed properties.
*
* These props typically consist of `state`, which is reactive, and other
* properties like `selectors`, `actions`, `effects`, etc. which can store
* callbacks and derived state. These props can then be referenced by any
* directive to make the HTML interactive.
*
* @example
* ```js
* store({
* state: {
* counter: { value: 0 },
* },
* actions: {
* counter: {
* increment: ({ state }) => {
* state.counter.value += 1;
* },
* },
* },
* });
* ```
*
* The code from the example above allows blocks to subscribe and interact with
* the store by using directives in the HTML, e.g.:
*
* ```html
* <div data-wp-interactive>
* <button
* data-wp-text="state.counter.value"
* data-wp-on--click="actions.counter.increment"
* >
* 0
* </button>
* </div>
* ```
*
* @param {StoreProps} properties Properties to be added to the global store.
* @param {StoreOptions} [options] Options passed to the `store` call.
*/
interface StoreOptions {
lock?: boolean | string;
}
const universalUnlock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
export function store< S extends object = {} >(
namespace: string,
storePart?: S,
options?: StoreOptions
): S;
export function store< T extends object >(
namespace: string,
storePart?: T,
options?: StoreOptions
): T;
export function store(
namespace: string,
{ state = {}, ...block }: any = {},
{ lock = false }: StoreOptions = {}
) {
if ( ! stores.has( namespace ) ) {
// Lock the store if the passed lock is different from the universal
// unlock. Once the lock is set (either false, true, or a given string),
// it cannot change.
if ( lock !== universalUnlock ) {
storeLocks.set( namespace, lock );
}
const rawStore = { state: deepSignal( state ), ...block };
const proxiedStore = new Proxy( rawStore, handlers );
rawStores.set( namespace, rawStore );
stores.set( namespace, proxiedStore );
proxyToNs.set( proxiedStore, namespace );
} else {
// Lock the store if it wasn't locked yet and the passed lock is
// different from the universal unlock. If no lock is given, the store
// will be public and won't accept any lock from now on.
if ( lock !== universalUnlock && ! storeLocks.has( namespace ) ) {
storeLocks.set( namespace, lock );
} else {
const storeLock = storeLocks.get( namespace );
const isLockValid =
lock === universalUnlock ||
( lock !== true && lock === storeLock );
if ( ! isLockValid ) {
if ( ! storeLock ) {
throw Error( 'Cannot lock a public store' );
} else {
throw Error(
'Cannot unlock a private store with an invalid lock code'
);
}
}
}
const target = rawStores.get( namespace );
deepMerge( target, block );
deepMerge( target.state, state );
}
return stores.get( namespace );
}
// Parse and populate the initial state.
Object.entries( parseInitialState() ).forEach( ( [ namespace, state ] ) => {
store( namespace, { state } );
} );

View File

@ -1,13 +1,25 @@
import { useRef, useEffect } from 'preact/hooks';
// @ts-nocheck
/**
* External dependencies
*/
import { useEffect } from 'preact/hooks';
import { effect } from '@preact/signals';
function afterNextFrame( callback ) {
const done = () => {
cancelAnimationFrame( raf );
setTimeout( callback );
};
const raf = requestAnimationFrame( done );
}
const afterNextFrame = ( callback ) => {
return new Promise( ( resolve ) => {
const done = () => {
clearTimeout( timeout );
window.cancelAnimationFrame( raf );
setTimeout( () => {
callback();
resolve();
} );
};
const timeout = setTimeout( done, 100 );
const raf = window.requestAnimationFrame( done );
} );
};
// Using the mangled properties:
// this.c: this._callback
@ -25,18 +37,20 @@ function createFlusher( compute, notify ) {
}
// Version of `useSignalEffect` with a `useEffect`-like execution. This hook
// implementation comes from this PR:
// https://github.com/preactjs/signals/pull/290.
//
// We need to include it here in this repo until the mentioned PR is merged.
export function useSignalEffect( cb ) {
const callback = useRef( cb );
callback.current = cb;
// implementation comes from this PR, but we added short-cirtuiting to avoid
// infinite loops: https://github.com/preactjs/signals/pull/290
export function useSignalEffect( callback ) {
useEffect( () => {
const execute = () => callback.current();
const notify = () => afterNextFrame( eff.flush );
const eff = createFlusher( execute, notify );
let eff = null;
let isExecuting = false;
const notify = async () => {
if ( eff && ! isExecuting ) {
isExecuting = true;
await afterNextFrame( eff.flush );
isExecuting = false;
}
};
eff = createFlusher( callback, notify );
return eff.dispose;
}, [] );
}

View File

@ -1,9 +1,18 @@
// @ts-nocheck
/**
* External dependencies
*/
import { h } from 'preact';
/**
* Internal dependencies
*/
import { directivePrefix as p } from './constants';
const ignoreAttr = `data-${ p }-ignore`;
const islandAttr = `data-${ p }-interactive`;
const fullPrefix = `data-${ p }-`;
let namespace = null;
// Regular expression for directive parsing.
const directiveParser = new RegExp(
@ -19,6 +28,12 @@ const directiveParser = new RegExp(
'i' // Case insensitive.
);
// Regular expression for reference parsing. It can contain a namespace before
// the reference, separated by `::`, like `some-namespace::state.somePath`.
// Namespaces can contain any alphanumeric characters, hyphens, underscores or
// forward slashes. References don't have any restrictions.
const nsPathRegExp = /^([\w-_\/]+)::(.+)$/;
export const hydratedIslands = new WeakSet();
// Recursive function that transforms a DOM tree into vDOM.
@ -34,7 +49,7 @@ export function toVdom( root ) {
if ( nodeType === 3 ) return [ node.data ];
if ( nodeType === 4 ) {
const next = treeWalker.nextSibling();
node.replaceWith( new Text( node.nodeValue ) );
node.replaceWith( new window.Text( node.nodeValue ) );
return [ node.nodeValue, next ];
}
if ( nodeType === 8 || nodeType === 7 ) {
@ -45,8 +60,7 @@ export function toVdom( root ) {
const props = {};
const children = [];
const directives = {};
let hasDirectives = false;
const directives = [];
let ignore = false;
let island = false;
@ -58,17 +72,19 @@ export function toVdom( root ) {
) {
if ( n === ignoreAttr ) {
ignore = true;
} else if ( n === islandAttr ) {
island = true;
} else {
hasDirectives = true;
let val = attributes[ i ].value;
let [ ns, value ] = nsPathRegExp
.exec( attributes[ i ].value )
?.slice( 1 ) ?? [ null, attributes[ i ].value ];
try {
val = JSON.parse( val );
value = JSON.parse( value );
} catch ( e ) {}
const [ , prefix, suffix ] = directiveParser.exec( n );
directives[ prefix ] = directives[ prefix ] || {};
directives[ prefix ][ suffix || 'default' ] = val;
if ( n === islandAttr ) {
island = true;
namespace = value?.namespace ?? null;
} else {
directives.push( [ n, ns, value ] );
}
}
} else if ( n === 'ref' ) {
continue;
@ -86,7 +102,22 @@ export function toVdom( root ) {
];
if ( island ) hydratedIslands.add( node );
if ( hasDirectives ) props.__directives = directives;
if ( directives.length ) {
props.__directives = directives.reduce(
( obj, [ name, ns, value ] ) => {
const [ , prefix, suffix = 'default' ] =
directiveParser.exec( name );
if ( ! obj[ prefix ] ) obj[ prefix ] = [];
obj[ prefix ].push( {
namespace: ns ?? namespace,
value,
suffix,
} );
return obj;
},
{}
);
}
let child = treeWalker.firstChild();
if ( child ) {

View File

@ -11,3 +11,4 @@ export * from './is-site-editor-page';
export * from './is-widget-editor-page';
export * from './trim-words';
export * from './find-block';
export * from './interactivity';

View File

@ -0,0 +1,7 @@
// Util to add the type of another store part.
// eslint-disable-next-line @typescript-eslint/ban-types
export type StorePart< T > = T extends Function
? T
: T extends object
? { [ P in keyof T ]?: StorePart< T[ P ] > }
: T;

View File

@ -35,9 +35,12 @@ const isProduction = NODE_ENV === 'production';
* Shared config for all script builds.
*/
let initialBundleAnalyzerPort = 8888;
const getSharedPlugins = ( { bundleAnalyzerReportTitle } ) =>
const getSharedPlugins = ( {
bundleAnalyzerReportTitle,
checkCircularDeps = true,
} ) =>
[
CHECK_CIRCULAR_DEPS === 'true'
CHECK_CIRCULAR_DEPS === 'true' && checkCircularDeps !== false
? new CircularDependencyPlugin( {
exclude: /node_modules/,
cwd: process.cwd(),
@ -924,6 +927,7 @@ const getInteractivityAPIConfig = ( options = {} ) => {
plugins: [
...getSharedPlugins( {
bundleAnalyzerReportTitle: 'WP directives',
checkCircularDeps: false,
} ),
new ProgressBarPlugin(
getProgressBarPluginConfig( 'WP directives' )
@ -943,6 +947,7 @@ const getInteractivityAPIConfig = ( options = {} ) => {
babelrc: false,
configFile: false,
presets: [
'@babel/preset-typescript',
[
'@babel/preset-react',
{

View File

@ -77,16 +77,13 @@ class ProductButton extends AbstractBlock {
$post_id = isset( $block->context['postId'] ) ? $block->context['postId'] : '';
$product = wc_get_product( $post_id );
wc_store(
wc_initial_state(
'woocommerce/product-button',
array(
'state' => array(
'woocommerce' => array(
'inTheCartText' => sprintf(
/* translators: %s: product number. */
__( '%s in cart', 'woo-gutenberg-products-block' ),
'###'
),
),
'inTheCartText' => sprintf(
/* translators: %s: product number. */
__( '%s in cart', 'woo-gutenberg-products-block' ),
'###'
),
)
);
@ -123,22 +120,21 @@ class ProductButton extends AbstractBlock {
);
$default_quantity = 1;
/**
* Filters the change the quantity to add to cart.
*
* @since 10.9.0
* @param number $default_quantity The default quantity.
* @param number $product_id The product id.
*/
$quantity_to_add = apply_filters( 'woocommerce_add_to_cart_quantity', $default_quantity, $product->get_id() );
$context = array(
'woocommerce' => array(
/**
* Filters the change the quantity to add to cart.
*
* @since 10.9.0
* @param number $default_quantity The default quantity.
* @param number $product_id The product id.
*/
'quantityToAdd' => apply_filters( 'woocommerce_add_to_cart_quantity', $default_quantity, $product->get_id() ),
'productId' => $product->get_id(),
'addToCartText' => null !== $product->add_to_cart_text() ? $product->add_to_cart_text() : __( 'Add to cart', 'woo-gutenberg-products-block' ),
'temporaryNumberOfItems' => $number_of_items_in_cart,
'animationStatus' => 'IDLE',
),
'quantityToAdd' => $quantity_to_add,
'productId' => $product->get_id(),
'addToCartText' => null !== $product->add_to_cart_text() ? $product->add_to_cart_text() : __( 'Add to cart', 'woo-gutenberg-products-block' ),
'temporaryNumberOfItems' => $number_of_items_in_cart,
'animationStatus' => 'IDLE',
);
/**
@ -168,20 +164,27 @@ class ProductButton extends AbstractBlock {
$this->prevent_cache();
}
$div_directives = 'data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK ) . '\'';
$interactive = array(
'namespace' => 'woocommerce/product-button',
);
$div_directives = '
data-wc-interactive=\'' . wp_json_encode( $interactive, JSON_NUMERIC_CHECK ) . '\'
data-wc-context=\'' . wp_json_encode( $context, JSON_NUMERIC_CHECK ) . '\'
';
$button_directives = '
data-wc-on--click="actions.woocommerce.addToCart"
data-wc-class--loading="context.woocommerce.isLoading"
data-wc-on--click="actions.addToCart"
data-wc-class--loading="context.isLoading"
';
$span_button_directives = '
data-wc-text="selectors.woocommerce.addToCartText"
data-wc-class--wc-block-slide-in="selectors.woocommerce.slideInAnimation"
data-wc-class--wc-block-slide-out="selectors.woocommerce.slideOutAnimation"
data-wc-layout-init="init.woocommerce.syncTemporaryNumberOfItemsOnLoad"
data-wc-effect="effects.woocommerce.startAnimation"
data-wc-on--animationend="actions.woocommerce.handleAnimationEnd"
data-wc-text="state.addToCartText"
data-wc-class--wc-block-slide-in="state.slideInAnimation"
data-wc-class--wc-block-slide-out="state.slideOutAnimation"
data-wc-on--animationend="actions.handleAnimationEnd"
data-wc-watch="callbacks.startAnimation"
data-wc-layout-init="callbacks.syncTemporaryNumberOfItemsOnLoad"
';
/**
@ -194,20 +197,20 @@ class ProductButton extends AbstractBlock {
return apply_filters(
'woocommerce_loop_add_to_cart_link',
strtr(
'<div data-wc-interactive class="wp-block-button wc-block-components-product-button {classes} {custom_classes}"
'<div class="wp-block-button wc-block-components-product-button {classes} {custom_classes}"
{div_directives}
>
<{html_element}
href="{add_to_cart_url}"
class="{button_classes}"
style="{button_styles}"
{attributes}
{button_directives}
>
<span {span_button_directives}> {add_to_cart_text} </span>
</{html_element}>
{view_cart_html}
</div>',
<{html_element}
href="{add_to_cart_url}"
class="{button_classes}"
style="{button_styles}"
{attributes}
{button_directives}
>
<span {span_button_directives}> {add_to_cart_text} </span>
</{html_element}>
{view_cart_html}
</div>',
array(
'{classes}' => esc_attr( $text_align_styles_and_classes['class'] ?? '' ),
'{custom_classes}' => esc_attr( $classname . ' ' . $custom_width_classes . ' ' . $custom_align_classes ),
@ -259,7 +262,10 @@ class ProductButton extends AbstractBlock {
*/
private function get_view_cart_html() {
return sprintf(
'<span hidden data-wc-bind--hidden="!selectors.woocommerce.displayViewCart">
'<span
hidden
data-wc-bind--hidden="!state.displayViewCart"
>
<a
href="%1$s"
class="added_to_cart wc_forward"

View File

@ -119,7 +119,7 @@ class ProductCollection extends AbstractBlock {
'data-wc-navigation-id',
'wc-product-collection-' . $this->parsed_block['attrs']['queryId']
);
$p->set_attribute( 'data-wc-interactive', true );
$p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-collection' ) ) );
$block_content = $p->get_updated_html();
}
}

View File

@ -77,11 +77,11 @@ class ProductGallery extends AbstractBlock {
$gallery_dialog = strtr(
'
<div class="wc-block-product-gallery-dialog__overlay" hidden data-wc-bind--hidden="!selectors.woocommerce.isDialogOpen" data-wc-effect="effects.woocommerce.keyboardAccess">
<dialog data-wc-bind--open="selectors.woocommerce.isDialogOpen">
<div class="wc-block-product-gallery-dialog__overlay" hidden data-wc-bind--hidden="!context.isDialogOpen" data-wc-watch="callbacks.keyboardAccess">
<dialog data-wc-bind--open="context.isDialogOpen">
<div class="wc-block-product-gallery-dialog__header">
<div class="wc-block-product-galler-dialog__header-right">
<button class="wc-block-product-gallery-dialog__close" data-wc-on--click="actions.woocommerce.dialog.handleCloseButtonClick">
<button class="wc-block-product-gallery-dialog__close" data-wc-on--click="actions.closeDialog">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="2"/>
<path d="M13 11.8L19.1 5.5L18.1 4.5L12 10.7L5.9 4.5L4.9 5.5L11 11.8L4.5 18.5L5.5 19.5L12 12.9L18.5 19.5L19.5 18.5L13 11.8Z" fill="black"/>
@ -132,26 +132,24 @@ class ProductGallery extends AbstractBlock {
$p = new \WP_HTML_Tag_Processor( $html );
if ( $p->next_tag() ) {
$p->set_attribute( 'data-wc-interactive', true );
$p->set_attribute( 'data-wc-interactive', wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ) ) );
$p->set_attribute(
'data-wc-context',
wp_json_encode(
array(
'woocommerce' => array(
'selectedImage' => $product->get_image_id(),
'firstMainImageId' => $product->get_image_id(),
'visibleImagesIds' => ProductGalleryUtils::get_product_gallery_image_ids( $product, $number_of_thumbnails, true ),
'dialogVisibleImagesIds' => ProductGalleryUtils::get_product_gallery_image_ids( $product, null, false ),
'mouseIsOverPreviousOrNextButton' => false,
'isDialogOpen' => false,
'productId' => $product_id,
),
'selectedImage' => $product->get_image_id(),
'firstMainImageId' => $product->get_image_id(),
'isDialogOpen' => false,
'visibleImagesIds' => ProductGalleryUtils::get_product_gallery_image_ids( $product, $number_of_thumbnails, true ),
'dialogVisibleImagesIds' => ProductGalleryUtils::get_product_gallery_image_ids( $product, null, false ),
'mouseIsOverPreviousOrNextButton' => false,
'productId' => $product_id,
)
)
);
if ( $product->is_type( 'variable' ) ) {
$p->set_attribute( 'data-wc-init--watch-changes-on-add-to-cart-form', 'effects.woocommerce.watchForChangesOnAddToCartForm' );
$p->set_attribute( 'data-wc-init--watch-changes-on-add-to-cart-form', 'callbacks.watchForChangesOnAddToCartForm' );
}
$p->add_class( $classname );

View File

@ -117,10 +117,10 @@ class ProductGalleryLargeImage extends AbstractBlock {
*/
private function get_main_images_html( $context, $product_id ) {
$attributes = array(
'data-wc-bind--style' => 'selectors.woocommerce.productGalleryLargeImage.styles',
'data-wc-effect' => 'effects.woocommerce.scrollInto',
'class' => 'wc-block-woocommerce-product-gallery-large-image__image',
'data-wc-class--wc-block-woocommerce-product-gallery-large-image__image--active-image-slide' => 'selectors.woocommerce.isSelected',
'class' => 'wc-block-woocommerce-product-gallery-large-image__image',
'data-wc-bind--hidden' => '!state.isSelected',
'data-wc-watch' => 'callbacks.scrollInto',
'data-wc-class--wc-block-woocommerce-product-gallery-large-image__image--active-image-slide' => 'state.isSelected',
);
if ( $context['fullScreenOnClick'] ) {
@ -129,7 +129,7 @@ class ProductGalleryLargeImage extends AbstractBlock {
if ( $context['hoverZoom'] ) {
$attributes['class'] .= ' wc-block-woocommerce-product-gallery-large-image__image--hoverZoom';
$attributes['data-wc-bind--style'] = 'selectors.woocommerce.productGalleryLargeImage.styles';
$attributes['data-wc-bind--style'] = 'state.styles';
}
$main_images = ProductGalleryUtils::get_product_gallery_images(
@ -178,18 +178,17 @@ class ProductGalleryLargeImage extends AbstractBlock {
return array();
}
$context = array(
'woocommerce' => array(
'styles' => array(
'transform' => 'scale(1.0)',
'transform-origin' => '',
),
'styles' => array(
'transform' => 'scale(1.0)',
'transform-origin' => '',
),
);
return array(
'data-wc-on--mousemove' => 'actions.woocommerce.handleMouseMove',
'data-wc-on--mouseleave' => 'actions.woocommerce.handleMouseLeave',
'data-wc-interactive' => wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ) ),
'data-wc-context' => wp_json_encode( $context, JSON_NUMERIC_CHECK ),
'data-wc-on--mousemove' => 'actions.startZoom',
'data-wc-on--mouseleave' => 'actions.resetZoom',
);
}
@ -206,7 +205,7 @@ class ProductGalleryLargeImage extends AbstractBlock {
}
return array(
'data-wc-on--click' => 'actions.woocommerce.handleClick',
'data-wc-on--click' => 'actions.openDialog',
);
}
}

View File

@ -87,15 +87,7 @@ class ProductGalleryLargeImageNextPrevious extends AbstractBlock {
if ( $p->next_tag() ) {
$p->set_attribute(
'data-wc-on--click',
'actions.woocommerce.handlePreviousImageButtonClick'
);
$p->set_attribute(
'data-wc-on--mouseleave',
'actions.woocommerce.handleMouseLeavePreviousOrNextButton'
);
$p->set_attribute(
'data-wc-on--mouseenter',
'actions.woocommerce.handleMouseEnterPreviousOrNextButton'
'actions.selectPreviousImage'
);
$prev_button = $p->get_updated_html();
}
@ -106,7 +98,7 @@ class ProductGalleryLargeImageNextPrevious extends AbstractBlock {
if ( $p->next_tag() ) {
$p->set_attribute(
'data-wc-on--click',
'actions.woocommerce.handleNextImageButtonClick'
'actions.selectNextImage'
);
$next_button = $p->get_updated_html();
}
@ -115,17 +107,21 @@ class ProductGalleryLargeImageNextPrevious extends AbstractBlock {
$position_class = 'wc-block-product-gallery-large-image-next-previous--' . $this->get_class_suffix( $context );
return strtr(
'<div class="wc-block-product-gallery-large-image-next-previous wp-block-woocommerce-product-gallery-large-image-next-previous {alignment_class}">
'<div
class="wc-block-product-gallery-large-image-next-previous wp-block-woocommerce-product-gallery-large-image-next-previous {alignment_class}"
data-wc-interactive=\'{data_wc_interactive}\'
>
<div class="wc-block-product-gallery-large-image-next-previous-container {position_class}">
{prev_button}
{next_button}
</div>
</div>',
array(
'{prev_button}' => $prev_button,
'{next_button}' => $next_button,
'{alignment_class}' => $alignment_class,
'{position_class}' => $position_class,
'{prev_button}' => $prev_button,
'{next_button}' => $next_button,
'{alignment_class}' => $alignment_class,
'{position_class}' => $position_class,
'{data_wc_interactive}' => wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ), JSON_NUMERIC_CHECK ),
)
);
}

View File

@ -69,11 +69,12 @@ class ProductGalleryPager extends AbstractBlock {
$html = $this->render_pager( $product_gallery_images_ids, $pager_display_mode, $number_of_thumbnails );
return sprintf(
'<div %1$s>
'<div %1$s data-wc-interactive=\'%3$s\'>
%2$s
</div>',
$wrapper_attributes,
$html
$html,
wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ) )
);
}
return '';
@ -124,18 +125,16 @@ class ProductGalleryPager extends AbstractBlock {
$p->set_attribute(
'data-wc-context',
wp_json_encode(
array(
'woocommerce' => array( 'imageId' => $product_gallery_image_id ),
)
array( 'imageId' => strval( $product_gallery_image_id ) ),
)
);
$p->set_attribute(
'data-wc-on--click',
'actions.woocommerce.handleSelectImage'
'actions.selectImage'
);
$p->set_attribute(
'data-wc-class--wc-block-product-gallery-pager__pager-item--is-active',
'selectors.woocommerce.isSelected'
'state.isSelected'
);
$html .= $p->get_updated_html();
}
@ -159,7 +158,7 @@ class ProductGalleryPager extends AbstractBlock {
$initial_opacity = $is_active ? '1' : '0.2';
return sprintf(
'<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="6" cy="6" r="6" fill="black" fill-opacity="%1$s" data-wc-bind--fill-opacity="selectors.woocommerce.pagerDotFillOpacity" />
<circle cx="6" cy="6" r="6" fill="black" fill-opacity="%1$s" data-wc-bind--fill-opacity="state.pagerDotFillOpacity" />
</svg>',
$initial_opacity
);

View File

@ -48,7 +48,7 @@ class ProductGalleryThumbnails extends AbstractBlock {
* @return string
*/
protected function generate_view_all_html( $remaining_thumbnails_count ) {
$view_all_html = '<div class="wc-block-product-gallery-thumbnails__thumbnail__overlay wc-block-product-gallery-dialog-on-click" data-wc-on--click="actions.woocommerce.handleClick">
$view_all_html = '<div class="wc-block-product-gallery-thumbnails__thumbnail__overlay wc-block-product-gallery-dialog-on-click" data-wc-on--click="actions.openDialog">
<span class="wc-block-product-gallery-thumbnails__thumbnail__remaining-thumbnails-count wc-block-product-gallery-dialog-on-click">+%1$s</span>
<span class="wc-block-product-gallery-thumbnails__thumbnail__view-all wc-block-product-gallery-dialog-on-click">%2$s</span>
</div>';
@ -157,7 +157,7 @@ class ProductGalleryThumbnails extends AbstractBlock {
if ( $processor->next_tag( 'img' ) ) {
$processor->set_attribute(
'data-wc-on--click',
'actions.woocommerce.thumbnails.handleClick'
'actions.selectImage'
);
$html .= $processor->get_updated_html();
@ -168,12 +168,13 @@ class ProductGalleryThumbnails extends AbstractBlock {
}
return sprintf(
'<div class="wc-block-product-gallery-thumbnails wp-block-woocommerce-product-gallery-thumbnails %1$s" style="%2$s">
'<div class="wc-block-product-gallery-thumbnails wp-block-woocommerce-product-gallery-thumbnails %1$s" style="%2$s" data-wc-interactive=\'%4$s\'>
%3$s
</div>',
esc_attr( $classes_and_styles['classes'] ),
esc_attr( $classes_and_styles['styles'] ),
$html
$html,
wp_json_encode( array( 'namespace' => 'woocommerce/product-gallery' ) )
);
}
}

View File

@ -0,0 +1,63 @@
<?php
/**
* Manages the initial state of the Interactivity API store in the server and
* its serialization so it can be restored in the browser upon hydration.
*
* It's a private class, exposed by other functions, like `wc_initial_state`.
*
* @access private
*/
class WC_Interactivity_Initial_State {
/**
* Map of initial state by namespace.
*
* @var array
*/
private static $initial_state = array();
/**
* Get state from a given namespace.
*
* @param string $namespace Namespace.
*
* @return array The requested state.
*/
public static function get_state( $namespace ) {
return self::$initial_state[ $namespace ] ?? array();
}
/**
* Merge data into the state with the given namespace.
*
* @param string $namespace Namespace.
* @param array $data State to merge.
*
* @return void
*/
public static function merge_state( $namespace, $data ) {
self::$initial_state[ $namespace ] = array_replace_recursive(
self::get_state( $namespace ),
$data
);
}
/**
* Reset the initial state.
*/
public static function reset() {
self::$initial_state = array();
}
/**
* Render the initial state.
*/
public static function render() {
if ( empty( self::$initial_state ) ) {
return;
}
echo sprintf(
'<script id="wc-interactivity-initial-state" type="application/json">%s</script>',
wp_json_encode( self::$initial_state, JSON_HEX_TAG | JSON_HEX_AMP )
);
}
}

View File

@ -1,56 +0,0 @@
<?php
/**
* Manages the initial state of the Interactivity API store in the server and
* its serialization so it can be restored in the browser upon hydration.
*
* It's a private class, exposed by other functions, like `wc_store`.
*
* @access private
*/
class WC_Interactivity_Store {
/**
* Store.
*
* @var array
*/
private static $store = array();
/**
* Get store data.
*
* @return array
*/
static function get_data() {
return self::$store;
}
/**
* Merge data.
*
* @param array $data The data that will be merged with the exsisting store.
*/
static function merge_data( $data ) {
self::$store = array_replace_recursive( self::$store, $data );
}
/**
* Reset the store data.
*/
static function reset() {
self::$store = array();
}
/**
* Render the store data.
*/
static function render() {
if ( empty( self::$store ) ) {
return;
}
echo sprintf(
'<script id="wc-interactivity-store-data" type="application/json">%s</script>',
wp_json_encode( self::$store )
);
}
}

View File

@ -0,0 +1,20 @@
<?php
/**
* Merge data into the state with the given namespace.
*
* @param string $namespace Namespace.
* @param array $data State to merge.
*
* @return array The current state for the given namespace.
*/
function wc_initial_state( $namespace, $data = null ) {
if ( $data ) {
WC_Interactivity_Initial_State::merge_state( $namespace, $data );
}
return WC_Interactivity_Initial_State::get_state( $namespace );
}
/**
* Render the Interactivity API initial state in the frontend.
*/
add_action( 'wp_footer', array( 'WC_Interactivity_Initial_State', 'render' ), 8 );

View File

@ -1,4 +1,4 @@
<?php
require __DIR__ . '/class-wc-interactivity-store.php';
require __DIR__ . '/store.php';
require __DIR__ . '/class-wc-interactivity-initial-state.php';
require __DIR__ . '/initial-state.php';
require __DIR__ . '/scripts.php';

View File

@ -1,19 +0,0 @@
<?php
/**
* Merge data with the exsisting store.
*
* @param array $data Data that will be merged with the exsisting store.
*
* @return $data The current store data.
*/
function wc_store( $data = null ) {
if ( $data ) {
WC_Interactivity_Store::merge_data( $data );
}
return WC_Interactivity_Store::get_data();
}
/**
* Render the Interactivity API store in the frontend.
*/
add_action( 'wp_footer', array( 'WC_Interactivity_Store', 'render' ), 8 );

View File

@ -54,9 +54,7 @@ class ProductGalleryUtils {
'data-wc-context',
wp_json_encode(
array(
'woocommerce' => array(
'imageId' => $product_gallery_image_id,
),
'imageId' => $product_gallery_image_id,
)
)
);
@ -99,6 +97,9 @@ class ProductGalleryUtils {
$unique_image_ids = array_slice( $unique_image_ids, 0, $max_number_of_visible_images );
}
// Reindex array.
$unique_image_ids = array_values( $unique_image_ids );
return $unique_image_ids;
}

View File

@ -48,7 +48,7 @@ export const getVisibleLargeImageId = async (
const mainImageParsedContext = JSON.parse( mainImageContext );
return mainImageParsedContext.woocommerce.imageId;
return mainImageParsedContext.imageId;
};
const waitForJavascriptFrontendFileIsLoaded = async ( page: Page ) => {
@ -69,7 +69,7 @@ const getThumbnailImageIdByNth = async (
'data-wc-context'
) ) as string;
const imageId = JSON.parse( imageContext ).woocommerce.imageId;
const imageId = JSON.parse( imageContext ).imageId;
return imageId;
};