{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-merge-split",
  "title": "useMergeSplit",
  "description": "Groups a checked-item list's contiguous rows into runs and reports one spring-animated background block per run, so adjacent selections read as one continuous shape that merges and splits instead of separate highlighted rows.",
  "files": [
    {
      "path": "src/hooks/use-merge-split.ts",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef } from \"react\";\n\nimport type { ItemRect } from \"@/hooks/use-proximity-hover\";\n\nexport interface MergeSplitBlock {\n  key: string;\n  indices: number[];\n  top: number;\n  left: number;\n  width: number;\n  height: number;\n  initialRect?: { top: number; left: number; width: number; height: number };\n}\n\nexport interface MergeSplitChange {\n  index: number;\n  rect: Rect;\n  checked: boolean;\n  key: string;\n}\n\nexport interface MergeSplitResult {\n  blocks: MergeSplitBlock[];\n  change: MergeSplitChange | null;\n}\n\ninterface Rect {\n  top: number;\n  left: number;\n  width: number;\n  height: number;\n}\n\ninterface RunRecord {\n  key: string;\n  indices: number[];\n  rect: Rect;\n}\n\nfunction computeRuns(\n  checkedIndices: readonly number[],\n  itemRects: readonly ItemRect[],\n): RunRecord[] {\n  const sorted = [...checkedIndices].toSorted((a, b) => a - b);\n  const groups: number[][] = [];\n  for (const idx of sorted) {\n    const current = groups[groups.length - 1];\n    if (current && idx === current[current.length - 1] + 1) {\n      current.push(idx);\n    } else {\n      groups.push([idx]);\n    }\n  }\n\n  const runs: RunRecord[] = [];\n  for (const indices of groups) {\n    const first = itemRects[indices[0]];\n    const last = itemRects[indices[indices.length - 1]];\n    // Not measured yet (item just registered, layout pending) — skip this\n    // run for now rather than publish a zeroed rect; it appears once\n    // useProximityHover's own measurement pass settles.\n    if (!first || !last) continue;\n    runs.push({\n      key: `run:${indices[0]}-${indices[indices.length - 1]}`,\n      indices,\n      rect: {\n        top: first.top,\n        left: first.left,\n        width: first.width,\n        height: last.top + last.height - first.top,\n      },\n    });\n  }\n  return runs;\n}\n\nfunction claimUniqueKey(preferredKey: string, claimedKeys: Set<string>) {\n  if (!claimedKeys.has(preferredKey)) {\n    claimedKeys.add(preferredKey);\n    return preferredKey;\n  }\n\n  let suffix = 2;\n  while (claimedKeys.has(`${preferredKey}:${suffix}`)) suffix += 1;\n  const key = `${preferredKey}:${suffix}`;\n  claimedKeys.add(key);\n  return key;\n}\n\nfunction reconcile(\n  newRuns: RunRecord[],\n  prevRuns: RunRecord[],\n  originIndex: number | null,\n  itemRects: readonly ItemRect[],\n): MergeSplitBlock[] {\n  const claimedPrimaryKeys = new Set<string>();\n  const outputKeys = new Set<string>();\n\n  return newRuns.map((run) => {\n    const runIndexSet = new Set(run.indices);\n    const overlapping = prevRuns.filter((p) => p.indices.some((i) => runIndexSet.has(i)));\n\n    if (overlapping.length === 0) {\n      // No relationship to any previous run — a brand new isolated block.\n      return { key: claimUniqueKey(run.key, outputKeys), indices: run.indices, ...run.rect };\n    }\n\n    // Prefer the previous run with the most shared indices as the\n    // \"primary\" continuation — the one that keeps its key and springs to\n    // the new extent (this is the growing side of a merge, or the\n    // continuing side of a split). Ties break toward the first found\n    // (lowest starting index, since prevRuns is index-sorted).\n    let primary = overlapping[0];\n    for (const candidate of overlapping) {\n      if (candidate.indices.length > primary.indices.length) primary = candidate;\n    }\n\n    // A click on the hovered gap joins selected runs above and below it.\n    // Preserve that row as the visual origin instead of retaining either\n    // existing run's key, which would make the merged geometry grow from\n    // only one side.\n    const originRect = originIndex === null ? undefined : itemRects[originIndex];\n    const originBridgesRuns =\n      originRect !== undefined &&\n      originIndex !== null &&\n      run.indices.includes(originIndex) &&\n      !prevRuns.some((previous) => previous.indices.includes(originIndex)) &&\n      overlapping.length > 1;\n    if (originBridgesRuns) {\n      // This must be a new Motion element, even if a prior bridge used the\n      // same geometry-derived `:origin` name. Reusing a currently rendered\n      // split piece's key would preserve its one-sided layout state and skip\n      // the center-origin `initialRect` on a repeated bridge.\n      const occupiedKeys = new Set([...prevRuns.map((previous) => previous.key), ...outputKeys]);\n      const key = claimUniqueKey(`${run.key}:origin`, occupiedKeys);\n      outputKeys.add(key);\n      return {\n        key,\n        indices: run.indices,\n        ...run.rect,\n        initialRect: originRect,\n      };\n    }\n\n    // The reverse transition: removing the hovered middle row splits one\n    // selected surface into two. Mount both result blocks from that old\n    // surface, so their inner edges pull apart together and open the gap at\n    // the clicked row rather than continuing from one retained side.\n    const originPreviousRun =\n      originIndex === null\n        ? undefined\n        : prevRuns.find((previous) => previous.indices.includes(originIndex));\n    const originSplitsRun =\n      originPreviousRun !== undefined &&\n      !run.indices.includes(originIndex!) &&\n      newRuns.filter((nextRun) =>\n        nextRun.indices.some((index) => originPreviousRun.indices.includes(index)),\n      ).length > 1;\n    if (originSplitsRun) {\n      const occupiedKeys = new Set([...prevRuns.map((previous) => previous.key), ...outputKeys]);\n      const key = claimUniqueKey(`${run.key}:origin-split`, occupiedKeys);\n      outputKeys.add(key);\n      return {\n        key,\n        indices: run.indices,\n        ...run.rect,\n        initialRect: originPreviousRun.rect,\n      };\n    }\n\n    if (!claimedPrimaryKeys.has(primary.key) && !outputKeys.has(primary.key)) {\n      claimedPrimaryKeys.add(primary.key);\n      outputKeys.add(primary.key);\n      return { key: primary.key, indices: run.indices, ...run.rect };\n    }\n\n    // `primary.key` was already claimed by another new run this pass: one\n    // previous run's indices now span two-plus new runs — a split. This run\n    // is the secondary piece peeling off, so start it from the old shared\n    // rect rather than popping in already at its own smaller size.\n    return {\n      key: claimUniqueKey(run.key, outputKeys),\n      indices: run.indices,\n      ...run.rect,\n      initialRect: primary.rect,\n    };\n  });\n}\n\nexport function useMergeSplit(\n  checkedIndices: readonly number[],\n  itemRects: readonly ItemRect[],\n  originIndex: number | null = null,\n): MergeSplitResult {\n  const prevRunsRef = useRef<RunRecord[]>([]);\n  const checkedKey = useMemo(\n    () => [...checkedIndices].toSorted((a, b) => a - b).join(\",\"),\n    [checkedIndices],\n  );\n\n  const result = useMemo(() => {\n    const newRuns = computeRuns(checkedIndices, itemRects);\n    const blocks = reconcile(newRuns, prevRunsRef.current, originIndex, itemRects);\n    const previousIndices = prevRunsRef.current.flatMap((run) => run.indices);\n    const changedIndices = [...new Set([...previousIndices, ...checkedIndices])].filter(\n      (index) => previousIndices.includes(index) !== checkedIndices.includes(index),\n    );\n    const index = changedIndices.length === 1 ? changedIndices[0] : null;\n    const rect = index === null ? undefined : itemRects[index];\n\n    return {\n      blocks,\n      change:\n        index === null || rect === undefined\n          ? null\n          : {\n              index,\n              rect,\n              checked: checkedIndices.includes(index),\n              key: `${checkedKey}:${index}`,\n            },\n    };\n    // checkedKey/itemRects/originIndex are the real dependencies (checkedIndices is\n    // content-compared via checkedKey since a fresh array reference is\n    // expected on every render).\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [checkedKey, itemRects, originIndex]);\n\n  // Commit the new runs as \"previous\" only after render, never while\n  // computing this render's own value — mutating a ref mid-render isn't\n  // safe under strict-mode's double-invoked renders. Preserve the reconciled\n  // key here, not computeRuns' extent-derived temporary key: the next\n  // grow/shrink must continue the same Motion element rather than remount it.\n  useEffect(() => {\n    prevRunsRef.current = result.blocks.map(({ key, indices, top, left, width, height }) => ({\n      key,\n      indices,\n      rect: { top, left, width, height },\n    }));\n  }, [result.blocks]);\n\n  return result;\n}\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}