{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "middle-truncation", "title": "Middle Truncation", "author": "ncdai ", "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(\n fn: (...args: Args) => Return,\n delay: number\n): (...args: Args) => void {\n let timeoutId: ReturnType | 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(null)\n const [displayed, setDisplayed] = useState(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 \n {displayed}\n \n )\n}\n", "type": "registry:component" } ], "docs": "https://chanhdai.com/components/middle-truncation", "type": "registry:component" }