Ctrl K
ComunidadGuías…

lib/board-dnd.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * The dnd-kit settings shared by the two boards: drag activation, collision
 * detection, and the absence of misleading card shifts during the gesture.
 *
 * ## `boardCollision` — the pointer chooses only the column
 *
 * `closestCorners` (the previous setting) measures corners. Near the middle of
 * a board, a card in the neighboring column can therefore beat the card under
 * the pointer. In a gutter or below the last card, the large column rectangle
 * can also win and make the drop marker jump to the end of the column.
 *
 * Detection therefore runs in this order:
 *
 * 1. the column under the pointer;
 * 2. a horizontal gutter inside the card area → the nearest column;
 * 3. outside the columns' vertical content band → no target.
 *
 * Cards deliberately are not droppables. Registering every card made dnd-kit
 * synchronously measure the entire board at activation and publish every
 * hovered card through React context. The board resolves the insertion point
 * from its own visible card nodes only when manual ordering needs it.
 */

import {
  closestCorners,
  pointerWithin,
  type ClientRect,
  type CollisionDetection,
  type DropAnimation,
  type DropAnimationFunctionArguments,
  type Modifier,
} from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { boardRowHeight } from "@/lib/board-windowing";
import { STATUSES } from "@/lib/issue-constants";
import { DRAG_ACTIVATION_DISTANCE } from "@/lib/drag-feel";

const STATUS_IDS = new Set<string>(STATUSES.map((s) => s.value));

/**
 * The app-wide resistance before a card drag starts (lib/drag-feel.ts):
 * enough that a click on a card never turns into a one-pixel drag, short
 * enough that the overlay follows a deliberate move from its first frames.
 */
export const BOARD_MOUSE_ACTIVATION_DISTANCE = DRAG_ACTIVATION_DISTANCE;

const CARD_SELECTOR = "[data-issue-id]";
const COLUMN_SCROLLER_SELECTOR = "[data-board-column-scroller]";
const DRAG_BOUNDS_INSET_PX = 1;
const DROP_COMMIT_TIMEOUT_MS = 500;

type PendingDrop = {
  activeId: string;
  animation: Animation | null;
  animationStarted: boolean;
  committed: Promise<boolean>;
  expectedPosition: number;
  expectedStatus: string;
  finish: () => void;
  hideCommittedTarget: ((node: HTMLElement | undefined) => void) | null;
  resolve: (committed: boolean) => void;
  settled: boolean;
  timeout: ReturnType<typeof setTimeout>;
  visualTarget: BoardDropVisualTarget | null;
};

export type BoardDropVisualTarget = {
  height: number;
  left: number;
  top: number;
  width: number;
};

export type BoardDropAnimationCoordinator = {
  animation: DropAnimation;
  cancel: () => void;
  layoutCommitted: (
    getIssue: (id: string) => {
      id: string;
      position: number;
      status: string;
    } | null,
  ) => void;
  prepare: (
    destination: {
      activeId: string;
      position: number;
      status: string;
      visualTarget?: BoardDropVisualTarget | null;
    },
    onFinish?: () => void,
  ) => void;
};

function findIssueNode(activeId: string, root: ParentNode | null = document) {
  return Array.from(root?.querySelectorAll<HTMLElement>(CARD_SELECTOR) ?? []).find(
    (node) => node.dataset.issueId === activeId,
  );
}

function stripInteractiveIdentity(root: HTMLElement) {
  const nodes = [root, ...root.querySelectorAll<HTMLElement>("*")];
  for (const node of nodes) {
    node.removeAttribute("id");
    node.removeAttribute("data-issue-id");
    node.removeAttribute("tabindex");
    node.removeAttribute("role");
    for (const attribute of Array.from(node.attributes)) {
      if (attribute.name.startsWith("aria-")) {
        node.removeAttribute(attribute.name);
      }
    }
  }
}

/** Capture the already-painted card instead of mounting its interactive tree again. */
export function captureBoardDragPreview(activeId: string, root: ParentNode | null = document) {
  const source = findIssueNode(activeId, root);
  if (!source) return null;
  const preview = source.cloneNode(true) as HTMLElement;
  stripInteractiveIdentity(preview);
  preview.classList.remove("opacity-40");
  preview.classList.add("pointer-events-none");
  // The copy is taken at rest: it wears the held look (hover tint, light
  // shadow) through this mark, as no pointer hovers it (issue-card.tsx).
  preview.querySelector("[data-card-surface]")?.setAttribute("data-held", "");
  return preview.outerHTML;
}

export type BoardDragBounds = Pick<
  ClientRect,
  "left" | "right" | "top" | "bottom"
>;

/** Read the visible card viewport once when the gesture activates. */
export function measureBoardDragBounds(
  board: HTMLElement | null,
): BoardDragBounds | null {
  if (!board) return null;
  const boardRect = board.getBoundingClientRect();
  const columnRects = Array.from(
    board.querySelectorAll<HTMLElement>(COLUMN_SCROLLER_SELECTOR),
    (node) => node.getBoundingClientRect(),
  );
  if (columnRects.length === 0) return boardRect;
  return {
    left: boardRect.left,
    right: boardRect.right,
    top: Math.max(boardRect.top, Math.min(...columnRects.map((r) => r.top))),
    bottom: Math.min(
      boardRect.bottom,
      Math.max(...columnRects.map((r) => r.bottom)),
    ),
  };
}

/**
 * Resolve the already-rendered drop marker to the card rectangle that will
 * occupy its slot. This lets the overlay start landing before React Query
 * publishes the optimistic cache update.
 */
export function measureBoardDropVisualTarget({
  root = document,
  activeId,
  activeIds,
  bounds,
  status,
}: {
  activeId: string;
  activeIds: Iterable<string>;
  root?: ParentNode | null;
  bounds: BoardDragBounds | null;
  status: string;
}): BoardDropVisualTarget | null {
  const source = findIssueNode(activeId, root);
  const column = Array.from(
    root?.querySelectorAll<HTMLElement>(COLUMN_SCROLLER_SELECTOR) ?? [],
  ).find((node) => node.dataset.boardColumnStatus === status);
  const marker = column?.querySelector<HTMLElement>(
    "[data-board-drop-indicator]",
  );
  const anchor = marker?.nextElementSibling as HTMLElement | null;
  if (!source || !column || !marker) return null;

  const sourceRect = source.getBoundingClientRect();
  const columnRect = column.getBoundingClientRect();
  const columnStyle = getComputedStyle(column);
  const gap = Number.parseFloat(columnStyle.rowGap) || 0;
  const previous = marker.previousElementSibling as HTMLElement | null;
  const previousRect = previous?.getBoundingClientRect();
  const anchorRect = anchor?.getBoundingClientRect();
  const paddingLeft = Number.parseFloat(columnStyle.paddingLeft) || 0;
  const paddingRight = Number.parseFloat(columnStyle.paddingRight) || 0;
  const paddingTop = Number.parseFloat(columnStyle.paddingTop) || 0;
  const slotTop =
    anchorRect?.top ??
    (previousRect ? previousRect.bottom + gap : columnRect.top + paddingTop);
  let top = slotTop;
  const orderedIds = Array.from(activeIds);

  // Cards from the target column that sit before the marker leave their old
  // slots before the bundle is inserted, so remove that occupied height.
  for (const issueId of orderedIds) {
    const node = findIssueNode(issueId, root);
    if (!node || node.closest(COLUMN_SCROLLER_SELECTOR) !== column) continue;
    const rect = node.getBoundingClientRect();
    if (rect.top < slotTop) top -= rect.height + gap;
  }

  // The overlay represents the held card, which may not be first in a
  // multi-card bundle. Add the preceding bundle cards back at the new slot.
  const activeIndex = Math.max(0, orderedIds.indexOf(activeId));
  for (const issueId of orderedIds.slice(0, activeIndex)) {
    const rect = findIssueNode(issueId, root)?.getBoundingClientRect();
    top += (rect?.height ?? sourceRect.height) + gap;
  }

  const width =
    anchorRect?.width ||
    previousRect?.width ||
    columnRect.width - paddingLeft - paddingRight ||
    sourceRect.width;
  const height = sourceRect.height;
  let left =
    anchorRect?.left ?? previousRect?.left ?? columnRect.left + paddingLeft;
  if (bounds) {
    left = Math.min(
      Math.max(left, bounds.left + DRAG_BOUNDS_INSET_PX),
      bounds.right - width - DRAG_BOUNDS_INSET_PX,
    );
    top = Math.min(
      Math.max(top, bounds.top + DRAG_BOUNDS_INSET_PX),
      bounds.bottom - height - DRAG_BOUNDS_INSET_PX,
    );
  }
  return { height, left, top, width };
}

/** Measure the exact space occupied by a dragged bundle in its target stack. */
export function measureBoardDropBundleHeight({
  root = document,
  activeIds,
  status,
}: {
  activeIds: Iterable<string>;
  root?: ParentNode | null;
  status: string;
}): number | null {
  const column = Array.from(
    root?.querySelectorAll<HTMLElement>(COLUMN_SCROLLER_SELECTOR) ?? [],
  ).find((node) => node.dataset.boardColumnStatus === status);
  if (!column) return null;
  const columns = Array.from(root?.querySelectorAll<HTMLElement>(COLUMN_SCROLLER_SELECTOR) ?? []);
  const heights = Array.from(
    activeIds,
    (id) => findIssueNode(id, root)?.getBoundingClientRect().height ??
      columns.map((source) => boardRowHeight(source, id)).find((height) => height != null),
  ).filter((height): height is number => height != null && height > 0);
  if (heights.length === 0) return null;
  const gap = Number.parseFloat(getComputedStyle(column).rowGap) || 0;
  return (
    heights.reduce((total, height) => total + height, 0) +
    gap * Math.max(0, heights.length - 1)
  );
}

/** Keep the fixed overlay inside the cached board viewport without layout reads. */
export function createBoardBoundsModifier(bounds: {
  current: BoardDragBounds | null;
}): Modifier {
  return ({ transform, draggingNodeRect }) => {
    const limit = bounds.current;
    if (!limit || !draggingNodeRect) return transform;
    const next = { ...transform };
    const minLeft = limit.left + DRAG_BOUNDS_INSET_PX;
    const maxRight = limit.right - DRAG_BOUNDS_INSET_PX;
    const minTop = limit.top + DRAG_BOUNDS_INSET_PX;
    const maxBottom = limit.bottom - DRAG_BOUNDS_INSET_PX;
    const left = draggingNodeRect.left + next.x;
    const right = draggingNodeRect.right + next.x;
    const top = draggingNodeRect.top + next.y;
    const bottom = draggingNodeRect.bottom + next.y;
    if (left < minLeft) next.x += minLeft - left;
    if (right > maxRight) next.x -= right - maxRight;
    if (top < minTop) next.y += minTop - top;
    if (bottom > maxBottom) next.y -= bottom - maxBottom;
    return next;
  };
}

/**
 * Coordinate dnd-kit's retained overlay with the board's optimistic React
 * commit. React Query schedules subscriber notifications asynchronously, so a
 * fixed number of animation frames can still observe the source node under
 * load. The board explicitly signals the layout effect that measured the
 * destination instead.
 */
export function createBoardDropAnimation(
  getRoot: () => ParentNode | null = () => document,
): BoardDropAnimationCoordinator {
  let pending: PendingDrop | null = null;

  const settle = (entry: PendingDrop | null, committed: boolean) => {
    if (!entry || entry.settled) return;
    entry.settled = true;
    clearTimeout(entry.timeout);
    entry.resolve(committed);
    // With an immediate marker target, the animation owns the visible landing
    // and waits on this promise before releasing its placeholder. It must be
    // the only path that calls `finish`, otherwise the real card can appear
    // while the surrounding FLIP clones are still moving.
    if (entry.visualTarget) {
      if (!committed && !entry.animationStarted) {
        entry.finish();
        if (pending === entry) pending = null;
      }
      return;
    }
    if (committed) {
      entry.timeout = setTimeout(() => {
        entry.finish();
        if (pending === entry) pending = null;
      }, DROP_COMMIT_TIMEOUT_MS);
    } else {
      entry.finish();
      if (pending === entry) pending = null;
    }
  };

  const cancel = () => {
    const entry = pending;
    if (!entry) return;
    clearTimeout(entry.timeout);
    if (!entry.settled) {
      entry.settled = true;
      entry.resolve(false);
    }
    entry.animation?.cancel();
    entry.finish();
    if (pending === entry) pending = null;
  };

  const prepare = (
    destination: {
      activeId: string;
      position: number;
      status: string;
      visualTarget?: BoardDropVisualTarget | null;
    },
    onFinish = () => {},
  ) => {
    cancel();
    let resolve!: (committed: boolean) => void;
    const committed = new Promise<boolean>((done) => {
      resolve = done;
    });
    let finished = false;
    const finish = () => {
      if (finished) return;
      finished = true;
      onFinish();
    };
    const entry = {
      activeId: destination.activeId,
      animation: null,
      animationStarted: false,
      committed,
      expectedPosition: destination.position,
      expectedStatus: destination.status,
      finish,
      hideCommittedTarget: null,
      resolve,
      settled: false,
      timeout: setTimeout(
        () => settle(entry, false),
        destination.visualTarget ? 2_000 : DROP_COMMIT_TIMEOUT_MS,
      ),
      visualTarget: destination.visualTarget ?? null,
    } satisfies PendingDrop;
    pending = entry;
  };

  const layoutCommitted = (
    getIssue: (id: string) => {
      id: string;
      position: number;
      status: string;
    } | null,
  ) => {
    const entry = pending;
    const issue = entry ? getIssue(entry.activeId) : null;
    if (
      !entry ||
      !issue ||
      issue.id !== entry.activeId ||
      issue.status !== entry.expectedStatus ||
      issue.position !== entry.expectedPosition
    ) {
      return;
    }
    entry.hideCommittedTarget?.(findIssueNode(entry.activeId, getRoot()));
    settle(entry, true);
  };

  const animation: DropAnimation = async ({
    active,
    dragOverlay,
    transform,
  }) => {
    const activeId = String(active.id);
    const entry = pending?.activeId === activeId ? pending : null;
    // A cancelled drag, or a drop that moves nothing: the card glides back
    // to where it still stands, dimmed, instead of popping out of sight.
    if (!entry) return returnToOrigin(active.node, dragOverlay.node, transform);
    entry.animationStarted = true;
    const hiddenNodes = new Map<HTMLElement, string>();
    const hide = (node: HTMLElement | undefined) => {
      if (!node || hiddenNodes.has(node)) return;
      hiddenNodes.set(node, node.style.visibility);
      node.style.visibility = "hidden";
    };
    entry.hideCommittedTarget = hide;

    hide(findIssueNode(activeId, getRoot()));
    try {
      const overlayRect = dragOverlay.node.getBoundingClientRect();
      let targetRect = entry.visualTarget;
      if (!targetRect) {
        if (!(await entry.committed)) return;
        const target = findIssueNode(activeId, getRoot());
        hide(target);
        if (!target) return;
        const previousTransform = target.style.transform;
        target.style.transform = "none";
        targetRect = target.getBoundingClientRect();
        target.style.transform = previousTransform;
      }
      const finalTransform = {
        ...transform,
        x: transform.x + targetRect.left - overlayRect.left,
        y: transform.y + targetRect.top - overlayRect.top,
        scaleX:
          overlayRect.width > 0
            ? transform.scaleX * (targetRect.width / overlayRect.width)
            : 1,
        scaleY:
          overlayRect.height > 0
            ? transform.scaleY * (targetRect.height / overlayRect.height)
            : 1,
      };

      if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
        try {
          const landing = dragOverlay.node.animate(
            [
              { transform: CSS.Transform.toString(transform) },
              { transform: CSS.Transform.toString(finalTransform) },
            ],
            {
              duration: 180,
              easing: "cubic-bezier(0.2, 0, 0, 1)",
              fill: "forwards",
            },
          );
          entry.animation = landing;
          await landing.finished;
        } catch {
          // A newer drag or unmount can cancel WAAPI. Visibility still belongs
          // to this coordinator and is restored in the outer finally block.
        }
      }
      if (entry.visualTarget && !entry.settled) {
        await entry.committed;
      }
    } finally {
      for (const [node, visibility] of hiddenNodes) {
        node.style.visibility = visibility;
      }
      clearTimeout(entry.timeout);
      entry.animation = null;
      entry.hideCommittedTarget = null;
      entry.finish();
      if (pending === entry) pending = null;
    }
  };

  return { animation, cancel, layoutCommitted, prepare };
}

async function returnToOrigin(
  origin: HTMLElement | null | undefined,
  overlay: HTMLElement,
  transform: DropAnimationFunctionArguments["transform"],
) {
  if (!origin || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
  const from = overlay.getBoundingClientRect();
  const to = origin.getBoundingClientRect();
  if (to.width === 0 && to.height === 0) return;
  // The card regains its full opacity as the drag ends: hidden while the
  // overlay flies back over it, it reappears as the overlay lands.
  const visibility = origin.style.visibility;
  origin.style.visibility = "hidden";
  try {
    await overlay.animate(
      [
        { transform: CSS.Transform.toString(transform) },
        {
          transform: CSS.Transform.toString({
            ...transform,
            x: transform.x + to.left - from.left,
            y: transform.y + to.top - from.top,
          }),
        },
      ],
      { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)", fill: "forwards" },
    ).finished;
  } catch {
    // A new drag or an unmount cancels the animation.
  } finally {
    origin.style.visibility = visibility;
  }
}

type RectBounds = Pick<ClientRect, "left" | "right" | "top" | "bottom">;

function containsPoint(rect: RectBounds, point: { x: number; y: number }) {
  return (
    point.x >= rect.left &&
    point.x <= rect.right &&
    point.y >= rect.top &&
    point.y <= rect.bottom
  );
}

function columnBounds(
  columns: Parameters<CollisionDetection>[0]["droppableContainers"],
  rects: Parameters<CollisionDetection>[0]["droppableRects"],
): RectBounds | null {
  const measured = columns
    .map((column) => rects.get(column.id))
    .filter((rect): rect is ClientRect => rect != null);
  if (measured.length === 0) return null;
  return {
    left: Math.min(...measured.map((rect) => rect.left)),
    right: Math.max(...measured.map((rect) => rect.right)),
    top: Math.min(...measured.map((rect) => rect.top)),
    bottom: Math.max(...measured.map((rect) => rect.bottom)),
  };
}

function detectBoardCollision(args: Parameters<CollisionDetection>[0]) {
  // Resolve one of the handful of columns. Cards are intentionally absent
  // from the droppable registry, keeping both activation and pointer movement
  // independent of the number of issues on the board.
  const columns = args.droppableContainers.filter((container) =>
    STATUS_IDS.has(String(container.id)),
  );
  const column = pointerWithin({
    ...args,
    droppableContainers: columns,
  })[0];
  const pointer = args.pointerCoordinates;
  const bounds = columnBounds(columns, args.droppableRects);
  if (pointer && bounds && !containsPoint(bounds, pointer)) return [];
  // dnd-kit algorithms return all sorted targets, while consumers only read
  // the first one (`getFirstCollision`). Return only the intended target.
  const nearestColumn =
    column ?? closestCorners({ ...args, droppableContainers: columns })[0];
  if (!nearestColumn) return [];

  return [nearestColumn];
}

export const boardCollision: CollisionDetection = (args) =>
  detectBoardCollision(args);