{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-merge-split", "title": "useMergeSplitBlocks", "description": "React hook + renderer for the selected-background merge/split animation. When one row bridges or splits two contiguous selected runs, their inner edges glide together (or apart) and swap to a single block, instead of one block spring-growing over the union.", "registryDependencies": [ "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/use-proximity-hover.json" ], "files": [ { "path": "src/system/hooks/use-merge-split.tsx", "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"framer-motion\";\nimport { spring } from \"@/lib/springs\";\nimport type { ItemRect } from \"@/hooks/use-proximity-hover\";\n\n// Run the layout effect on the client (where it must fire before paint, so a\n// merge/split shows on the first frame) and a no-op-safe useEffect on the server.\nconst useIsoLayoutEffect =\n typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\n// Edge spring for the selected-bg merge/split: spring.moderate (critically\n// damped) so converging edges meet exactly instead of overshooting. On a merge\n// the inner corners trail by `cornerDelay`, staying rounded until the halves meet.\nconst mergeSpring = spring.moderate;\nconst cornerDelay = 0.07;\n// A boundary resolves after its motion finishes (merge → swap to one block;\n// split → drop), driven by a duration timer rather than onAnimationComplete —\n// framer skips that callback when an animation's target equals its current value\n// (which spam-toggling produces), which would otherwise strand a half. The\n// buffer biases late, by which point the halves have met/parted, so it's unseen.\nconst convergeMs = (mergeSpring.duration + cornerDelay) * 1000 + 80;\nconst splitMs = mergeSpring.duration * 1000 + 80;\n\n// A selected-background block for one render. A run is normally one block; mid\n// merge/split it is drawn as two abutting halves with sharp inner corners.\ntype Rect = { top: number; left: number; width: number; height: number };\nexport interface SelBlock extends Rect {\n key: string;\n radii: [number, number, number, number]; // tl, tr, br, bl\n instant: boolean; // skip the spring (the zero-shift swap, the split snap-in)\n exitInstant: boolean; // drop without the fade (absorbed half at the swap)\n delayCorners: boolean; // trail the corner straightening (merge converge)\n cornerDelay?: number; // optional per-block delay override\n opacity?: number; // override the hover-derived opacity (commit ghost = 0)\n // State a fresh block animates *from* on mount, so it springs into place\n // instead of snapping when continuity is lost (fast toggling) or the block is\n // inherently new (a split's lower half). Continuous blocks ignore it.\n enterFrom?: { top: number; height: number; radii: [number, number, number, number] };\n}\n\n// A contiguous run of selected/checked rows, with a stable id so framer-motion\n// can morph it across renders rather than exit+re-enter.\nexport type Run = { start: number; end: number; id: number };\n\n// One in-flight merge or split; geometry is recomputed from the live runs each\n// render so rapid toggles redirect instead of freezing.\ninterface Boundary {\n tid: number;\n kind: \"merge\" | \"split\";\n survivorId: number; // persisting run (merged run / split's upper run)\n otherId: number; // merge: absorbed run; split: new lower run\n gapIndex: number; // bridging/deselected row — where the halves meet\n phase: \"converge\" | \"commit\" | \"splitIn\" | \"diverge\";\n}\n\n// Two runs within `outer`, ordered, separated by exactly one row (a single-row\n// bridge — the only shape a click can merge or split).\nfunction bridgePair(outer: Run, runs: Run[]) {\n const inside = runs\n .filter((r) => r.start >= outer.start && r.end <= outer.end)\n .sort((a, b) => a.start - b.start);\n if (inside.length !== 2) return null;\n const [up, lo] = inside;\n return lo.start === up.end + 2 ? { up, lo, gap: up.end + 1 } : null;\n}\n\n// ── Merge / split boundary animation ─────────────────────────────\n// When one unselected row bridges two selected runs, their inner edges glide to\n// the bridging row's midpoint (facing corners straightening to sharp), then swap\n// to one block with no visible motion — instead of the surviving block\n// spring-growing over the whole union. Deselecting a middle row plays the\n// inverse: snap into two abutting halves, then glide apart.\n//\n// Given the contiguous selection `runs` (with stable ids), the measured\n// `itemRects`, and the corner radius `R` to round to, this returns the list of\n// background blocks to paint — one per run, or two abutting halves for any run\n// currently mid merge/split. Render them with .\nexport function useMergeSplitBlocks(\n runs: Run[],\n itemRects: ItemRect[],\n R: number\n): SelBlock[] {\n const [boundaries, setBoundaries] = useState([]);\n const prevRunsRef = useRef([]);\n const tidRef = useRef(0);\n const timersRef = useRef(new Map>());\n const runsSig = runs.map((g) => `${g.id}:${g.start}-${g.end}`).join(\"|\");\n\n // Detect merges/splits before paint (so the first frame already shows the\n // halves) and drop any boundary the latest selection invalidated (e.g. the\n // bridge row was toggled again mid-flight).\n useIsoLayoutEffect(() => {\n const prev = prevRunsRef.current;\n const cur = runs;\n const found: Boundary[] = [];\n for (const c of cur) {\n const p = bridgePair(c, prev); // two prev runs collapsed into one\n if (p && (c.id === p.up.id || c.id === p.lo.id))\n found.push({\n tid: ++tidRef.current,\n kind: \"merge\",\n survivorId: c.id,\n otherId: c.id === p.up.id ? p.lo.id : p.up.id,\n gapIndex: p.gap,\n phase: \"converge\",\n });\n }\n for (const p of prev) {\n const c = bridgePair(p, cur); // one prev run split into two\n if (c)\n found.push({\n tid: ++tidRef.current,\n kind: \"split\",\n survivorId: c.up.id,\n otherId: c.lo.id,\n gapIndex: c.gap,\n phase: \"splitIn\",\n });\n }\n prevRunsRef.current = cur.map((r) => ({ ...r }));\n // Resolve each new boundary after its motion window (merge → swap to one\n // block; split → drop), so an interrupted animation can't strand a half.\n for (const b of found) {\n timersRef.current.set(\n b.tid,\n setTimeout(() => {\n timersRef.current.delete(b.tid);\n setBoundaries((bs) =>\n bs.some((x) => x.tid === b.tid)\n ? bs.flatMap((x) =>\n x.tid !== b.tid\n ? [x]\n : x.kind === \"merge\"\n ? [{ ...x, phase: \"commit\" as const }]\n : []\n )\n : bs\n );\n }, b.kind === \"merge\" ? convergeMs : splitMs)\n );\n }\n const stillValid = (b: Boundary) =>\n b.kind === \"merge\"\n ? cur.some(\n (c) =>\n c.id === b.survivorId &&\n b.gapIndex > c.start &&\n b.gapIndex < c.end\n )\n : cur.some((c) => c.id === b.survivorId && c.end === b.gapIndex - 1) &&\n cur.some((c) => c.id === b.otherId && c.start === b.gapIndex + 1);\n setBoundaries((active) => {\n // Cancel the resolve timer of any boundary the latest selection\n // invalidated — otherwise it sits in timersRef until firing as a no-op.\n // Clearing is idempotent, so a double-invoked updater is harmless.\n for (const b of active) {\n if (stillValid(b)) continue;\n const timer = timersRef.current.get(b.tid);\n if (timer !== undefined) {\n clearTimeout(timer);\n timersRef.current.delete(b.tid);\n }\n }\n return [...active.filter(stillValid), ...found];\n });\n }, [runsSig]);\n\n // Clear any pending timers on unmount.\n useEffect(() => {\n const timers = timersRef.current;\n return () => timers.forEach(clearTimeout);\n }, []);\n\n // Follow-up render: a fresh split holds its abutting frame once then\n // diverges; a committed merge is dropped.\n useEffect(() => {\n if (!boundaries.some((b) => b.phase === \"splitIn\" || b.phase === \"commit\"))\n return;\n setBoundaries((bs) =>\n bs.flatMap((b) =>\n b.phase === \"commit\"\n ? []\n : [{ ...b, phase: b.phase === \"splitIn\" ? \"diverge\" : b.phase }]\n )\n );\n }, [boundaries]);\n\n // Build the blocks to paint: one per run, overridden into abutting halves for\n // any run in an in-flight boundary.\n const rectOf = (start: number, end: number): Rect | null => {\n const s = itemRects[start];\n const e = itemRects[end];\n if (!s || !e) return null;\n return {\n top: s.top,\n left: Math.min(s.left, e.left),\n width: Math.max(s.width, e.width),\n height: e.top + e.height - s.top,\n };\n };\n const blocks: SelBlock[] = [];\n for (const run of runs) {\n const r = rectOf(run.start, run.end);\n if (r)\n blocks.push({\n key: `sel-${run.id}`,\n ...r,\n radii: [R, R, R, R],\n instant: false,\n exitInstant: false,\n delayCorners: false,\n });\n }\n const byId = new Map(blocks.map((b) => [b.key, b]));\n for (const b of boundaries) {\n const gap = itemRects[b.gapIndex];\n const sv = byId.get(`sel-${b.survivorId}`);\n if (!gap || !sv) continue;\n const midY = gap.top + gap.height / 2;\n if (b.kind === \"merge\") {\n if (b.phase === \"commit\") {\n // Zero-shift swap: survivor jumps to the full union (already covered by\n // its top half + the absorbed bottom half). The absorbed half is held\n // one render at opacity 0 so removing it next render can't flash a\n // one-frame overlap with the now-full survivor.\n sv.instant = true;\n blocks.push({\n key: `sel-${b.otherId}`,\n top: midY,\n left: sv.left,\n width: sv.width,\n height: sv.top + sv.height - midY,\n radii: [0, 0, R, R],\n instant: true,\n exitInstant: true,\n delayCorners: false,\n opacity: 0,\n });\n continue;\n }\n // converge: survivor → top half, absorbed run → bottom-half ghost, inner\n // corners straightening to sharp.\n // Slightly trail lower merges while keeping a baseline and small cap.\n const mergeCornerDelay = Math.min(\n cornerDelay + 0.03,\n Math.max(cornerDelay, cornerDelay + (midY / Math.max(gap.height, 1)) * 0.002)\n );\n const bottom = sv.top + sv.height;\n sv.height = midY - sv.top;\n sv.radii = [R, R, 0, 0];\n sv.delayCorners = true;\n sv.cornerDelay = mergeCornerDelay;\n blocks.push({\n key: `sel-${b.otherId}`,\n top: midY,\n left: sv.left,\n width: sv.width,\n height: bottom - midY,\n radii: [0, 0, R, R],\n // Mount at full corners so a fresh ghost still animates the\n // straightening with the same delay as the survivor.\n enterFrom: { top: midY, height: bottom - midY, radii: [R, R, R, R] },\n instant: false,\n exitInstant: true,\n delayCorners: true,\n cornerDelay: mergeCornerDelay,\n });\n } else if (b.phase === \"splitIn\") {\n const lo = byId.get(`sel-${b.otherId}`);\n if (!lo) continue;\n // Pin both halves at the seam (identical to the single block); the\n // diverge render then springs them to their real rects.\n const bottom = lo.top + lo.height;\n sv.height = midY - sv.top;\n sv.radii = [R, R, 0, 0];\n sv.instant = true;\n lo.top = midY;\n lo.height = bottom - midY;\n lo.radii = [0, 0, R, R];\n lo.instant = true;\n lo.enterFrom = { top: midY, height: bottom - midY, radii: [0, 0, R, R] };\n }\n // diverge: nothing to override — the steady blocks spring to their real\n // rects from the seam; the timer drops the boundary.\n }\n\n // Split safety net, pinned synchronously. The split boundary above is created\n // in a layout effect that runs *after* this render, so on the very frame a\n // split first appears its fresh lower half would mount at its final rect and\n // snap. Detecting the split here (previous runs vs current) and pinning both\n // halves at the seam guarantees the lower mounts on the seam regardless of\n // render/paint timing (the cause of the rapid-toggle snap).\n for (const p of prevRunsRef.current) {\n const c = bridgePair(p, runs);\n const gap = c && itemRects[c.gap];\n if (!c || !gap) continue;\n const midY = gap.top + gap.height / 2;\n const up = byId.get(`sel-${c.up.id}`);\n const lo = byId.get(`sel-${c.lo.id}`);\n if (!up || !lo) continue;\n const bottom = lo.top + lo.height;\n up.height = midY - up.top;\n up.radii = [R, R, 0, 0];\n up.instant = true;\n lo.top = midY;\n lo.height = bottom - midY;\n lo.radii = [0, 0, R, R];\n lo.instant = true;\n lo.enterFrom = { top: midY, height: bottom - midY, radii: [0, 0, R, R] };\n }\n\n return blocks;\n}\n\n// Renders the selected-background blocks produced by useMergeSplitBlocks — one\n// per run, or two abutting halves mid merge/split. A block's own `opacity`\n// override (e.g. the commit ghost) applies; otherwise blocks render fully\n// opaque. Corners are driven numerically so merge/split can straighten and\n// re-round individual corners.\nexport function SelectionBackgrounds({\n blocks,\n}: {\n blocks: SelBlock[];\n}) {\n return (\n \n {blocks.map((b) => {\n const corner = b.delayCorners\n ? { ...mergeSpring, delay: b.cornerDelay ?? cornerDelay }\n : mergeSpring;\n const opacity = b.opacity ?? 1;\n return (\n \n );\n })}\n \n );\n}\n", "type": "registry:hook", "target": "hooks/use-merge-split.tsx" } ], "type": "registry:hook" }