UNPKG

@wordpress/interactivity

Version:

Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.

410 lines (379 loc) 11.9 kB
/** * External dependencies */ import { useMemo as _useMemo, useCallback as _useCallback, useEffect as _useEffect, useLayoutEffect as _useLayoutEffect, type EffectCallback, type Inputs, } from 'preact/hooks'; import { effect } from '@preact/signals'; /** * Internal dependencies */ import { getScope, setScope, resetScope } from './scopes'; import { getNamespace, setNamespace, resetNamespace } from './namespaces'; interface Flusher { readonly flush: () => void; readonly dispose: () => void; } declare global { interface Window { scheduler?: { readonly yield?: () => Promise< void >; }; } } interface SyncAwareFunction extends Function { sync?: boolean; } /** * Executes a callback function after the next frame is rendered. * * @param callback The callback function to be executed. * @return A promise that resolves after the callback function is executed. */ const afterNextFrame = ( callback: () => void ) => { return new Promise< void >( ( resolve ) => { const done = () => { clearTimeout( timeout ); window.cancelAnimationFrame( raf ); setTimeout( () => { callback(); resolve(); } ); }; const timeout = setTimeout( done, 100 ); const raf = window.requestAnimationFrame( done ); } ); }; /** * Returns a promise that resolves after yielding to main. * * @return Promise<void> */ export const splitTask = typeof window.scheduler?.yield === 'function' ? window.scheduler.yield.bind( window.scheduler ) : () => { return new Promise( ( resolve ) => { setTimeout( resolve, 0 ); } ); }; /** * Creates a Flusher object that can be used to flush computed values and notify listeners. * * Using the mangled properties: * this.c: this._callback * this.x: this._compute * https://github.com/preactjs/signals/blob/main/mangle.json * * @param compute The function that computes the value to be flushed. * @param notify The function that notifies listeners when the value is flushed. * @return The Flusher object with `flush` and `dispose` properties. */ function createFlusher( compute: () => void, notify: () => void ): Flusher { let flush: () => void = () => undefined; const dispose = effect( function ( this: any ): void { flush = this.c.bind( this ); this.x = compute; this.c = notify; return compute(); } ); return { flush, dispose } as const; } /** * Custom hook that executes a callback function whenever a signal is triggered. * Version of `useSignalEffect` with a `useEffect`-like execution. This hook * implementation comes from this PR, but we added short-cirtuiting to avoid * infinite loops: https://github.com/preactjs/signals/pull/290 * * @param callback The callback function to be executed. */ export function useSignalEffect( callback: () => unknown ) { _useEffect( () => { let eff: Flusher | null = 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; }, [] ); } /** * Returns the passed function wrapped with the current scope so it is * accessible whenever the function runs. This is primarily to make the scope * available inside hook callbacks. * * Asynchronous functions should use generators that yield promises instead of awaiting them. * See the documentation for details: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-interactivity/packages-interactivity-api-reference/#the-store * * @param func The passed function. * @return The wrapped function. */ export function withScope< Func extends ( ...args: any[] ) => Generator< any, any >, >( func: Func ): ( ...args: Parameters< Func > ) => ReturnType< Func > extends Generator< any, infer Return > ? Promise< Return > : never; export function withScope< Func extends Function >( func: Func ): Func; export function withScope< Func extends SyncAwareFunction >( func: Func ): Func; export function withScope( func: ( ...args: unknown[] ) => unknown ) { const scope = getScope(); const ns = getNamespace(); let wrapped: Function; if ( func?.constructor?.name === 'GeneratorFunction' ) { wrapped = async ( ...args: Parameters< typeof func > ) => { const gen = func( ...args ) as Generator; let value: any; let it: any; let error: any; while ( true ) { setNamespace( ns ); setScope( scope ); try { it = error ? gen.throw( error ) : gen.next( value ); error = undefined; } catch ( e ) { throw e; } finally { resetScope(); resetNamespace(); } try { value = await it.value; } catch ( e ) { error = e; } if ( it.done ) { if ( error ) { throw error; } else { break; } } } return value; }; } else { wrapped = ( ...args: Parameters< typeof func > ) => { setNamespace( ns ); setScope( scope ); try { return func( ...args ); } finally { resetNamespace(); resetScope(); } }; } // If function was annotated via `withSyncEvent()`, maintain the annotation. const syncAware = func as SyncAwareFunction; if ( syncAware.sync ) { const syncAwareWrapped = wrapped as SyncAwareFunction; syncAwareWrapped.sync = true; return syncAwareWrapped; } return wrapped; } /** * Accepts a function that contains imperative code which runs whenever any of * the accessed _reactive_ properties (e.g., values from the global state or the * context) is modified. * * This hook makes the element's scope available so functions like * `getElement()` and `getContext()` can be used inside the passed callback. * * @param callback The hook callback. */ export function useWatch( callback: () => unknown ) { useSignalEffect( withScope( callback ) ); } /** * Accepts a function that contains imperative code which runs only after the * element's first render, mainly useful for initialization logic. * * This hook makes the element's scope available so functions like * `getElement()` and `getContext()` can be used inside the passed callback. * * @param callback The hook callback. */ export function useInit( callback: EffectCallback ) { _useEffect( withScope( callback ), [] ); } /** * Accepts a function that contains imperative, possibly effectful code. The * effects run after browser paint, without blocking it. * * This hook is equivalent to Preact's `useEffect` and makes the element's scope * available so functions like `getElement()` and `getContext()` can be used * inside the passed callback. * * @param callback Imperative function that can return a cleanup * function. * @param inputs If present, effect will only activate if the * values in the list change (using `===`). */ export function useEffect( callback: EffectCallback, inputs: Inputs ) { _useEffect( withScope( callback ), inputs ); } /** * Accepts a function that contains imperative, possibly effectful code. Use * this to read layout from the DOM and synchronously re-render. * * This hook is equivalent to Preact's `useLayoutEffect` and makes the element's * scope available so functions like `getElement()` and `getContext()` can be * used inside the passed callback. * * @param callback Imperative function that can return a cleanup * function. * @param inputs If present, effect will only activate if the * values in the list change (using `===`). */ export function useLayoutEffect( callback: EffectCallback, inputs: Inputs ) { _useLayoutEffect( withScope( callback ), inputs ); } /** * Returns a memoized version of the callback that only changes if one of the * inputs has changed (using `===`). * * This hook is equivalent to Preact's `useCallback` and makes the element's * scope available so functions like `getElement()` and `getContext()` can be * used inside the passed callback. * * @param callback Callback function. * @param inputs If present, the callback will only be updated if the * values in the list change (using `===`). * * @return The callback function. */ export function useCallback< T extends Function >( callback: T, inputs: Inputs ): T { return _useCallback< T >( withScope( callback ), inputs ); } /** * Returns the memoized output of the passed factory function, allowing access * to the current element's scope. * * This hook is equivalent to Preact's `useMemo` and makes the element's scope * available so functions like `getElement()` and `getContext()` can be used * inside the passed factory function. Note that `useMemo` will only recompute * the memoized value when one of the inputs has changed. * * @param factory Factory function that returns that value for memoization. * @param inputs If present, the factory will only be run to recompute if the * values in the list change (using `===`). * * @return The memoized value. */ export function useMemo< T >( factory: () => T, inputs: Inputs ): T { return _useMemo( withScope( factory ), inputs ); } /** * Creates a root fragment by replacing a node or an array of nodes in a parent element. * For wrapperless hydration. * See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c * * @param parent The parent element where the nodes will be replaced. * @param replaceNode The node or array of nodes to replace in the parent element. * @return The created root fragment. */ export const createRootFragment = ( parent: Element, replaceNode: Node | Node[] ) => { replaceNode = ( [] as Node[] ).concat( replaceNode ); const sibling = replaceNode[ replaceNode.length - 1 ].nextSibling; function insert( child: any, root: any ) { parent.insertBefore( child, root || sibling ); } return ( ( parent as any ).__k = { nodeType: 1, parentNode: parent, firstChild: replaceNode[ 0 ], childNodes: replaceNode, insertBefore: insert, appendChild: insert, removeChild( c: Node ) { parent.removeChild( c ); }, } ); }; /** * Transforms a kebab-case string to camelCase. * * @param str The kebab-case string to transform to camelCase. * @return The transformed camelCase string. */ export function kebabToCamelCase( str: string ): string { return str .replace( /^-+|-+$/g, '' ) .toLowerCase() .replace( /-([a-z])/g, function ( _match, group1: string ) { return group1.toUpperCase(); } ); } const logged: Set< string > = new Set(); /** * Shows a warning with `message` if environment is not `production`. * * Based on the `@wordpress/warning` package. * * @param message Message to show in the warning. */ export const warn = ( message: string ): void => { if ( globalThis.SCRIPT_DEBUG ) { if ( logged.has( message ) ) { return; } // eslint-disable-next-line no-console console.warn( message ); // Throwing an error and catching it immediately to improve debugging // A consumer can use 'pause on caught exceptions' try { throw Error( message ); } catch ( e ) { // Do nothing. } logged.add( message ); } }; /** * Checks if the passed `candidate` is a plain object with just the `Object` * prototype. * * @param candidate The item to check. * @return Whether `candidate` is a plain object. */ export const isPlainObject = ( candidate: unknown ): candidate is Record< string, unknown > => Boolean( candidate && typeof candidate === 'object' && candidate.constructor === Object ); /** * Indicates that the passed `callback` requires synchronous access to the event object. * * @param callback The event callback. * @return Altered event callback. */ export function withSyncEvent( callback: Function ): SyncAwareFunction { const syncAware = callback as SyncAwareFunction; syncAware.sync = true; return syncAware; }