mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
156 lines
5.1 KiB
TypeScript
156 lines
5.1 KiB
TypeScript
import type { MailboxNode } from '@/lib/utils';
|
|
|
|
/**
|
|
* Helpers for drag & drop of the folder tree in Settings → Folders.
|
|
*
|
|
* The tree is rendered as a single flattened sortable list; while dragging,
|
|
* the pointer's vertical position picks the insertion point and its horizontal
|
|
* offset picks the nesting depth (dnd-kit's sortable-tree projection model).
|
|
* This is what lets a drag both reorder folders and move them under a
|
|
* different parent (GitHub #855).
|
|
*/
|
|
|
|
export interface FlatFolder {
|
|
id: string;
|
|
/** Normalized: null for root-level folders. */
|
|
parentId: string | null;
|
|
depth: number;
|
|
node: MailboxNode;
|
|
}
|
|
|
|
export interface Projection {
|
|
depth: number;
|
|
parentId: string | null;
|
|
}
|
|
|
|
function arrayMove<T>(array: T[], from: number, to: number): T[] {
|
|
const result = array.slice();
|
|
const [item] = result.splice(from, 1);
|
|
result.splice(to, 0, item);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Flattens the visible part of the tree (collapsed folders contribute only
|
|
* themselves, not their children) in rendering order.
|
|
*
|
|
* parentId is taken from the tree structure, not from the raw mailbox data:
|
|
* a folder whose real parent is missing from the server response is rendered
|
|
* at root level, and reordering must treat it as a root sibling.
|
|
*/
|
|
export function flattenVisibleTree(
|
|
nodes: MailboxNode[],
|
|
expandedIds: Set<string>,
|
|
): FlatFolder[] {
|
|
const result: FlatFolder[] = [];
|
|
const walk = (items: MailboxNode[], parentId: string | null, depth: number) => {
|
|
for (const node of items) {
|
|
result.push({ id: node.id, parentId, depth, node });
|
|
if (node.children.length > 0 && expandedIds.has(node.id)) {
|
|
walk(node.children, node.id, depth + 1);
|
|
}
|
|
}
|
|
};
|
|
walk(nodes, null, 0);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Removes the descendants of `rootId` (but not `rootId` itself). Used while
|
|
* dragging so a folder's subtree travels with it and can never become its own
|
|
* drop target (which would create a cycle).
|
|
*/
|
|
export function removeSubtree(items: FlatFolder[], rootId: string): FlatFolder[] {
|
|
const excluded = new Set<string>([rootId]);
|
|
return items.filter(item => {
|
|
if (item.parentId !== null && excluded.has(item.parentId)) {
|
|
excluded.add(item.id);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Computes where the dragged folder would land: its new depth (clamped to the
|
|
* depths that are structurally valid at that position) and the parent that
|
|
* depth implies. `dragOffset` is the horizontal pointer delta since drag
|
|
* start; each `indentationWidth` of it shifts the target depth by one level.
|
|
*/
|
|
export function getProjection(
|
|
items: FlatFolder[],
|
|
activeId: string,
|
|
overId: string,
|
|
dragOffset: number,
|
|
indentationWidth: number,
|
|
): Projection | null {
|
|
const overItemIndex = items.findIndex(i => i.id === overId);
|
|
const activeItemIndex = items.findIndex(i => i.id === activeId);
|
|
if (overItemIndex < 0 || activeItemIndex < 0) return null;
|
|
|
|
const activeItem = items[activeItemIndex];
|
|
const newItems = arrayMove(items, activeItemIndex, overItemIndex);
|
|
const previousItem = newItems[overItemIndex - 1];
|
|
const nextItem = newItems[overItemIndex + 1];
|
|
|
|
const dragDepth = Math.round(dragOffset / indentationWidth);
|
|
const projectedDepth = activeItem.depth + dragDepth;
|
|
// Can nest at most one level under the item above; can't out-dent past the
|
|
// item below (that would split its sibling group).
|
|
const maxDepth = previousItem ? previousItem.depth + 1 : 0;
|
|
const minDepth = nextItem ? nextItem.depth : 0;
|
|
const depth = Math.min(Math.max(projectedDepth, minDepth), maxDepth);
|
|
|
|
let parentId: string | null = null;
|
|
if (depth > 0 && previousItem) {
|
|
if (depth === previousItem.depth) {
|
|
parentId = previousItem.parentId;
|
|
} else if (depth > previousItem.depth) {
|
|
parentId = previousItem.id;
|
|
} else {
|
|
// Out-dented below a deeper item: adopt the parent of the nearest
|
|
// preceding item at the target depth.
|
|
parentId = newItems
|
|
.slice(0, overItemIndex)
|
|
.reverse()
|
|
.find(item => item.depth === depth)?.parentId ?? null;
|
|
}
|
|
}
|
|
|
|
return { depth, parentId };
|
|
}
|
|
|
|
export interface DropPlan {
|
|
parentId: string | null;
|
|
parentChanged: boolean;
|
|
/** The new parent's sibling group in its new order, including the dragged folder. */
|
|
orderedSiblingIds: string[];
|
|
}
|
|
|
|
/**
|
|
* Turns a projection into the concrete mutation to perform: the (possibly
|
|
* unchanged) parent and the new ordering of that parent's visible children.
|
|
*/
|
|
export function getDropPlan(
|
|
items: FlatFolder[],
|
|
activeId: string,
|
|
overId: string,
|
|
projection: Projection,
|
|
): DropPlan | null {
|
|
const activeIndex = items.findIndex(i => i.id === activeId);
|
|
const overIndex = items.findIndex(i => i.id === overId);
|
|
if (activeIndex < 0 || overIndex < 0) return null;
|
|
|
|
const activeItem = items[activeIndex];
|
|
const newItems = arrayMove(items, activeIndex, overIndex);
|
|
const orderedSiblingIds = newItems
|
|
.filter(i => (i.id === activeId ? projection.parentId : i.parentId) === projection.parentId)
|
|
.map(i => i.id);
|
|
|
|
return {
|
|
parentId: projection.parentId,
|
|
parentChanged: activeItem.parentId !== projection.parentId,
|
|
orderedSiblingIds,
|
|
};
|
|
}
|