@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
140 lines (134 loc) • 4.87 kB
JavaScript
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import debounce from 'lodash/debounce';
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-explicit-any
/**
*
* Directly map object values
*
* @private
* @param object The object to transform
* @param mapFunction The function to map an old value to new one
* @returns Object with the same key but transformed values
*
*/
function mapValues(object, mapFunction) {
return Object.entries(object).reduce((acc, [key, value]) => ({
...acc,
[key]: mapFunction(value)
}), {});
}
// When we use the `useSharedPluginStateWithSelector` example: `useSharedPluginStateWithSelector(api, ['width'], selector)`
// it will re-render every time the component re-renders as the array "['width']" is seen as an update.
// This hook is used to prevent re-renders due to this.
function useStaticPlugins(plugins) {
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => plugins, []);
}
/**
*
* ⚠️⚠️⚠️ This is a debounced hook ⚠️⚠️⚠️
* If the plugins you are listening to generate multiple shared states while the user is typing,
* your React Component will get only the last one.
*
* Used to return the current plugin state of input dependencies.
*
* @example
* Example in plugin:
*
* ```typescript
* function selector(states: NamedPluginStatesFromInjectionAPI<API, ['dog']>) {
* return {
* title: states.dogState?.title,
* }
* }
*
* function ExampleContent({ api }: Props) {
* const { title } = useSharedPluginStateWithSelector(
* api,
* ['dog'],
* selector
* )
* return <p>{ title }</p>
* }
*
* ```
*
* @param injectionApi Plugin injection API from `NextEditorPlugin`
* @param plugins Plugin names to get the shared plugin state for
* @param selector A function that takes the shared states of the plugins and returns a subset of a plugin state.
* @returns A corresponding object, the keys are names of the plugin with `State` appended,
* the values are the shared state exposed by that plugin.
*/
export function useSharedPluginStateWithSelector(injectionApi, plugins, selector) {
const pluginNames = useStaticPlugins(plugins);
const selectorRef = useRef(selector);
// Create a memoized object containing the named plugins
const namedExternalPlugins = useMemo(() => pluginNames.reduce((acc, pluginName) => ({
...acc,
[`${String(pluginName)}State`]: injectionApi === null || injectionApi === void 0 ? void 0 : injectionApi[pluginName]
}), {}), [injectionApi, pluginNames]);
return useSharedPluginStateInternal(namedExternalPlugins, selectorRef);
}
function useSharedPluginStateInternal(externalPlugins, selector) {
const refStates = useRef(mapValues(externalPlugins, value => value === null || value === void 0 ? void 0 : value.sharedState.currentState()));
const [pluginStates, setPluginState] = useState(() => selector.current(refStates.current));
const mounted = useRef(false);
useLayoutEffect(() => {
const debouncedPluginStateUpdate = debounce(() => {
setPluginState(currentPluginStates => {
const nextStates = selector.current({
...refStates.current
});
if (shallowEqual(nextStates, currentPluginStates)) {
return currentPluginStates;
}
return nextStates;
});
});
// If we re-render this hook due to a change in the external
// plugins we need to push a state update to ensure we have
// the most current state.
if (mounted.current) {
refStates.current = mapValues(externalPlugins, value => value === null || value === void 0 ? void 0 : value.sharedState.currentState());
debouncedPluginStateUpdate();
}
const unsubs = Object.entries(externalPlugins).map(([pluginKey, externalPlugin]) => {
return externalPlugin === null || externalPlugin === void 0 ? void 0 : externalPlugin.sharedState.onChange(({
nextSharedState,
prevSharedState
}) => {
if (prevSharedState === nextSharedState) {
return;
}
refStates.current[pluginKey] = nextSharedState;
debouncedPluginStateUpdate();
});
});
mounted.current = true;
return () => {
refStates.current = {};
unsubs.forEach(cb => cb === null || cb === void 0 ? void 0 : cb());
};
}, [externalPlugins, selector]);
return pluginStates;
}
function shallowEqual(objA, objB) {
if (objA === objB) {
return true;
}
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (objA[key] !== objB[key]) {
return false;
}
}
return true;
}