Refactor: share nav drag and drop logic

This commit is contained in:
MangoPig
2026-06-24 02:11:02 +01:00
parent 69af324b1b
commit 212dd1c435
6 changed files with 626 additions and 712 deletions
@@ -5,6 +5,17 @@ import { ChevronDown, ChevronRight, Folder, LayoutGrid, ListCollapse, UnfoldVert
import { ProjectContextMenu } from "../ProjectContextMenu/ProjectContextMenu";
import { useAppShellData } from "../data/app-shell.context";
import { resolveAPIBase } from "../../../lib/api";
import {
collectBranchNodeIds,
findTreeNodeDepth,
findTreeNodeLocation,
getPointerRelativeY,
isUuidString,
moveTreeNode,
resolveTreeDropTarget,
type NavTreeAdapter,
type NavTreeDropTarget,
} from "../shared/navTreeDnd";
import {
createProjectFolderTarget,
createProjectSurfaceTarget,
@@ -56,24 +67,13 @@ type PendingProjectFolderDraft = {
depth: number;
};
type ProjectDragTarget = {
parentId: string | null;
index: number;
intent: "before" | "after" | "inside";
targetNodeId?: string;
};
type ProjectDragTarget = NavTreeDropTarget;
type ProjectDragState = {
draggedNodeId: string;
dropTarget: ProjectDragTarget | null;
};
type ProjectNodeLocation = {
parentId: string | null;
index: number;
node: ProjectTreeNode;
};
const LONG_PRESS_MS = 320;
const getProjectTreeNodeId = (node: ProjectTreeNode): string =>
@@ -98,198 +98,20 @@ const buildProjectTree = (
...buildPersistedFolderNodes(folders),
];
const collectProjectFolderIds = (nodes: readonly ProjectTreeNode[]): string[] => {
const ids: string[] = [];
for (const node of nodes) {
if (node.kind !== "folder") {
continue;
}
ids.push(node.id);
ids.push(...collectProjectFolderIds(node.children));
}
return ids;
};
const readPersistedFolders = (body: ProjectFoldersResponse): PersistedProjectFolderRecord[] =>
Array.isArray(body.data?.folders) ? body.data.folders : [];
const cloneProjectTreeNode = (node: ProjectTreeNode): ProjectTreeNode => {
if (node.kind === "project") {
return {
kind: "project",
item: { ...node.item },
};
}
return {
kind: "folder",
id: node.id,
label: node.label,
meta: node.meta,
children: node.children.map(cloneProjectTreeNode),
};
};
const findProjectNodeLocation = (
nodes: readonly ProjectTreeNode[],
nodeId: string,
parentId: string | null = null,
): ProjectNodeLocation | null => {
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (getProjectTreeNodeId(node) === nodeId) {
return { parentId, index, node };
}
if (node.kind === "folder") {
const nestedLocation = findProjectNodeLocation(node.children, nodeId, node.id);
if (nestedLocation) {
return nestedLocation;
}
}
}
return null;
};
const findProjectNodeDepth = (nodes: readonly ProjectTreeNode[], nodeId: string, depth = 0): number | null => {
for (const node of nodes) {
if (getProjectTreeNodeId(node) === nodeId) {
return depth;
}
if (node.kind === "folder") {
const nestedDepth = findProjectNodeDepth(node.children, nodeId, depth + 1);
if (nestedDepth !== null) {
return nestedDepth;
}
}
}
return null;
};
const projectTreeContainsNode = (nodes: readonly ProjectTreeNode[], nodeId: string): boolean => {
for (const node of nodes) {
if (getProjectTreeNodeId(node) === nodeId) {
return true;
}
if (node.kind === "folder" && projectTreeContainsNode(node.children, nodeId)) {
return true;
}
}
return false;
};
const removeProjectTreeNode = (
nodes: readonly ProjectTreeNode[],
nodeId: string,
): { nodes: ProjectTreeNode[]; removed: ProjectTreeNode | null } => {
const nextNodes: ProjectTreeNode[] = [];
let removed: ProjectTreeNode | null = null;
for (const node of nodes) {
if (getProjectTreeNodeId(node) === nodeId) {
removed = node;
continue;
}
if (node.kind === "folder") {
const result = removeProjectTreeNode(node.children, nodeId);
if (result.removed) {
removed = result.removed;
nextNodes.push({
...node,
children: result.nodes,
});
continue;
}
}
nextNodes.push(node);
}
return { nodes: nextNodes, removed };
};
const insertProjectTreeNode = (
nodes: readonly ProjectTreeNode[],
parentId: string | null,
index: number,
nodeToInsert: ProjectTreeNode,
): ProjectTreeNode[] => {
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 (node.kind !== "folder") {
return node;
}
if (node.id === parentId) {
const nextChildren = [...node.children];
nextChildren.splice(Math.max(0, Math.min(index, nextChildren.length)), 0, nodeToInsert);
return {
const projectTreeAdapter: NavTreeAdapter<ProjectTreeNode> = {
getNodeId: getProjectTreeNodeId,
isBranchNode: (node) => node.kind === "folder",
getChildren: (node) => (node.kind === "folder" ? node.children : []),
withChildren: (node, children) =>
node.kind === "folder"
? {
...node,
children: nextChildren,
};
}
return {
...node,
children: insertProjectTreeNode(node.children, parentId, index, nodeToInsert),
};
});
};
const moveProjectTreeNode = (
nodes: readonly ProjectTreeNode[],
draggedNodeId: string,
dropTarget: ProjectDragTarget,
): ProjectTreeNode[] => {
const location = findProjectNodeLocation(nodes, draggedNodeId);
if (!location) {
return [...nodes];
}
if (
location.node.kind === "folder" &&
dropTarget.parentId !== null &&
(projectTreeContainsNode(location.node.children, dropTarget.parentId) || dropTarget.parentId === location.node.id)
) {
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 = removeProjectTreeNode(nodes, draggedNodeId);
if (!removalResult.removed) {
return [...nodes];
}
return insertProjectTreeNode(removalResult.nodes, dropTarget.parentId, normalizedIndex, removalResult.removed);
children: [...children],
}
: node,
};
const ProjectFolderDraftRow = (props: {
@@ -385,18 +207,18 @@ const ProjectFolderBranch = (props: {
[styles.treeItemFolder]: true,
[styles.treeItemDragging]: isDraggedNode(),
[styles.treeItemDropBefore]: dropIntent() === "before",
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(props.depth) }}
aria-expanded={!isCollapsed()}
onClick={() => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
[styles.treeItemDropAfter]: dropIntent() === "after",
[styles.treeItemDropInside]: dropIntent() === "inside",
}}
style={{ "--tree-depth": String(props.depth) }}
aria-expanded={!isCollapsed()}
onClick={() => {
if (props.dragState || props.isTreeClickSuppressed()) {
return;
}
props.onToggleFolder(node.id);
}}
props.onToggleFolder(node.id);
}}
onContextMenu={(event): void => props.onOpenFolderMenu(event, node)}
onPointerDown={(event): void => props.onNodePointerDown(event, node.id)}
onPointerMove={(event): void =>
@@ -550,7 +372,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const syncProjectTree = (): void => {
const nextTree = buildProjectTree(appShellData.projectItems(), persistedFolders());
const availableFolderIds = new Set(collectProjectFolderIds(nextTree));
const availableFolderIds = new Set(collectBranchNodeIds(nextTree, projectTreeAdapter));
setProjectTreeNodes(nextTree);
setCollapsedFolderIds((current) => current.filter((folderId) => availableFolderIds.has(folderId)));
@@ -563,7 +385,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
setDragState(null);
};
const folderIds = (): string[] => collectProjectFolderIds(projectTreeNodes());
const folderIds = (): string[] => collectBranchNodeIds(projectTreeNodes(), projectTreeAdapter);
const expandAllFolders = (): void => {
setCollapsedFolderIds([]);
@@ -599,6 +421,11 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return;
}
if (!isUuidString(projectId)) {
setPersistedFolders([]);
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
headers: {
@@ -713,7 +540,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
suppressTreeClickTemporarily();
setProjectTreeNodes((current) =>
moveProjectTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget),
moveTreeNode(current, nextDragState.draggedNodeId, nextDragState.dropTarget as ProjectDragTarget, projectTreeAdapter),
);
setDragState(null);
};
@@ -765,7 +592,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
};
const selectProject = (projectId: string): void => {
const location = findProjectNodeLocation(projectTreeNodes(), projectId);
const location = findTreeNodeLocation(projectTreeNodes(), projectId, projectTreeAdapter);
if (!location || location.node.kind !== "project") {
return;
@@ -787,6 +614,7 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
const submitPendingFolder = async (): Promise<void> => {
const name = pendingFolderName().trim();
const draft = pendingFolderDraft();
const projectId = selectedProject().id;
if (!draft) {
return;
@@ -798,8 +626,13 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return;
}
if (!isUuidString(projectId)) {
cancelPendingFolder();
return;
}
try {
const response = await fetch(`${resolveAPIBase()}/projects/${selectedProject().id}/folders`, {
const response = await fetch(`${resolveAPIBase()}/projects/${projectId}/folders`, {
method: "POST",
headers: {
Accept: "application/json",
@@ -835,16 +668,16 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return;
}
switch (target.kind) {
switch (target.kind) {
case "surface":
beginFolderDraft(null, 0);
return;
case "folder":
beginFolderDraft(target.id, (findProjectNodeDepth(projectTreeNodes(), target.id) ?? 0) + 1);
beginFolderDraft(target.id, (findTreeNodeDepth(projectTreeNodes(), target.id, projectTreeAdapter) ?? 0) + 1);
return;
case "project": {
const parentId = findProjectNodeLocation(projectTreeNodes(), target.id)?.parentId ?? null;
beginFolderDraft(parentId, parentId ? (findProjectNodeDepth(projectTreeNodes(), parentId) ?? 0) + 1 : 0);
const parentId = findTreeNodeLocation(projectTreeNodes(), target.id, projectTreeAdapter)?.parentId ?? null;
beginFolderDraft(parentId, parentId ? (findTreeNodeDepth(projectTreeNodes(), parentId, projectTreeAdapter) ?? 0) + 1 : 0);
return;
}
}
@@ -886,50 +719,20 @@ export const ProjectSelector = (props: ProjectSelectorProps): JSX.Element => {
return;
}
const currentTarget = event.currentTarget;
if (!(currentTarget instanceof HTMLElement)) {
const relativeY = getPointerRelativeY(event);
if (relativeY === null) {
return;
}
const bounds = currentTarget.getBoundingClientRect();
const relativeY = bounds.height <= 0 ? 0.5 : (event.clientY - bounds.top) / bounds.height;
let nextTarget: ProjectDragTarget;
if (node.kind === "folder") {
if (relativeY < 0.28) {
nextTarget = {
parentId,
index,
intent: "before",
targetNodeId: node.id,
};
} else if (relativeY > 0.72) {
nextTarget = {
parentId,
index: index + 1,
intent: "after",
targetNodeId: node.id,
};
} else {
nextTarget = {
parentId: node.id,
index: node.children.length,
intent: "inside",
targetNodeId: node.id,
};
}
} else {
nextTarget = {
parentId,
index: relativeY < 0.5 ? index : index + 1,
intent: relativeY < 0.5 ? "before" : "after",
targetNodeId: node.item.id,
};
}
setDragState({
...nextDragState,
dropTarget: nextTarget,
dropTarget: resolveTreeDropTarget({
parentId,
index,
node,
relativeY,
adapter: projectTreeAdapter,
}),
});
};