/** * 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(STATUSES.map((s) => s.value)); /** Display rank of each ticket on the board (columns then cards). */ export function displayRank(columns: { items: Issue[] }[]): Map { const rank = new Map(); 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, issueById: Map, rank: Map ): 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; }): 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 ); }