279 lines
6.6 KiB
TypeScript
279 lines
6.6 KiB
TypeScript
export type NavTreeDropIntent = "before" | "after" | "inside";
|
|
|
|
export type NavTreeDropTarget = {
|
|
parentId: string | null;
|
|
index: number;
|
|
intent: NavTreeDropIntent;
|
|
targetNodeId?: string;
|
|
};
|
|
|
|
export type NavTreeDragState = {
|
|
draggedNodeId: string;
|
|
dropTarget: NavTreeDropTarget | null;
|
|
};
|
|
|
|
export type NavTreeLocation<TNode> = {
|
|
parentId: string | null;
|
|
index: number;
|
|
node: TNode;
|
|
};
|
|
|
|
export type NavTreeAdapter<TNode> = {
|
|
getNodeId: (node: TNode) => string;
|
|
isBranchNode: (node: TNode) => boolean;
|
|
getChildren: (node: TNode) => readonly TNode[];
|
|
withChildren: (node: TNode, children: readonly TNode[]) => TNode;
|
|
};
|
|
|
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
export const isUuidString = (value: string | null | undefined): boolean => {
|
|
if (typeof value !== "string") {
|
|
return false;
|
|
}
|
|
|
|
return UUID_PATTERN.test(value.trim());
|
|
};
|
|
|
|
export const collectBranchNodeIds = <TNode>(
|
|
nodes: readonly TNode[],
|
|
adapter: NavTreeAdapter<TNode>,
|
|
): string[] => {
|
|
const ids: string[] = [];
|
|
|
|
for (const node of nodes) {
|
|
if (!adapter.isBranchNode(node)) {
|
|
continue;
|
|
}
|
|
|
|
ids.push(adapter.getNodeId(node));
|
|
ids.push(...collectBranchNodeIds(adapter.getChildren(node), adapter));
|
|
}
|
|
|
|
return ids;
|
|
};
|
|
|
|
export const findTreeNodeLocation = <TNode>(
|
|
nodes: readonly TNode[],
|
|
nodeId: string,
|
|
adapter: NavTreeAdapter<TNode>,
|
|
parentId: string | null = null,
|
|
): NavTreeLocation<TNode> | null => {
|
|
for (let index = 0; index < nodes.length; index += 1) {
|
|
const node = nodes[index];
|
|
|
|
if (adapter.getNodeId(node) === nodeId) {
|
|
return { parentId, index, node };
|
|
}
|
|
|
|
if (!adapter.isBranchNode(node)) {
|
|
continue;
|
|
}
|
|
|
|
const nestedLocation = findTreeNodeLocation(adapter.getChildren(node), nodeId, adapter, adapter.getNodeId(node));
|
|
if (nestedLocation) {
|
|
return nestedLocation;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export const findTreeNodeDepth = <TNode>(
|
|
nodes: readonly TNode[],
|
|
nodeId: string,
|
|
adapter: NavTreeAdapter<TNode>,
|
|
depth = 0,
|
|
): number | null => {
|
|
for (const node of nodes) {
|
|
if (adapter.getNodeId(node) === nodeId) {
|
|
return depth;
|
|
}
|
|
|
|
if (!adapter.isBranchNode(node)) {
|
|
continue;
|
|
}
|
|
|
|
const nestedDepth = findTreeNodeDepth(adapter.getChildren(node), nodeId, adapter, depth + 1);
|
|
if (nestedDepth !== null) {
|
|
return nestedDepth;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
export const treeContainsNode = <TNode>(
|
|
nodes: readonly TNode[],
|
|
nodeId: string,
|
|
adapter: NavTreeAdapter<TNode>,
|
|
): boolean => {
|
|
for (const node of nodes) {
|
|
if (adapter.getNodeId(node) === nodeId) {
|
|
return true;
|
|
}
|
|
|
|
if (adapter.isBranchNode(node) && treeContainsNode(adapter.getChildren(node), nodeId, adapter)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
export const removeTreeNode = <TNode>(
|
|
nodes: readonly TNode[],
|
|
nodeId: string,
|
|
adapter: NavTreeAdapter<TNode>,
|
|
): { nodes: TNode[]; removed: TNode | null } => {
|
|
const nextNodes: TNode[] = [];
|
|
let removed: TNode | null = null;
|
|
|
|
for (const node of nodes) {
|
|
if (adapter.getNodeId(node) === nodeId) {
|
|
removed = node;
|
|
continue;
|
|
}
|
|
|
|
if (adapter.isBranchNode(node)) {
|
|
const result = removeTreeNode(adapter.getChildren(node), nodeId, adapter);
|
|
|
|
if (result.removed) {
|
|
removed = result.removed;
|
|
nextNodes.push(adapter.withChildren(node, result.nodes));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
nextNodes.push(node);
|
|
}
|
|
|
|
return { nodes: nextNodes, removed };
|
|
};
|
|
|
|
export const insertTreeNode = <TNode>(
|
|
nodes: readonly TNode[],
|
|
parentId: string | null,
|
|
index: number,
|
|
nodeToInsert: TNode,
|
|
adapter: NavTreeAdapter<TNode>,
|
|
): TNode[] => {
|
|
if (parentId === null) {
|
|
const nextNodes = [...nodes];
|
|
nextNodes.splice(Math.max(0, Math.min(index, nextNodes.length)), 0, nodeToInsert);
|
|
return nextNodes;
|
|
}
|
|
|
|
return nodes.map((node) => {
|
|
if (!adapter.isBranchNode(node)) {
|
|
return node;
|
|
}
|
|
|
|
if (adapter.getNodeId(node) === parentId) {
|
|
const nextChildren = [...adapter.getChildren(node)];
|
|
nextChildren.splice(Math.max(0, Math.min(index, nextChildren.length)), 0, nodeToInsert);
|
|
return adapter.withChildren(node, nextChildren);
|
|
}
|
|
|
|
return adapter.withChildren(node, insertTreeNode(adapter.getChildren(node), parentId, index, nodeToInsert, adapter));
|
|
});
|
|
};
|
|
|
|
export const moveTreeNode = <TNode>(
|
|
nodes: readonly TNode[],
|
|
draggedNodeId: string,
|
|
dropTarget: NavTreeDropTarget,
|
|
adapter: NavTreeAdapter<TNode>,
|
|
): TNode[] => {
|
|
const location = findTreeNodeLocation(nodes, draggedNodeId, adapter);
|
|
|
|
if (!location) {
|
|
return [...nodes];
|
|
}
|
|
|
|
if (
|
|
adapter.isBranchNode(location.node) &&
|
|
dropTarget.parentId !== null &&
|
|
(treeContainsNode(adapter.getChildren(location.node), dropTarget.parentId, adapter) ||
|
|
dropTarget.parentId === adapter.getNodeId(location.node))
|
|
) {
|
|
return [...nodes];
|
|
}
|
|
|
|
let normalizedIndex = dropTarget.index;
|
|
if (dropTarget.parentId === location.parentId && dropTarget.index > location.index) {
|
|
normalizedIndex -= 1;
|
|
}
|
|
|
|
if (dropTarget.parentId === location.parentId && normalizedIndex === location.index) {
|
|
return [...nodes];
|
|
}
|
|
|
|
const removalResult = removeTreeNode(nodes, draggedNodeId, adapter);
|
|
if (!removalResult.removed) {
|
|
return [...nodes];
|
|
}
|
|
|
|
return insertTreeNode(removalResult.nodes, dropTarget.parentId, normalizedIndex, removalResult.removed, adapter);
|
|
};
|
|
|
|
export const getPointerRelativeY = (event: PointerEvent): number | null => {
|
|
const currentTarget = event.currentTarget;
|
|
if (!(currentTarget instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
const bounds = currentTarget.getBoundingClientRect();
|
|
return bounds.height <= 0 ? 0.5 : (event.clientY - bounds.top) / bounds.height;
|
|
};
|
|
|
|
export const resolveTreeDropTarget = <TNode>(params: {
|
|
parentId: string | null;
|
|
index: number;
|
|
node: TNode;
|
|
relativeY: number;
|
|
adapter: NavTreeAdapter<TNode>;
|
|
beforeThreshold?: number;
|
|
beforeThresholdFirstSibling?: number;
|
|
afterThreshold?: number;
|
|
}): NavTreeDropTarget => {
|
|
const {
|
|
parentId,
|
|
index,
|
|
node,
|
|
relativeY,
|
|
adapter,
|
|
beforeThreshold = 0.28,
|
|
beforeThresholdFirstSibling = 0.42,
|
|
afterThreshold = 0.72,
|
|
} = params;
|
|
|
|
const targetNodeId = adapter.getNodeId(node);
|
|
|
|
if (adapter.isBranchNode(node)) {
|
|
const nextBeforeThreshold = index === 0 ? beforeThresholdFirstSibling : beforeThreshold;
|
|
|
|
if (relativeY < nextBeforeThreshold) {
|
|
return { parentId, index, intent: "before", targetNodeId };
|
|
}
|
|
|
|
if (relativeY > afterThreshold) {
|
|
return { parentId, index: index + 1, intent: "after", targetNodeId };
|
|
}
|
|
|
|
return {
|
|
parentId: targetNodeId,
|
|
index: adapter.getChildren(node).length,
|
|
intent: "inside",
|
|
targetNodeId,
|
|
};
|
|
}
|
|
|
|
return {
|
|
parentId,
|
|
index: relativeY < 0.5 ? index : index + 1,
|
|
intent: relativeY < 0.5 ? "before" : "after",
|
|
targetNodeId,
|
|
};
|
|
};
|