{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "middle-truncation",
  "title": "Middle Truncation",
  "author": "ncdai <dai@chanhdai.com>",
  "description": "Truncate text in the middle while preserving start and end.",
  "files": [
    {
      "path": "src/registry/components/middle-truncation/middle-truncation.tsx",
      "content": "\"use client\"\n\nimport React, { useLayoutEffect, useRef, useState } from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nlet cachedCanvas: HTMLCanvasElement | null = null\nlet cachedCtx: CanvasRenderingContext2D | null = null\n\n/**\n * Returns a singleton canvas 2D context for text measurement.\n * Creates the canvas on first call and reuses it for all subsequent calls.\n *\n * @throws {Error} If canvas 2D context creation fails.\n */\nfunction getCanvas(): CanvasRenderingContext2D {\n  if (!cachedCtx) {\n    cachedCanvas = document.createElement(\"canvas\")\n    const ctx = cachedCanvas.getContext(\"2d\")\n    if (!ctx) {\n      throw new Error(\"Failed to get 2d context from canvas\")\n    }\n    cachedCtx = ctx\n  }\n  return cachedCtx\n}\n\nfunction measureText(text: string, font: string) {\n  const ctx = getCanvas()\n  ctx.font = font\n  return ctx.measureText(text).width\n}\n\nfunction getComputedFont(el: HTMLElement) {\n  const cs = window.getComputedStyle(el)\n  return `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`\n}\n\n/**\n * Creates a debounced version of a function that syncs execution with the browser's paint cycle.\n *\n * Combines debouncing (waits for inactivity) with requestAnimationFrame (syncs with browser rendering)\n * to ensure smooth UI updates without jank.\n *\n * @template Args - The argument types of the function.\n * @template Return - The return type of the function (ignored in debounced version).\n * @param fn - The function to debounce.\n * @param delay - Milliseconds to wait before executing after the last call.\n * @returns A debounced version that executes on the next animation frame after the delay.\n *\n * @example\n * const debouncedScroll = debounceWithRAF(handleScroll, 150)\n * window.addEventListener('scroll', debouncedScroll)\n */\nfunction debounceWithRAF<Args extends unknown[], Return = void>(\n  fn: (...args: Args) => Return,\n  delay: number\n): (...args: Args) => void {\n  let timeoutId: ReturnType<typeof setTimeout> | undefined\n  let rafId: number | undefined\n\n  return (...args: Args): void => {\n    if (timeoutId !== undefined) {\n      clearTimeout(timeoutId)\n    }\n    if (rafId !== undefined) {\n      cancelAnimationFrame(rafId)\n    }\n\n    timeoutId = setTimeout(() => {\n      rafId = requestAnimationFrame(() => {\n        fn(...args)\n      })\n    }, delay)\n  }\n}\n\n/**\n * Truncates text in the middle, preserving the start and end portions.\n *\n * Uses binary search to find the optimal truncation point based on pixel width,\n * ensuring the result fits within the container. The truncated text will be in\n * the format: \"start{ellipsis}end\".\n *\n * @param text - The text to truncate.\n * @param end - Fixed number of characters to preserve at the end. Mutually exclusive with minEnd.\n * @param minEnd - Minimum characters at the end when splitting evenly. Mutually exclusive with end.\n * @param containerW - Available width in pixels.\n * @param font - CSS font string for accurate measurement.\n * @param ellipsis - The string to use as separator in the middle.\n * @returns The original text if it fits, otherwise truncated text with ellipsis in the middle.\n *\n * @example\n * // Fixed end: always preserve exactly 4 chars at the end\n * computeTruncated(\"very-long-filename.txt\", 4, undefined, 100, \"16px Arial\", \"...\")\n * // Returns: \"very-long-file...txt\"\n *\n * @example\n * // MinEnd: split evenly, but ensure at least 4 chars at the end\n * computeTruncated(\"document.pdf\", undefined, 4, 100, \"16px Arial\", \"...\")\n * // Returns: \"doc....pdf\" (prioritizes minEnd when width is small)\n *\n * @example\n * // No constraints: split evenly in the middle\n * computeTruncated(\"abcdefghijklmnop\", undefined, undefined, 100, \"16px Arial\", \"...\")\n * // Returns: \"abcd...mnop\"\n */\nfunction computeTruncated(\n  text: string,\n  end: number | undefined,\n  minEnd: number | undefined,\n  containerW: number,\n  font: string,\n  ellipsis: string\n): string {\n  const fullW = measureText(text, font)\n  if (fullW <= containerW) return text\n\n  // Strategy 1: Fixed end (always preserve exactly X chars at the end)\n  if (end !== undefined) {\n    const endStr = text.slice(-end)\n    const endW = measureText(ellipsis + endStr, font)\n    const available = containerW - endW\n\n    let lo = 0\n    let hi = text.length - end\n    while (lo < hi) {\n      const mid = Math.ceil((lo + hi) / 2)\n      if (measureText(text.slice(0, mid), font) <= available) lo = mid\n      else hi = mid - 1\n    }\n\n    return text.slice(0, lo) + ellipsis + endStr\n  }\n\n  // Strategy 2: Split evenly (with optional minEnd constraint)\n  const ellipsisW = measureText(ellipsis, font)\n  const availableForText = containerW - ellipsisW\n\n  let lo = 0\n  let hi = text.length\n  while (lo < hi) {\n    const mid = Math.ceil((lo + hi) / 2)\n\n    let startLen: number\n    let endLen: number\n\n    if (minEnd !== undefined) {\n      endLen = Math.max(Math.ceil(mid / 2), minEnd)\n      startLen = Math.max(0, mid - endLen)\n    } else {\n      startLen = Math.floor(mid / 2)\n      endLen = Math.ceil(mid / 2)\n    }\n\n    const startStr = text.slice(0, startLen)\n    const endStr = text.slice(-endLen)\n    const combinedW = measureText(startStr + endStr, font)\n\n    if (combinedW <= availableForText) lo = mid\n    else hi = mid - 1\n  }\n\n  let startLen: number\n  let endLen: number\n\n  if (minEnd !== undefined) {\n    endLen = Math.max(Math.ceil(lo / 2), minEnd)\n    startLen = Math.max(0, lo - endLen)\n  } else {\n    startLen = Math.floor(lo / 2)\n    endLen = Math.ceil(lo / 2)\n  }\n\n  return text.slice(0, startLen) + ellipsis + text.slice(-endLen)\n}\n\ntype BaseProps = React.ComponentPropsWithoutRef<\"span\"> & {\n  /** The text content to truncate. */\n  children: string\n  /** Custom ellipsis string to show in the middle. @default \"...\" */\n  ellipsis?: string\n}\n\nexport type MiddleTruncationProps = BaseProps &\n  (\n    | {\n        /** Fixed number of characters to always preserve at the end. Cannot be used with minEnd. */\n        end: number\n        minEnd?: never\n      }\n    | {\n        /** When splitting evenly, ensure at least this many characters at the end. Cannot be used with end. */\n        minEnd: number\n        end?: never\n      }\n    | {\n        /** When neither end nor minEnd is provided, splits text evenly in the middle. */\n        end?: never\n        minEnd?: never\n      }\n  )\n\nexport function MiddleTruncation({\n  className,\n  children,\n  end,\n  minEnd,\n  ellipsis = \"...\",\n  ...props\n}: MiddleTruncationProps) {\n  const containerRef = useRef<HTMLSpanElement>(null)\n  const [displayed, setDisplayed] = useState<string>(children)\n\n  useLayoutEffect(() => {\n    const el = containerRef.current\n    if (!el) return\n\n    const recalculate = (width: number) => {\n      const font = getComputedFont(el)\n      setDisplayed(\n        computeTruncated(children, end, minEnd, width, font, ellipsis)\n      )\n    }\n\n    const debouncedRecalculate = debounceWithRAF(recalculate, 150)\n\n    const ro = new ResizeObserver(([entry]) => {\n      debouncedRecalculate(entry.contentRect.width)\n    })\n\n    recalculate(el.offsetWidth)\n    ro.observe(el)\n\n    return () => ro.disconnect()\n  }, [children, end, minEnd, ellipsis])\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"block truncate\", className)}\n      title={children}\n      {...props}\n    >\n      {displayed}\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/middle-truncation.tsx"
    }
  ],
  "docs": "https://chanhdai.com/components/middle-truncation",
  "categories": [
    "typography"
  ],
  "type": "registry:component"
}