Ctrl K
ComunidadGuías…

lib/board-drag.ts

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

Archivo original ↗Preguntar a KiBot
/**
 * What a drag and drop moves on a board, when a selection is in progress.
 *
 * Two rules, and they fit in one sentence each:
 *
 * - **We ship the selection** as soon as the card entered is part of it. Enter
 * a card OUTSIDE the selection moves only it — the selection stays where it is,
 * like on a desk: we do not lose thirty sorted tickets because we have
 * grabbed the thirty-first.
 *
 * - **The deck keeps its reading order.** The tickets land in
 * the order in which they were on the screen (columns from left to right, cards from top
 * to bottom), not in the order in which the selection `Set` encountered them — which,
 * depends on the order of ⇧-clicks and wants nothing say.
 *
 * The positions are calculated only once for the entire packet: we divide
 * the interval between the two neighbors of the drop-off point into as many parts as there are
 * tickets. Passing them one by one through the calculation to two neighbors the
 * would all stack on the same value.
 *
 * And a third, which is not position calculation but its counterpart to
 * the screen: **we show the arrival point, we cannot guess it.** `previewBoardMove`
 * derives the drop mark from ALREADY planned moves, then replays the sort
 * display of the target column. The line we see while dragging is therefore
 * calculated by the same code as the writing that follows — including when the column
 * is sorted by priority or by date, where the card does not land under the cursor
 * but where the sort puts it.
 */

import { STATUSES, type IssueStatus } from "@/lib/issue-constants";
import type { Issue } from "@/lib/types";

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

/** Display rank of each ticket on the board (columns then cards). */
export function displayRank(columns: { items: Issue[] }[]): Map<string, number> {
  const rank = new Map<string, number>();
  let n = 0;
  for (const column of columns) {
    for (const issue of column.items) rank.set(issue.id, n++);
  }
  return rank;
}

/**
 * The tickets that a swipe carries: the selection if the card entered is part of it, this card alone otherwise. Rendered in board display order.
 */
export function dragBundle(
  activeId: string,
  selectedIds: Set<string>,
  issueById: Map<string, Issue>,
  rank: Map<string, number>
): Issue[] {
  const active = issueById.get(activeId);
  if (!active) return [];
  if (!selectedIds.has(activeId) || selectedIds.size < 2) return [active];
  const bundle = Array.from(selectedIds, (id) => issueById.get(id)).filter(
    (issue): issue is Issue => issue !== undefined
  );
  return bundle.sort(
    (a, b) => (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0)
  );
}

/** The rectangle of a drag item, collapsed to whatever the insertion point reads. */
export interface DragRect {
  top: number;
  height: number;
}

/** Where the deposit would land, as the current gesture designates. */
export interface DropTarget {
  status: IssueStatus;
  /** The hovered card, `null` when it is the bottom of the column. */
  overIssueId: string | null;
  /** The insertion point is AFTER the hovered map, not before. */
  after: boolean;
}

/**
 * What the gesture designates: a column, a card, and which side of it
 * card.
 *
 * **The side is the half that was missing.** Without it, hovering over a card would always say "before it" — the last place in a column becomes
 * unattainable other than by aiming at the gap under the last card, and the
 * deposit marker jumps a notch from what the hand is aiming for. We compare
 * therefore the CENTERS: that of the held card (the original rectangle translated
 * by dragging, what dnd-kit calls `active.rect.current.translated`) against
 * that of the hovered card.
 */
export function readDropTarget({
  overId,
  overRect,
  activeRect,
  issueById,
}: {
  overId: string | number | null | undefined;
  overRect: DragRect | null | undefined;
  activeRect: DragRect | null | undefined;
  issueById: Map<string, Issue>;
}): DropTarget | null {
  if (overId == null) return null;
  const id = String(overId);
  if (STATUS_IDS.has(id)) {
    return { status: id as IssueStatus, overIssueId: null, after: false };
  }
  const over = issueById.get(id);
  if (!over) return null;
  const after =
    !!activeRect &&
    !!overRect &&
    activeRect.top + activeRect.height / 2 >= overRect.top + overRect.height / 2;
  return { status: over.status, overIssueId: over.id, after };
}

/** `count` positions distributed between the two neighbors of the insertion point. */
function spreadPositions(
  before: Issue | undefined,
  after: Issue | undefined,
  count: number
): number[] {
  if (!before && !after) return Array.from({ length: count }, (_, k) => k);
  if (!before) return Array.from({ length: count }, (_, k) => after!.position - count + k);
  if (!after) return Array.from({ length: count }, (_, k) => before.position + 1 + k);
  const step = (after.position - before.position) / (count + 1);
  return Array.from({ length: count }, (_, k) => before.position + step * (k + 1));
}

export interface PlannedMove {
  issue: Issue;
  patch: { status?: IssueStatus; position: number };
}

/**
 * The move to write for each ticket in the package. Blank = nothing to do
 * (deposit without effect), in which case the caller writes nothing at all.
 */
export function planBoardMove({
  bundle,
  targetStatus,
  overIssueId,
  dropAfter = false,
  columnItems,
  manual,
  now,
}: {
  /** The slipped packet, in the order it should land. */
  bundle: Issue[];
  targetStatus: IssueStatus;
  /** The card hovered over the deposit, `null` if dropped at the bottom of the column. */
  overIssueId: string | null;
  /** The deposit targets the LOWER half of the hovered card: we insert
 after it (see `readDropTarget`). Without it, the end of a column is unattainable. */
  dropAfter?: boolean;
  /** The entire target column, sorted by position — including the package. */
  columnItems: Issue[];
  /** Manual sorting: only case where the order in a column is reordered. */
  manual: boolean;
  /** Base timestamp for sorting by field (position there is cosmetic). */
  now: number;
}): PlannedMove[] {
  const moving = manual
    ? bundle
    : // Except for manual sorting, the order of a column is derived from a field: a ticket
      // already in the target column has nothing to change.
      bundle.filter((issue) => issue.status !== targetStatus);
  if (moving.length === 0) return [];

  if (!manual) {
    return moving.map((issue, k) => ({
      issue,
      patch: { status: targetStatus, position: now + k },
    }));
  }

  const movingIds = new Set(moving.map((i) => i.id));
  const rest = columnItems.filter((i) => !movingIds.has(i.id));
  // The insertion point is read on the COMPLETE column (the hovered map can
  // be part of the package), then translates into an index among the remaining tickets.
  const overIndex = overIssueId
    ? columnItems.findIndex((i) => i.id === overIssueId)
    : -1;
  let index: number;
  if (overIndex < 0) {
    index = rest.length;
  } else {
    const before = columnItems
      .slice(0, overIndex)
      .filter((i) => !movingIds.has(i.id)).length;
    // “After” only makes sense if the hovered card remains in place: a card
    // of the package does not count as a neighbor, she leaves with it.
    index = before + (dropAfter && !movingIds.has(overIssueId!) ? 1 : 0);
  }

  const positions = spreadPositions(rest[index - 1], rest[index], moving.length);
  return moving.map((issue, k) => ({
    issue,
    patch:
      issue.status === targetStatus
        ? { position: positions[k] }
        : { status: targetStatus, position: positions[k] },
  }));
}

/** A reorder step offered from a card's menu, the keyboard twin of a drag. */
export type KeyboardMoveDirection = "up" | "down" | "top";

/**
 * The move a card's "Move up", "Move down" or "Move to top" entry writes
 * (WCAG 2.5.7): one card, in its own column, under manual sort. It reads its
 * neighbor and hands the result to `planBoardMove`, so the menu and a drop on
 * that neighbor write the same position. `null` = the card is already there.
 */
export function planKeyboardMove({
  issue,
  direction,
  columnItems,
}: {
  issue: Issue;
  direction: KeyboardMoveDirection;
  /** The card's column, sorted by position, as the board shows it. */
  columnItems: Issue[];
}): PlannedMove | null {
  const index = columnItems.findIndex((i) => i.id === issue.id);
  if (index < 0) return null;
  const target =
    direction === "down" ? columnItems[index + 1] : direction === "up" ? columnItems[index - 1] : columnItems[0];
  if (!target || target.id === issue.id) return null;
  const [move] = planBoardMove({
    bundle: [issue],
    targetStatus: issue.status,
    overIssueId: target.id,
    dropAfter: direction === "down",
    columnItems,
    manual: true,
    now: 0,
  });
  return move ?? null;
}

/** The deposit mark to draw: a line, in a column, in a place. */
export interface DropPreview {
  status: IssueStatus;
  /** The line is drawn BEFORE this card; `null` = at the end of the column. */
  beforeIssueId: string | null;
  /** How many tickets will land there (the packet). */
  count: number;
}

/**
 * Where the package will land, such that the column WILL DISPLAY it.
 *
 * We do not rededuce anything from the cursor: we apply the already planned patches to a
 * copy of the target column, we replay its display sort, and we read the place
 * obtained. This is what makes the mark fair in a column sorted by priority
 * or by date, where the drop point owes nothing to the cursor — the ticket falls there
 * where the field stores it, sometimes ten cards from the targeted point.
 *
 * The line is anchored on a card THAT REMAINS: under manual sorting, the deck is
 * still displayed in the column while dragging, and anchoring to it would cause
 * to run the marker behind its own target.
 *
 * `null` = nothing to show, because there is nothing to write (drop without effect).
 */
export function previewBoardMove({
  moves,
  displayItems,
  comparator,
}: {
  /** Moves planned by `planBoardMove` — empty, there is no marker. */
  moves: PlannedMove[];
  /** The target column as it is displayed (so already sorted for the screen). */
  displayItems: Issue[];
  /** The display sort of this column. */
  comparator: (a: Issue, b: Issue) => number;
}): DropPreview | null {
  if (moves.length === 0) return null;
  const movingIds = new Set(moves.map((m) => m.issue.id));
  const status = moves[0].patch.status ?? moves[0].issue.status;
  const rest = displayItems.filter((i) => !movingIds.has(i.id));
  // The sorting is stable: for equal value, a card in place keeps pace with one
  // card that arrives — the marker is therefore placed AFTER its ties, like sorting.
  const projected = [
    ...rest,
    ...moves.map((m) => ({ ...m.issue, ...m.patch }) as Issue),
  ].sort(comparator);

  const landing = projected.findIndex((i) => movingIds.has(i.id));
  if (landing < 0) return null;
  const restBefore = projected
    .slice(0, landing)
    .filter((i) => !movingIds.has(i.id)).length;
  return {
    status,
    beforeIssueId: rest[restBefore]?.id ?? null,
    count: moves.length,
  };
}

/** Two identical marks — to only re-set the board when it moves. */
export function sameDropPreview(
  a: DropPreview | null,
  b: DropPreview | null
): boolean {
  if (a === b) return true;
  if (!a || !b) return false;
  return (
    a.status === b.status &&
    a.beforeIssueId === b.beforeIssueId &&
    a.count === b.count
  );
}