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
@@ -0,0 +1,188 @@
@use "../../../styles/tools/mixins" as *;
@mixin section-label {
@include text-caption;
color: var(--color-text-subtle);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@mixin tree-list {
list-style: none;
display: grid;
gap: var(--space-1);
padding: 0;
}
@mixin empty-slot {
min-height: calc(var(--control-size-lg) - var(--space-2));
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border-radius: var(--radius-lg);
border: 1px dashed color-mix(in srgb, var(--color-border) 38%, transparent);
opacity: 0.35;
}
@mixin input-row {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
padding: var(--space-2) var(--space-3);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-surface) 94%, transparent);
}
@mixin input {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--color-text);
font: inherit;
outline: none;
&::placeholder {
color: var(--color-text-muted);
}
}
@mixin item {
width: 100%;
min-width: 0;
display: grid;
position: relative;
isolation: isolate;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
min-height: calc(var(--control-size-lg) - var(--space-2));
padding: var(--space-2) var(--space-3);
padding-left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
border: 0;
border-radius: 0;
background: transparent;
color: var(--color-text-muted);
text-align: left;
transition:
color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard),
transform 180ms var(--easing-standard);
&::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid transparent;
border-radius: var(--radius-lg);
background: transparent;
transition:
background 160ms var(--easing-standard),
border-color 160ms var(--easing-standard),
box-shadow 160ms var(--easing-standard);
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
}
@mixin item-hover {
color: var(--color-text);
&::after {
background: color-mix(in srgb, var(--color-surface-hover) 80%, var(--color-accent-soft) 20%);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
}
@mixin item-folder {
color: var(--color-text);
}
@mixin item-dragging {
opacity: 0.45;
transform: scale(0.985);
box-shadow: none;
}
@mixin item-drop-before {
&::before {
content: "";
position: absolute;
left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
right: var(--space-3);
top: calc((var(--space-1) * -0.5) - 1px);
height: 2px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent);
pointer-events: none;
z-index: 2;
}
}
@mixin item-drop-after {
&::before {
content: "";
position: absolute;
left: calc(var(--space-3) + (var(--tree-depth, 0) * var(--space-4)));
right: var(--space-3);
bottom: calc((var(--space-1) * -0.5) - 1px);
height: 2px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent-strong) 78%, transparent);
pointer-events: none;
z-index: 2;
}
}
@mixin item-drop-inside {
color: var(--color-text);
&::after {
border-color: color-mix(in srgb, var(--color-accent-strong) 55%, transparent);
background: color-mix(in srgb, var(--color-accent-soft) 36%, var(--color-surface));
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
}
@mixin folder-chevron {
color: var(--color-text-muted);
transition: transform 160ms var(--easing-standard);
}
@mixin folder-chevron-open {
transform: rotate(90deg);
}
@mixin item-active {
color: var(--color-text);
&::after {
border-color: var(--color-border);
background: var(--color-surface);
box-shadow: inset 0 1px 0 color-mix(in srgb, white 4%, transparent);
}
}
@mixin icon {
color: inherit;
opacity: 0.85;
}
@mixin label {
@include text-label;
min-width: 0;
}
@mixin item-meta {
@include text-caption;
color: var(--color-text-muted);
}
@@ -0,0 +1,278 @@
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,
};
};