302 lines
8.8 KiB
TypeScript
302 lines
8.8 KiB
TypeScript
// @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 );
|
|
};
|