{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-diff",
  "type": "registry:ui",
  "title": "Text Diff",
  "description": "An animated word- or character-level diff: deletions strike through, insertions type in, inline or side by side with a +12 −5 summary.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/text-diff.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\n\nexport type TextDiffGranularity = \"word\" | \"char\";\nexport type TextDiffMode = \"inline\" | \"split\";\n\nexport interface TextDiffProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The old text. */\n  from: string;\n  /** The new text. */\n  to: string;\n  /** Compare whole words (with spaces and punctuation as their own tokens) or single characters. */\n  granularity?: TextDiffGranularity;\n  /** One merged paragraph, or \"Before\" and \"After\" side by side (stacked in narrow containers). */\n  mode?: TextDiffMode;\n  /** Show insertions and deletions. Off shows the clean new text. The header switch toggles it too. */\n  showChanges?: boolean;\n  /** Animate each new diff: deletions strike through (or collapse), insertions type in. */\n  animate?: boolean;\n  /** Length of the whole transition in ms. */\n  duration?: number;\n  /** Header with the \"+12 −5\" summary, the Show changes switch and a replay button. */\n  toolbar?: boolean;\n  insertColor?: string;\n  deleteColor?: string;\n  beforeLabel?: string;\n  afterLabel?: string;\n}\n\n/* ---------------------------------- diff ---------------------------------- */\n\ntype EditType = \"equal\" | \"insert\" | \"delete\";\ninterface Run {\n  type: EditType;\n  text: string;\n}\n\nconst WORD_TOKENS = /\\s+|[\\p{L}\\p{N}_]+(?:['’-][\\p{L}\\p{N}_]+)*|[^\\s\\p{L}\\p{N}_]/gu;\n/** Past this much work (edit distance × width) the diff degrades to \"replace everything\". */\nconst MAX_WORK = 4_000_000;\n\nfunction tokenize(text: string, granularity: TextDiffGranularity) {\n  return granularity === \"char\" ? Array.from(text) : (text.match(WORD_TOKENS) ?? []);\n}\n\n/** Myers' O((N+M)·D) diff over token arrays, with the common prefix and suffix trimmed first. */\nfunction diff(a: string[], b: string[]): { type: EditType; token: string }[] {\n  let start = 0;\n  while (start < a.length && start < b.length && a[start] === b[start]) start++;\n  let endA = a.length;\n  let endB = b.length;\n  while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {\n    endA--;\n    endB--;\n  }\n  const A = a.slice(start, endA);\n  const B = b.slice(start, endB);\n  const N = A.length;\n  const M = B.length;\n  const max = N + M;\n  const middle: { type: EditType; token: string }[] = [];\n\n  if (max > 0) {\n    const offset = max + 1;\n    const v = new Int32Array(2 * max + 3);\n    const trace: Int32Array[] = [];\n    let found = false;\n    for (let d = 0; d <= max && !found; d++) {\n      if (d * v.length > MAX_WORK) break;\n      trace.push(v.slice());\n      for (let k = -d; k <= d; k += 2) {\n        let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]) ? v[offset + k + 1] : v[offset + k - 1] + 1;\n        let y = x - k;\n        while (x < N && y < M && A[x] === B[y]) {\n          x++;\n          y++;\n        }\n        v[offset + k] = x;\n        if (x >= N && y >= M) {\n          found = true;\n          break;\n        }\n      }\n    }\n    if (!found) {\n      for (const token of A) middle.push({ type: \"delete\", token });\n      for (const token of B) middle.push({ type: \"insert\", token });\n    } else {\n      // Walk the trace backwards to recover the edit script.\n      let x = N;\n      let y = M;\n      for (let d = trace.length - 1; d >= 0; d--) {\n        const row = trace[d];\n        const k = x - y;\n        const prevK = k === -d || (k !== d && row[offset + k - 1] < row[offset + k + 1]) ? k + 1 : k - 1;\n        const prevX = row[offset + prevK];\n        const prevY = prevX - prevK;\n        while (x > prevX && y > prevY) {\n          middle.push({ type: \"equal\", token: A[x - 1] });\n          x--;\n          y--;\n        }\n        if (d > 0) {\n          if (x === prevX) middle.push({ type: \"insert\", token: B[y - 1] });\n          else middle.push({ type: \"delete\", token: A[x - 1] });\n        }\n        x = prevX;\n        y = prevY;\n      }\n      middle.reverse();\n    }\n  }\n\n  return [\n    ...a.slice(0, start).map((token) => ({ type: \"equal\" as const, token })),\n    ...middle,\n    ...a.slice(endA).map((token) => ({ type: \"equal\" as const, token })),\n  ];\n}\n\ntype Block = { kind: \"equal\"; text: string } | { kind: \"change\"; del: string; ins: string };\n\n/**\n * Groups edits into runs and tidies them for reading. An unchanged stretch between two changes is\n * folded into them when it is only whitespace or, at character level, when it is no longer than the\n * changes on either side (so \"colour → color\" stays a one-letter edit, but a rewritten sentence reads\n * as words rather than confetti). Inside every change block deletions come before insertions.\n */\nfunction toRuns(edits: { type: EditType; token: string }[], granularity: TextDiffGranularity): Run[] {\n  const blocks: Block[] = [];\n  for (const e of edits) {\n    const last = blocks[blocks.length - 1];\n    if (e.type === \"equal\") {\n      if (last?.kind === \"equal\") last.text += e.token;\n      else blocks.push({ kind: \"equal\", text: e.token });\n      continue;\n    }\n    let change = last?.kind === \"change\" ? last : null;\n    if (!change) {\n      change = { kind: \"change\", del: \"\", ins: \"\" };\n      blocks.push(change);\n    }\n    if (e.type === \"delete\") change.del += e.token;\n    else change.ins += e.token;\n  }\n\n  const merged: Block[] = [];\n  for (let i = 0; i < blocks.length; i++) {\n    const block = blocks[i];\n    const prev = merged[merged.length - 1];\n    const next = blocks[i + 1];\n    if (block.kind === \"equal\" && prev?.kind === \"change\" && next?.kind === \"change\") {\n      const size = block.text.length;\n      const small =\n        !block.text.trim() ||\n        (granularity === \"char\" && size <= Math.max(prev.del.length, prev.ins.length) && size <= Math.max(next.del.length, next.ins.length));\n      if (small) {\n        prev.del += block.text + next.del;\n        prev.ins += block.text + next.ins;\n        i++;\n        continue;\n      }\n    }\n    merged.push(block);\n  }\n\n  const runs: Run[] = [];\n  for (const block of merged) {\n    if (block.kind === \"equal\") runs.push({ type: \"equal\", text: block.text });\n    else {\n      if (block.del) runs.push({ type: \"delete\", text: block.del });\n      if (block.ins) runs.push({ type: \"insert\", text: block.ins });\n    }\n  }\n  return runs;\n}\n\nfunction countUnits(text: string, granularity: TextDiffGranularity) {\n  return granularity === \"char\" ? Array.from(text.replace(/\\s/g, \"\")).length : (text.match(/[\\p{L}\\p{N}_]+/gu) ?? []).length;\n}\n\n/* -------------------------------- component -------------------------------- */\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst CLIP_OPEN = \"inset(-0.35em 0% -0.35em 0%)\";\nconst CLIP_SHUT = \"inset(-0.35em 100% -0.35em 0%)\";\n\n/**\n * An animated text diff. Computes a word- or character-level Myers diff between two strings and\n * renders it inline or side by side, with insertions underlined and deletions struck through (plus\n * screen-reader labels, so colour is never the only cue). Each new pair animates: deletions strike\n * through (or collapse when changes are hidden) and insertions type in, then a \"+12 −5\" summary.\n */\nexport function TextDiff({\n  from,\n  to,\n  granularity = \"word\",\n  mode = \"inline\",\n  showChanges: showChangesProp = true,\n  animate = true,\n  duration = 900,\n  toolbar = true,\n  insertColor = \"#22A06B\",\n  deleteColor = \"#E5484D\",\n  beforeLabel = \"Before\",\n  afterLabel = \"After\",\n  className = \"\",\n  style,\n  ...rest\n}: TextDiffProps) {\n  const reduced = usePrefersReducedMotion();\n  const runs = React.useMemo(() => toRuns(diff(tokenize(from, granularity), tokenize(to, granularity)), granularity), [from, to, granularity]);\n\n  // Every new pair of strings (or a replay) is a new \"generation\": its change chunks remount and animate.\n  const pairKey = `${granularity}\\u0000${from}\\u0000${to}`;\n  const [generation, setGeneration] = React.useState(0);\n  const [prevPair, setPrevPair] = React.useState(pairKey);\n  if (prevPair !== pairKey) {\n    setPrevPair(pairKey);\n    setGeneration((g) => g + 1);\n  }\n  const [showChanges, setShowChanges] = React.useState(showChangesProp);\n  const [prevShowProp, setPrevShowProp] = React.useState(showChangesProp);\n  if (prevShowProp !== showChangesProp) {\n    setPrevShowProp(showChangesProp);\n    setShowChanges(showChangesProp);\n  }\n\n  const inserted = runs.filter((r) => r.type === \"insert\").reduce((n, r) => n + countUnits(r.text, granularity), 0);\n  const deleted = runs.filter((r) => r.type === \"delete\").reduce((n, r) => n + countUnits(r.text, granularity), 0);\n  const unit = granularity === \"char\" ? \"character\" : \"word\";\n  const plural = (n: number) => `${n} ${unit}${n === 1 ? \"\" : \"s\"}`;\n\n  const play = animate && !reduced && generation > 0;\n  const seconds = duration / 1000;\n  const deleteCount = runs.filter((r) => r.type === \"delete\").length;\n  const insertCount = runs.filter((r) => r.type === \"insert\").length;\n  // Deletions take the first ~40% of the transition, insertions type in across the rest.\n  const chunkIn = Math.min(0.32, seconds * 0.3);\n  const insertStagger = insertCount > 1 ? Math.min(0.09, (seconds * 0.6 - chunkIn) / (insertCount - 1)) : 0;\n  const deleteStagger = deleteCount > 1 ? Math.min(0.06, (seconds * 0.35) / (deleteCount - 1)) : 0;\n\n  const renderRuns = (pane: \"inline\" | \"before\" | \"after\") => {\n    let delIndex = 0;\n    let insIndex = 0;\n    return runs.map((run, i) => {\n      if (run.type === \"equal\") return <React.Fragment key={`e-${i}`}>{run.text}</React.Fragment>;\n      const isDelete = run.type === \"delete\";\n      if ((pane === \"before\" && !isDelete) || (pane === \"after\" && isDelete)) return null;\n      const order = isDelete ? delIndex++ : insIndex++;\n      const chunks = run.text.match(/\\S+\\s*|\\s+/g) ?? [run.text];\n      // Inline view with changes hidden: deletions collapse away and are hidden from assistive tech.\n      const collapsed = isDelete && pane === \"inline\" && !showChanges;\n      const Tag = isDelete ? \"del\" : \"ins\";\n      const delay = isDelete ? order * deleteStagger : seconds * 0.35 + order * insertStagger;\n      const color = isDelete ? deleteColor : insertColor;\n      const marked = showChanges;\n\n      return (\n        <Tag\n          key={`${generation}-${i}`}\n          aria-hidden={collapsed || undefined}\n          // A hairline gap keeps \"old\" and \"new\" apart when a deletion is directly followed by its replacement.\n          className={`no-underline transition-[margin] duration-300 ${pane === \"inline\" && marked && isDelete && runs[i + 1]?.type === \"insert\" ? \"mr-[0.3em]\" : \"\"}`}\n        >\n          {marked ? <span className=\"sr-only\">{isDelete ? \"[deleted: \" : \"[inserted: \"}</span> : null}\n          {chunks.map((chunk, c) => (\n            <motion.span\n              key={c}\n              className={`inline-block whitespace-pre transition-colors duration-300 ${\n                isDelete\n                  ? `line-through decoration-[0.09em] ${marked ? \"[background:linear-gradient(color-mix(in_srgb,var(--td-del)_14%,transparent),color-mix(in_srgb,var(--td-del)_14%,transparent))_0_50%/100%_1.4em_no-repeat] text-zinc-500 dark:text-zinc-400\" : \"\"}`\n                  : `underline decoration-[0.09em] underline-offset-[0.22em] ${marked ? \"[background:linear-gradient(color-mix(in_srgb,var(--td-ins)_15%,transparent),color-mix(in_srgb,var(--td-ins)_15%,transparent))_0_50%/100%_1.4em_no-repeat]\" : \"\"}`\n              }`}\n              initial={\n                play\n                  ? isDelete\n                    ? { textDecorationColor: \"rgba(0,0,0,0)\", width: \"auto\", opacity: 1, clipPath: CLIP_OPEN }\n                    : { width: 0, opacity: 0, clipPath: CLIP_SHUT, textDecorationColor: marked ? color : \"rgba(0,0,0,0)\" }\n                  : false\n              }\n              animate={\n                isDelete\n                  ? collapsed\n                    ? { width: 0, opacity: 0, clipPath: CLIP_OPEN, textDecorationColor: color }\n                    : { width: \"auto\", opacity: 1, clipPath: CLIP_OPEN, textDecorationColor: marked ? color : \"rgba(0,0,0,0)\" }\n                  : { width: \"auto\", opacity: 1, clipPath: CLIP_OPEN, textDecorationColor: marked ? color : \"rgba(0,0,0,0)\" }\n              }\n              transition={\n                reduced || !animate\n                  ? { duration: 0 }\n                  : {\n                      duration: isDelete ? Math.min(0.4, seconds * 0.4) : chunkIn,\n                      delay: play ? delay + c * 0.02 : 0,\n                      ease: [0.22, 1, 0.36, 1],\n                    }\n              }\n            >\n              {chunk}\n            </motion.span>\n          ))}\n          {marked ? <span className=\"sr-only\">]</span> : null}\n        </Tag>\n      );\n    });\n  };\n\n  const text = \"text-[15px] leading-[1.75] text-zinc-800 dark:text-zinc-200\";\n  const paneLabel = \"mb-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\";\n\n  return (\n    <div\n      {...rest}\n      className={`@container w-full min-w-0 rounded-2xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-[#17191c] ${className}`}\n      style={{ ...style, [\"--td-ins\" as string]: insertColor, [\"--td-del\" as string]: deleteColor }}\n    >\n      {toolbar ? (\n        <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-zinc-900/10 px-4 py-2.5 dark:border-white/10\">\n          <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            Diff · {granularity === \"char\" ? \"chars\" : \"words\"}\n          </span>\n          <span className=\"flex items-center gap-2 font-mono text-[11px] tabular-nums\" aria-hidden=\"true\">\n            <span style={{ color: insertColor }}>+{inserted}</span>\n            <span style={{ color: deleteColor }}>−{deleted}</span>\n          </span>\n          <span className=\"sr-only\">{`${plural(inserted)} inserted, ${plural(deleted)} deleted`}</span>\n          <span className=\"ml-auto flex items-center gap-1\">\n            <button\n              type=\"button\"\n              role=\"switch\"\n              aria-checked={showChanges}\n              onClick={() => setShowChanges((s) => !s)}\n              className=\"group flex h-7 items-center gap-2 rounded-md px-2 text-[12px] text-zinc-600 outline-hidden transition-colors hover:bg-zinc-900/[0.05] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--td-ins)] dark:text-zinc-300 dark:hover:bg-white/[0.07]\"\n            >\n              <span\n                aria-hidden=\"true\"\n                className=\"relative h-4 w-7 rounded-full bg-zinc-900/15 transition-colors group-aria-checked:bg-[var(--td-ins)] dark:bg-white/15\"\n              >\n                <span className=\"absolute left-0.5 top-0.5 size-3 rounded-full bg-white shadow-sm transition-transform group-aria-checked:translate-x-3\" />\n              </span>\n              Show changes\n            </button>\n            {animate ? (\n              <button\n                type=\"button\"\n                aria-label=\"Replay transition\"\n                title=\"Replay\"\n                onClick={() => setGeneration((g) => g + 1)}\n                className=\"grid size-7 place-items-center rounded-md text-zinc-500 outline-hidden transition-colors hover:bg-zinc-900/[0.05] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--td-ins)] dark:text-zinc-400 dark:hover:bg-white/[0.07] dark:hover:text-white\"\n              >\n                <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                  <path d=\"M2.5 8a5.5 5.5 0 1 0 1.6-3.9\" />\n                  <path d=\"M2.5 2.5v3h3\" />\n                </svg>\n              </button>\n            ) : null}\n          </span>\n        </div>\n      ) : null}\n\n      {mode === \"split\" ? (\n        <div className=\"grid grid-cols-1 @lg:grid-cols-2\">\n          <div className=\"min-w-0 border-b border-zinc-900/10 px-5 py-4 @lg:border-b-0 @lg:border-r dark:border-white/10\">\n            <p className={paneLabel}>{beforeLabel}</p>\n            <p className={text}>{renderRuns(\"before\")}</p>\n          </div>\n          <div className=\"min-w-0 px-5 py-4\">\n            <p className={paneLabel}>{afterLabel}</p>\n            <p className={text}>{renderRuns(\"after\")}</p>\n          </div>\n        </div>\n      ) : (\n        <p className={`px-5 py-4 ${text}`}>{renderRuns(\"inline\")}</p>\n      )}\n\n      {/* Announces the summary of each new diff. */}\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {generation > 0 ? `${plural(inserted)} inserted, ${plural(deleted)} deleted` : \"\"}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/text-diff"
}
