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<SidebarListItem, { kind: "thread" }>;type Layout = Parameters<SortingStrategy>[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<Modifier>[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<string, boolean>(); const sections = new Map<string, SidebarSection | null>(); 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<Layout, "rects" | "activeIndex" | "overIndex"> | undefined; let transforms: ReturnType<SortingStrategy>[] | 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<SidebarSection, ThreadItem[]> = { 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); };}
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<SidebarListItem, { kind: "thread" }>;
type Layout = Parameters<SortingStrategy>[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<Modifier>[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<string, boolean>();
const sections = new Map<string, SidebarSection | null>();
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<Layout, "rects" | "activeIndex" | "overIndex"> | undefined;
let transforms: ReturnType<SortingStrategy>[] | 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<SidebarSection, ThreadItem[]> = {
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);
};
}