232 lines · 9.6 KB · TypeScript
RawDownload
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);  };}