import { closestCenter, type CollisionDetection, type Modifier } from "@dnd-kit/core"; import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; import { resolveSidebarDropTarget, sidebarListItemId, sidebarMarkerId, type SidebarListItem, type SidebarListMarker, type SidebarSection, } from "./Sidebar.logic"; const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; const hidden = { ...stationary, scaleY: 0 }; type ThreadItem = Extract; type Layout = Parameters[0]; /** Keep the lifted card below the Pins label, including when Pins is empty. * The container rect follows scrolling; the offset is measured once at pickup. */ export function restrictBelowSidebarLabel( { transform, containerNodeRect, draggingNodeRect }: Parameters[0], offset: number, ) { if (!containerNodeRect || !draggingNodeRect) return transform; const minimumY = containerNodeRect.top + offset - draggingNodeRect.top; return transform.y < minimumY ? { ...transform, y: minimumY } : transform; } /** Reject the nearest unsupported target without selecting another section. * Recreate this detector when drop eligibility changes. */ export function createSidebarCollisionDetection( isValidTarget: (id: string) => boolean, options: { items?: readonly SidebarListItem[]; activationY?: number | null; } = {}, ): CollisionDetection { const validity = new Map(); const sections = new Map(); let previousPointerY = options.activationY; let boundarySection: "pinned" | "active" | undefined; return (args) => { let collisions = closestCenter(args); const pointer = args.pointerCoordinates; const items = options.items; const source = items?.find((item) => item.kind === "thread" && item.key === args.active.id); const boundary = args.droppableContainers .find((container) => container.id === sidebarMarkerId("pinned-divider")) ?.node.current?.querySelector(".sidebar-drag-boundary-label") ?.getBoundingClientRect(); if (items && boundary && source?.kind === "thread" && pointer) { boundarySection ??= source.section === "pinned" ? "pinned" : "active"; // Use the visible divider row, including its sortable translation. // Only pointer movement can change sections: opening the destination // moves this row, but must not toggle a stationary gesture back. const previousY = previousPointerY ?? pointer.y; previousPointerY = pointer.y; if (pointer.x >= boundary.left && pointer.x <= boundary.right) { if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned"; else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active"; const nextHeader = args.droppableContainers.find( (container) => container.id === sidebarMarkerId("snoozed-header"), ) ?? args.droppableContainers.find( (container) => container.id === sidebarMarkerId("settled-header"), ); const activeBottom = nextHeader?.node.current?.getBoundingClientRect().top; if (boundarySection === "pinned" || (activeBottom != null && pointer.y < activeBottom)) { const target = collisions.find((collision) => { const id = String(collision.id); if (!sections.has(id)) { sections.set( id, resolveSidebarDropTarget(items, String(args.active.id), id)?.section ?? null, ); } return sections.get(id) === boundarySection; }); if (target) collisions = [target, ...collisions.filter((collision) => collision !== target)]; } } } const nearest = collisions[0]; if (!nearest || nearest.id === args.active.id) { return collisions; } const id = String(nearest.id); const valid = validity.get(id) ?? isValidTarget(id); validity.set(id, valid); return valid ? collisions : collisions.filter((collision) => collision.id === args.active.id); }; } /** Preview the committed section layout without moving or mounting DOM nodes. * A zero scaleY marks rows/markers to hide while retaining their measured nodes. */ export function createSidebarSortingStrategy(input: { items: readonly SidebarListItem[]; settledOrder: readonly string[]; settledExpanded: boolean; settledVisibleCount?: number; routeThreadKey?: string | null; snoozedThreadCount?: number; cardHeight?: number; slimHeight?: number; /** Space each pinned boundary opens for its label while dragging. The * markers stay zero height at rest, so nothing is reserved until pickup. */ boundaryLabelHeight?: number; }): SortingStrategy { const { items } = input; const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index])); let previous: Pick | undefined; let transforms: ReturnType[] | null = []; function project({ rects, activeIndex, overIndex }: Layout) { const active = items[activeIndex]; const over = items[overIndex] ?? active; if (active?.kind !== "thread" || !over || !rects[0]) return []; const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); if (!target) return []; const groups: Record = { pinned: [], active: [], snoozed: [], settled: [], }; let cardHeight = input.cardHeight; let slimHeight = input.slimHeight; let headerScale: number | undefined; for (const [index, item] of items.entries()) { if (item.kind === "marker") { if (item.marker === "settled-header" || item.marker === "snoozed-header") { const height = rects[index]?.height; if (height) headerScale ??= height / 32; } continue; } if (item.section === "pinned" || item.section === "active") cardHeight ??= rects[index]?.height; else slimHeight ??= rects[index]?.height; if (item.key !== active.key) groups[item.section].push(item); } // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9. const scale = slimHeight !== undefined ? slimHeight / 36 : (headerScale ?? (cardHeight ?? 82) / 82); cardHeight ??= 82 * scale; slimHeight ??= 36 * scale; const labelHeight = (input.boundaryLabelHeight ?? 0) * scale; const group = groups[target.section]; const order = target.section === "pinned" ? target.pinnedOrder : target.section === "settled" ? input.settledOrder : target.activeOrder; const ranks = new Map(order.map((key, index) => [key, index])); const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY; const index = group.findIndex( (item) => (ranks.get(item.key) ?? Number.POSITIVE_INFINITY) > rank, ); group.splice(index < 0 ? group.length : index, 0, { ...active, section: target.section }); const settledOrder = ( input.settledOrder.length > 0 ? input.settledOrder : groups.settled.map((item) => item.key) ).filter((key) => key !== active.key || target.section === "settled"); const visible = input.settledExpanded ? settledOrder.slice(0, input.settledVisibleCount ?? settledOrder.length) : []; const routeKey = input.routeThreadKey; if (routeKey && settledOrder.includes(routeKey) && !visible.includes(routeKey)) { visible.push(routeKey); } groups.settled = visible.map((key) => ({ kind: "thread", key, section: "settled" })); const projected: SidebarListItem[] = []; const marker = (name: SidebarListMarker) => projected.push({ kind: "marker", marker: name }); const section = (name: "active" | "settled") => { if (groups[name].length > 0) projected.push(...groups[name]); else marker(`${name}-placeholder`); }; marker("pinned-header"); projected.push(...groups.pinned); marker("pinned-divider"); section("active"); if ( groups.snoozed.length > 0 || ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) && items.some((item) => item.kind === "marker" && item.marker === "snoozed-header")) ) { marker("snoozed-header"); projected.push(...groups.snoozed); } marker("settled-header"); section("settled"); const result = items.map(() => hidden); let top = rects[0].top; for (const item of projected) { const index = indices.get(sidebarListItemId(item)); const rect = index === undefined ? undefined : rects[index]; if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top }; const fallback = item.kind === "thread" && (item.section === "pinned" || item.section === "active") ? cardHeight : slimHeight; const moved = item.kind === "thread" && item.key === active.key; const height = item.kind === "marker" && (item.marker === "pinned-header" || item.marker === "pinned-divider") ? labelHeight : item.kind === "marker" && item.marker.endsWith("placeholder") ? slimHeight : moved ? fallback : (rect?.height ?? fallback); top += height + 1; } result[activeIndex] = stationary; return result; } return (args) => { if ( previous?.rects !== args.rects || previous.activeIndex !== args.activeIndex || previous.overIndex !== args.overIndex ) { previous = args; transforms = project(args); } return transforms === null ? verticalListSortingStrategy(args) : (transforms[args.index] ?? stationary); }; }