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:
parent
3a49ae4dd3
commit
9e9f0341e2
|
@ -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();
|
||||
}
|
||||
} );
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 );
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
export const csnMetaTagItemprop = 'wc-client-side-navigation';
|
||||
export const directivePrefix = 'wc';
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 );
|
||||
};
|
|
@ -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 );
|
||||
};
|
|
@ -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 ) );
|
||||
} );
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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( () => {} );
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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 );
|
||||
};
|
|
@ -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 } );
|
||||
} );
|
|
@ -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;
|
||||
}, [] );
|
||||
}
|
||||
|
|
|
@ -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 ) {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 );
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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' ) )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 )
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 )
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 );
|
|
@ -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';
|
||||
|
|
|
@ -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 );
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue