@headless-tree/core
Version:
The definitive tree component for the Web
313 lines (286 loc) • 9.55 kB
text/typescript
import {
FeatureImplementation,
HotkeysConfig,
ItemInstance,
TreeConfig,
TreeInstance,
TreeState,
Updater,
} from "../types/core";
import { treeFeature } from "../features/tree/feature";
import { ItemMeta } from "../features/tree/types";
import { buildStaticInstance } from "./build-static-instance";
import { throwError } from "../utilities/errors";
import type { TreeDataRef } from "../features/main/types";
const verifyFeatures = (features: FeatureImplementation[] | undefined) => {
const loadedFeatures = features?.map((feature) => feature.key);
for (const feature of features ?? []) {
const missingDependency = feature.deps?.find(
(dep) => !loadedFeatures?.includes(dep),
);
if (missingDependency) {
throw throwError(`${feature.key} needs ${missingDependency}`);
}
}
};
// Check all possible pairs and sort the array
const exhaustiveSort = <T>(
arr: T[],
compareFn: (param1: T, param2: T) => number,
) => {
const n = arr.length;
for (let i = 0; i < n; i++) {
for (let j = i + 1; j < n; j++) {
if (compareFn(arr[j], arr[i]) < 0) {
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
}
return arr;
};
const compareFeatures =
(originalOrder: FeatureImplementation[]) =>
(feature1: FeatureImplementation, feature2: FeatureImplementation) => {
if (feature2.key && feature1.overwrites?.includes(feature2.key)) {
return 1;
}
if (feature1.key && feature2.overwrites?.includes(feature1.key)) {
return -1;
}
return originalOrder.indexOf(feature1) - originalOrder.indexOf(feature2);
};
const sortFeatures = (features: FeatureImplementation[] = []) =>
exhaustiveSort(features, compareFeatures(features));
export const createTree = <T>(
initialConfig: TreeConfig<T>,
): TreeInstance<T> => {
const buildInstance = initialConfig.instanceBuilder ?? buildStaticInstance;
const additionalFeatures = [
treeFeature,
...sortFeatures(initialConfig.features),
];
verifyFeatures(additionalFeatures);
const features = [...additionalFeatures];
const [treeInstance, finalizeTree] = buildInstance(
features,
"treeInstance",
(tree) => ({ tree }),
);
let state = additionalFeatures.reduce(
(acc, feature) => feature.getInitialState?.(acc, treeInstance) ?? acc,
initialConfig.initialState ?? initialConfig.state ?? {},
) as TreeState<T>;
let config = additionalFeatures.reduce(
(acc, feature) =>
(feature.getDefaultConfig?.(acc, treeInstance) as TreeConfig<T>) ?? acc,
initialConfig,
) as TreeConfig<T>;
const stateHandlerNames = additionalFeatures.reduce(
(acc, feature) => ({ ...acc, ...feature.stateHandlerNames }),
{} as Record<string, keyof TreeConfig<T>>,
);
let treeElement: HTMLElement | undefined | null;
const treeDataRef: { current: any } = { current: {} };
let rebuildScheduled = false;
const itemInstancesMap: Record<string, ItemInstance<T>> = {};
let itemInstances: ItemInstance<T>[] = [];
const itemElementsMap: Record<string, HTMLElement | undefined | null> = {};
const itemDataRefs: Record<string, { current: any }> = {};
let itemMetaMap: Record<string, ItemMeta> = {};
const hotkeyPresets = {} as HotkeysConfig<T>;
const rebuildItemMeta = () => {
// TODO can we find a way to only run this for the changed substructure?
itemInstances = [];
itemMetaMap = {};
const [rootInstance, finalizeRootInstance] = buildInstance(
features,
"itemInstance",
(item) => ({ item, tree: treeInstance, itemId: config.rootItemId }),
);
finalizeRootInstance();
itemInstancesMap[config.rootItemId] = rootInstance;
itemMetaMap[config.rootItemId] = {
itemId: config.rootItemId,
index: -1,
parentId: null!,
level: -1,
posInSet: 0,
setSize: 1,
};
for (const item of treeInstance.getItemsMeta()) {
itemMetaMap[item.itemId] = item;
if (!itemInstancesMap[item.itemId]) {
const [instance, finalizeInstance] = buildInstance(
features,
"itemInstance",
(instance) => ({
item: instance,
tree: treeInstance,
itemId: item.itemId,
}),
);
finalizeInstance();
itemInstancesMap[item.itemId] = instance;
itemInstances.push(instance);
} else {
itemInstances.push(itemInstancesMap[item.itemId]);
}
}
rebuildScheduled = false;
};
const eachFeature = (fn: (feature: FeatureImplementation<any>) => void) => {
for (const feature of additionalFeatures) {
fn(feature);
}
};
const mainFeature: FeatureImplementation<T> = {
key: "main",
treeInstance: {
getState: () => state,
setState: ({}, updater) => {
// Not necessary, since I think the subupdate below keeps the state fresh anyways?
// state = typeof updater === "function" ? updater(state) : updater;
config.setState?.(state); // TODO this cant be right... This doesnt allow external state updates
// TODO this is never used, remove
},
setMounted: ({}, isMounted) => {
const ref = treeDataRef.current as TreeDataRef;
ref.isMounted = isMounted;
if (isMounted) {
ref.waitingForMount?.forEach((cb) => cb());
ref.waitingForMount = [];
}
},
applySubStateUpdate: <K extends keyof TreeState<any>>(
{},
stateName: K,
updater: Updater<TreeState<T>[K]>,
) => {
const apply = () => {
state[stateName] =
typeof updater === "function" ? updater(state[stateName]) : updater;
const externalStateSetter = config[
stateHandlerNames[stateName]
] as Function;
externalStateSetter?.(state[stateName]);
};
const ref = treeDataRef.current as TreeDataRef;
if (ref.isMounted) {
apply();
} else {
ref.waitingForMount ??= [];
ref.waitingForMount.push(apply);
}
},
// TODO rebuildSubTree: (itemId: string) => void;
rebuildTree: () => {
const ref = treeDataRef.current as TreeDataRef;
if (ref.isMounted) {
rebuildItemMeta();
config.setState?.(state);
} else {
ref.waitingForMount ??= [];
ref.waitingForMount.push(() => {
rebuildItemMeta();
config.setState?.(state);
});
}
},
scheduleRebuildTree: () => {
rebuildScheduled = true;
},
getConfig: () => config,
setConfig: (_, updater) => {
const newConfig =
typeof updater === "function" ? updater(config) : updater;
const hasChangedExpandedItems =
newConfig.state?.expandedItems &&
newConfig.state?.expandedItems !== state.expandedItems;
config = newConfig;
if (newConfig.state) {
state = { ...state, ...newConfig.state };
}
if (hasChangedExpandedItems) {
// if expanded items where changed from the outside
rebuildItemMeta();
config.setState?.(state);
}
},
getItemInstance: ({}, itemId) => {
const existingInstance = itemInstancesMap[itemId];
if (!existingInstance) {
const [instance, finalizeInstance] = buildInstance(
features,
"itemInstance",
(instance) => ({
item: instance,
tree: treeInstance,
itemId,
}),
);
finalizeInstance();
return instance;
}
return existingInstance;
},
getItems: () => {
if (rebuildScheduled) rebuildItemMeta();
return itemInstances;
},
registerElement: ({}, element) => {
if (treeElement === element) {
return;
}
if (treeElement && !element) {
eachFeature((feature) =>
feature.onTreeUnmount?.(treeInstance, treeElement!),
);
} else if (!treeElement && element) {
eachFeature((feature) =>
feature.onTreeMount?.(treeInstance, element),
);
}
treeElement = element;
},
getElement: () => treeElement,
getDataRef: () => treeDataRef,
getHotkeyPresets: () => hotkeyPresets,
},
itemInstance: {
registerElement: ({ itemId, item }, element) => {
if (itemElementsMap[itemId] === element) {
return;
}
const oldElement = itemElementsMap[itemId];
if (oldElement && !element) {
eachFeature((feature) =>
feature.onItemUnmount?.(item, oldElement!, treeInstance),
);
} else if (!oldElement && element) {
eachFeature((feature) =>
feature.onItemMount?.(item, element!, treeInstance),
);
}
itemElementsMap[itemId] = element;
},
getElement: ({ itemId }) => itemElementsMap[itemId],
// eslint-disable-next-line no-return-assign
getDataRef: ({ itemId }) => (itemDataRefs[itemId] ??= { current: {} }),
getItemMeta: ({ itemId }) =>
itemMetaMap[itemId] ?? {
itemId,
parentId: null,
level: -1,
index: -1,
posInSet: 0,
setSize: 1,
},
},
};
features.unshift(mainFeature);
for (const feature of features) {
Object.assign(hotkeyPresets, feature.hotkeys ?? {});
}
finalizeTree();
return treeInstance;
};