@wordpress/interactivity
Version:
Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.
352 lines (332 loc) • 10.7 kB
JavaScript
/**
* External dependencies
*/
import { useMemo as _useMemo, useCallback as _useCallback, useEffect as _useEffect, useLayoutEffect as _useLayoutEffect } from 'preact/hooks';
import { effect } from '@preact/signals';
/**
* Internal dependencies
*/
import { getScope, setScope, resetScope } from './scopes';
import { getNamespace, setNamespace, resetNamespace } from './namespaces';
/**
* 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 => {
return new Promise(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, notify) {
let flush = () => undefined;
const dispose = effect(function () {
flush = this.c.bind(this);
this.x = compute;
this.c = notify;
return compute();
});
return {
flush,
dispose
};
}
/**
* 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) {
_useEffect(() => {
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;
}, []);
}
/**
* 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) {
const scope = getScope();
const ns = getNamespace();
let wrapped;
if (func?.constructor?.name === 'GeneratorFunction') {
wrapped = async (...args) => {
const gen = func(...args);
let value;
let it;
let error;
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) => {
setNamespace(ns);
setScope(scope);
try {
return func(...args);
} finally {
resetNamespace();
resetScope();
}
};
}
// If function was annotated via `withSyncEvent()`, maintain the annotation.
const syncAware = func;
if (syncAware.sync) {
const syncAwareWrapped = wrapped;
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) {
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) {
_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, 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, 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(callback, inputs) {
return _useCallback(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(factory, inputs) {
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, replaceNode) => {
replaceNode = [].concat(replaceNode);
const sibling = replaceNode[replaceNode.length - 1].nextSibling;
function insert(child, root) {
parent.insertBefore(child, root || sibling);
}
return parent.__k = {
nodeType: 1,
parentNode: parent,
firstChild: replaceNode[0],
childNodes: replaceNode,
insertBefore: insert,
appendChild: insert,
removeChild(c) {
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) {
return str.replace(/^-+|-+$/g, '').toLowerCase().replace(/-([a-z])/g, function (_match, group1) {
return group1.toUpperCase();
});
}
const logged = 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 => {
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 => 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) {
const syncAware = callback;
syncAware.sync = true;
return syncAware;
}
//# sourceMappingURL=utils.js.map