diff --git a/apps/web/package.json b/apps/web/package.json index 6a27357..e18001a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,6 +41,7 @@ "react-hook-form": "^7.60.0", "recharts": "3.8.0", "shadcn": "^4.19.0", + "shiki": "^4.4.3", "sonner": "^1.7.0", "zod": "^3.25.0" }, diff --git a/apps/web/src/components/reui/cascader/cascader-nav.tsx b/apps/web/src/components/reui/cascader/cascader-nav.tsx index b05a0c8..8950828 100644 --- a/apps/web/src/components/reui/cascader/cascader-nav.tsx +++ b/apps/web/src/components/reui/cascader/cascader-nav.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { useCascaderActions, diff --git a/apps/web/src/components/reui/cascader/cascader-virtual.tsx b/apps/web/src/components/reui/cascader/cascader-virtual.tsx index d5b1dd5..38054ad 100644 --- a/apps/web/src/components/reui/cascader/cascader-virtual.tsx +++ b/apps/web/src/components/reui/cascader/cascader-virtual.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns" import { @@ -81,7 +79,8 @@ export function useCascaderVirtualizer({ const measureEstimate = React.useCallback(() => estimateSize, [estimateSize]) - // React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one. + // React Compiler bails on `useVirtualizer` HERE, and only here - the bail does not + // propagate to the components that call this hook. They opt out themselves. const virtualizer = useVirtualizer({ count, getScrollElement, @@ -225,6 +224,11 @@ function CascaderVirtualRows({ estimateSize, overscan, }: CascaderVirtualItemsProps) { + /* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches + `getVirtualItems()` on the virtualizer, whose identity never changes, so the + window freezes on scroll. Inert where no compiler runs. */ + "use no memo" + const { estimateRowSize, overscan: rootOverscan, @@ -375,6 +379,11 @@ function CascaderVirtualColumnRows({ overscan, activeIndex, }: CascaderVirtualColumnProps & { activeIndex: number }) { + /* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches + `getVirtualItems()` on the virtualizer, whose identity never changes, so the + window freezes on scroll. Inert where no compiler runs. */ + "use no memo" + const { estimateRowSize, overscan: rootOverscan, diff --git a/apps/web/src/components/reui/cascader/cascader.tsx b/apps/web/src/components/reui/cascader/cascader.tsx index 751f644..96bfcff 100644 --- a/apps/web/src/components/reui/cascader/cascader.tsx +++ b/apps/web/src/components/reui/cascader/cascader.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { useCascaderLoader, diff --git a/apps/web/src/components/reui/code-block/code-block-highlight.tsx b/apps/web/src/components/reui/code-block/code-block-highlight.tsx new file mode 100644 index 0000000..4830d51 --- /dev/null +++ b/apps/web/src/components/reui/code-block/code-block-highlight.tsx @@ -0,0 +1,1159 @@ +import type { ReactNode } from "react" +import type { ShikiTransformer } from "shiki" + +/** + * One themed slice of a line. `color`/`colorDark` emit as `--cb-c`/`--cb-cd` + * custom properties, so one rule pair on the `
` theme-switches the whole
+ * block. An uncoloured token keeps both undefined and renders as a bare text
+ * node with no wrapper.
+ */
+export type CodeBlockToken = {
+  content: string
+  color?: string
+  colorDark?: string
+  fontStyle?: CodeBlockFontStyle
+  word?: boolean
+}
+
+export type CodeBlockFontStyle = "italic" | "bold" | "underline"
+
+export type CodeBlockDiffKind = "add" | "remove"
+
+export type CodeBlockLevel = "error" | "warning" | "info"
+
+/**
+ * Per-line presentation state, resolved once at highlight time.
+ *
+ * Two sources merge here: the props on the root, and the classes a shiki
+ * transformer put on the line, so `@shikijs/transformers` notation such as
+ * `[!code ++]` reaches the same place without the renderer knowing shiki
+ * exists. Props win on conflict, because notation lives in the source string,
+ * which a consumer often does not control.
+ */
+export type CodeBlockLineState = {
+  highlighted?: boolean
+  diff?: CodeBlockDiffKind
+  focused?: boolean
+  level?: CodeBlockLevel
+}
+
+/**
+ * A rendered line. `number` is the DISPLAYED number (offset by `startLine`);
+ * line specs address source lines, so changing `startLine` never invalidates
+ * them. Instances are reused across passes when unchanged, which is what lets
+ * the row be a plain `React.memo` and a streamed chunk cost one re-render.
+ */
+export type CodeBlockLine = {
+  tokens: CodeBlockToken[]
+  number: number
+  text: string
+  state?: CodeBlockLineState
+  /**
+   * Replaces the counter-driven gutter number for this row - a unified patch
+   * shows "old new" pairs, a hunk header shows dots. Rendered verbatim.
+   */
+  gutter?: string
+}
+
+/** Source lines, as `[2, 3, 4]` or as a range string such as `"2-4,7"`. */
+export type CodeBlockLineSpec = number[] | string
+
+export type CodeBlockDiffSpec = {
+  added?: CodeBlockLineSpec
+  removed?: CodeBlockLineSpec
+}
+
+export type CodeBlockLevelSpec = Partial<
+  Record
+>
+
+/** A word to mark, optionally restricted to some source lines. */
+export type CodeBlockWordSpec =
+  | string
+  | { word: string; lines?: CodeBlockLineSpec }
+
+export type CodeBlockThemes = { light: string; dark: string }
+
+/**
+ * shiki's own transformer type, imported type-only so it erases at compile
+ * time and costs the bundle nothing. The previous structural stand-in
+ * (`Record`) rejected every real `ShikiTransformer`: an
+ * interface without an index signature is not assignable to it.
+ */
+export type CodeBlockTransformer = ShikiTransformer
+
+export type CodeBlockHighlightOptions = {
+  language?: string
+  themes?: CodeBlockThemes
+  transformers?: CodeBlockTransformer[]
+  startLine?: number
+  /**
+   * Distinguishes same-signature blocks in the line-reuse cache. Without it,
+   * two identically-configured streams evict each other's previous document.
+   * The root passes its own instance id.
+   */
+  instanceKey?: string
+  highlightedLines?: CodeBlockLineSpec
+  highlightedWords?: CodeBlockWordSpec[]
+  focusedLines?: CodeBlockLineSpec
+  diff?: CodeBlockDiffSpec
+  lineLevels?: CodeBlockLevelSpec
+}
+
+/** What a `CodeBlockLineActions` render prop receives. */
+export type CodeBlockLineActionContext = {
+  line: number
+  text: string
+  state?: CodeBlockLineState
+}
+
+export type CodeBlockLineActionsRender = (
+  context: CodeBlockLineActionContext
+) => ReactNode
+
+/* -------------------------------------------------------------------------- */
+/*                                  Languages                                  */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * A STATIC map, never ``import(`shiki/langs/${lang}.mjs`)``: a template import
+ * makes bundlers bundle all ~200 grammars. Written out, each entry is its own
+ * lazy chunk. To add a language, add a line: that is the intended extension
+ * point of this file.
+ */
+export const codeBlockLanguages: Record Promise> = {
+  bash: () => import("shiki/langs/bash.mjs"),
+  c: () => import("shiki/langs/c.mjs"),
+  cpp: () => import("shiki/langs/cpp.mjs"),
+  csharp: () => import("shiki/langs/csharp.mjs"),
+  css: () => import("shiki/langs/css.mjs"),
+  diff: () => import("shiki/langs/diff.mjs"),
+  docker: () => import("shiki/langs/docker.mjs"),
+  go: () => import("shiki/langs/go.mjs"),
+  graphql: () => import("shiki/langs/graphql.mjs"),
+  html: () => import("shiki/langs/html.mjs"),
+  java: () => import("shiki/langs/java.mjs"),
+  javascript: () => import("shiki/langs/javascript.mjs"),
+  json: () => import("shiki/langs/json.mjs"),
+  jsx: () => import("shiki/langs/jsx.mjs"),
+  kotlin: () => import("shiki/langs/kotlin.mjs"),
+  markdown: () => import("shiki/langs/markdown.mjs"),
+  php: () => import("shiki/langs/php.mjs"),
+  python: () => import("shiki/langs/python.mjs"),
+  ruby: () => import("shiki/langs/ruby.mjs"),
+  rust: () => import("shiki/langs/rust.mjs"),
+  scss: () => import("shiki/langs/scss.mjs"),
+  shell: () => import("shiki/langs/shellscript.mjs"),
+  sql: () => import("shiki/langs/sql.mjs"),
+  swift: () => import("shiki/langs/swift.mjs"),
+  toml: () => import("shiki/langs/toml.mjs"),
+  tsx: () => import("shiki/langs/tsx.mjs"),
+  typescript: () => import("shiki/langs/typescript.mjs"),
+  vue: () => import("shiki/langs/vue.mjs"),
+  yaml: () => import("shiki/langs/yaml.mjs"),
+}
+
+/** Spellings a consumer is likely to pass, mapped onto the map above. */
+const LANGUAGE_ALIASES: Record = {
+  "c++": "cpp",
+  "c#": "csharp",
+  cs: "csharp",
+  dockerfile: "docker",
+  htm: "html",
+  js: "javascript",
+  jsonc: "json",
+  md: "markdown",
+  mdx: "markdown",
+  py: "python",
+  rb: "ruby",
+  rs: "rust",
+  sh: "shell",
+  shellscript: "shell",
+  ts: "typescript",
+  yml: "yaml",
+  zsh: "shell",
+}
+
+export const codeBlockThemes: Record Promise> = {
+  "github-light": () => import("shiki/themes/github-light.mjs"),
+  "github-dark": () => import("shiki/themes/github-dark.mjs"),
+  /**
+   * Design-token theming: every token colour becomes a `var(--code-token-*)`
+   * reference, so the palette lives in the consumer's stylesheet and follows
+   * their themes. Pass as BOTH sides. Variables consumed (prefix `--code-`):
+   * foreground, token-constant, token-string, token-comment, token-keyword,
+   * token-parameter, token-function, token-string-expression,
+   * token-punctuation, token-link.
+   */
+  "css-variables": async () => {
+    const { createCssVariablesTheme } = await import("shiki/core")
+    return createCssVariablesTheme({
+      name: "css-variables",
+      variablePrefix: "--code-",
+      fontStyle: true,
+    })
+  },
+}
+
+export const DEFAULT_CODE_BLOCK_THEMES: CodeBlockThemes = {
+  light: "github-light",
+  dark: "github-dark",
+}
+
+/** Resolves an alias and reports whether the grammar is actually available. */
+export function resolveCodeBlockLanguage(
+  language?: string
+): string | undefined {
+  if (!language) return undefined
+  const normalized = language.trim().toLowerCase()
+  const resolved = LANGUAGE_ALIASES[normalized] ?? normalized
+  return resolved in codeBlockLanguages ? resolved : undefined
+}
+
+/* -------------------------------------------------------------------------- */
+/*                                Pure helpers                                 */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Turns `[2, 3]` or `"2-4,7"` into a set of source line numbers.
+ *
+ * Deliberately total: a reversed range, a negative bound or outright garbage
+ * yields an empty set rather than throwing. These values often come from user
+ * content or from a model, and a code viewer that crashes on a bad range is
+ * worse than one that shows no highlight.
+ */
+export function parseLineSpec(spec?: CodeBlockLineSpec): Set {
+  const out = new Set()
+  if (!spec) return out
+
+  if (Array.isArray(spec)) {
+    for (const value of spec) {
+      if (Number.isInteger(value) && value > 0) out.add(value)
+    }
+    return out
+  }
+
+  for (const part of spec.split(",")) {
+    const trimmed = part.trim()
+    if (!trimmed) continue
+
+    const range = trimmed.match(/^(\d+)\s*-\s*(\d+)$/)
+    if (range) {
+      const start = Number(range[1])
+      const end = Number(range[2])
+      if (start > 0 && end >= start) {
+        for (let line = start; line <= end; line += 1) out.add(line)
+      }
+      continue
+    }
+
+    if (/^\d+$/.test(trimmed)) {
+      const single = Number(trimmed)
+      if (single > 0) out.add(single)
+    }
+  }
+
+  return out
+}
+
+/** Normalises line endings so every downstream offset is LF-based. */
+export function normalizeCode(code: string): string {
+  return code.replace(/\r\n?/g, "\n")
+}
+
+/**
+ * Character ranges for `highlightedWords`, as shiki `decorations`.
+ *
+ * shiki rejects overlapping decorations, so a later match that would overlap an
+ * earlier one is dropped rather than passed through to throw.
+ */
+export function buildWordDecorations(
+  code: string,
+  words?: CodeBlockWordSpec[]
+): { start: number; end: number; properties: { class: string } }[] {
+  if (!words?.length) return []
+
+  const lineStarts: number[] = [0]
+  for (let index = 0; index < code.length; index += 1) {
+    if (code[index] === "\n") lineStarts.push(index + 1)
+  }
+
+  const lineAt = (offset: number) => {
+    let low = 0
+    let high = lineStarts.length - 1
+    while (low < high) {
+      const mid = Math.ceil((low + high) / 2)
+      if (lineStarts[mid] <= offset) low = mid
+      else high = mid - 1
+    }
+    return low + 1
+  }
+
+  const taken: { start: number; end: number }[] = []
+  const out: { start: number; end: number; properties: { class: string } }[] =
+    []
+
+  for (const entry of words) {
+    const word = typeof entry === "string" ? entry : entry.word
+    if (!word) continue
+    const limit =
+      typeof entry === "string" ? undefined : parseLineSpec(entry.lines)
+
+    let from = code.indexOf(word)
+    while (from !== -1) {
+      const to = from + word.length
+      const withinLimit = !limit || limit.size === 0 || limit.has(lineAt(from))
+      const overlaps = taken.some((r) => from < r.end && to > r.start)
+
+      if (withinLimit && !overlaps) {
+        taken.push({ start: from, end: to })
+        out.push({ start: from, end: to, properties: { class: "cb-word" } })
+      }
+      from = code.indexOf(word, from + word.length)
+    }
+  }
+
+  return out.sort((a, b) => a.start - b.start)
+}
+
+/**
+ * Strips `[!code ...]` notation from a copy payload: the transformer already
+ * drops it from RENDERED output, but the raw `code` string would paste the
+ * comment into someone's editor.
+ */
+export function stripNotationComments(code: string): string {
+  return code
+    .split("\n")
+    .map((line) =>
+      line.replace(
+        /\s*(?:\/\/|#|--|;|%||\*\/)?\s*$/,
+        ""
+      )
+    )
+    .join("\n")
+}
+
+/**
+ * Plain, unhighlighted lines. One token per line, no colour.
+ *
+ * This is both the `highlight={false}` renderer and the first paint of a
+ * streaming block, so the tail of a stream is readable before its grammar pass
+ * lands and no frame is ever blank.
+ */
+export function toPlainLines(code: string, startLine = 1): CodeBlockLine[] {
+  return normalizeCode(code)
+    .split("\n")
+    .map((text, index) => ({
+      tokens: text ? [{ content: text }] : [],
+      number: startLine + index,
+      text,
+    }))
+}
+
+/**
+ * Pulls `code` and `language` out of the props react-markdown gives a `pre`:
+ * the glue every AI chat app writes by hand, shipped here instead. Tolerant by
+ * construction, because a still-streaming fence has no closing delimiter and
+ * often no language yet, and must render as plain text rather than throw.
+ */
+export function markdownCodeProps(props: {
+  children?: ReactNode
+  className?: string
+}): { code: string; language?: string } {
+  let language: string | undefined
+  let code = ""
+
+  const readClassName = (value: unknown) => {
+    if (typeof value !== "string") return
+    const match = value.match(/(?:^|\s)language-([\w+#-]+)/)
+    if (match && !language) language = match[1]
+  }
+
+  const walk = (node: unknown): void => {
+    if (node === null || node === undefined || node === false) return
+    if (typeof node === "string") {
+      code += node
+      return
+    }
+    if (typeof node === "number") {
+      code += String(node)
+      return
+    }
+    if (Array.isArray(node)) {
+      for (const child of node) walk(child)
+      return
+    }
+    if (typeof node === "object" && "props" in (node as object)) {
+      const nodeProps = (node as { props?: Record }).props
+      if (!nodeProps) return
+      readClassName(nodeProps.className)
+      walk(nodeProps.children)
+    }
+  }
+
+  readClassName(props.className)
+  walk(props.children)
+
+  return { code: code.replace(/\n$/, ""), language }
+}
+
+/** One segment of a markdown string: prose, or a fenced code block. */
+export type CodeBlockMarkdownPart = {
+  type: "text" | "code"
+  content: string
+  language?: string
+  /** True for a fence whose closing delimiter has not arrived yet. */
+  open: boolean
+}
+
+/**
+ * Splits markdown into prose and fenced code, for transcripts that render a
+ * raw assistant message without a markdown dependency. The unterminated
+ * trailing fence is the point: mid-stream it comes back as a code part flagged
+ * `open`, instead of being dropped or read as prose.
+ */
+export function markdownFences(markdown: string): CodeBlockMarkdownPart[] {
+  const parts: CodeBlockMarkdownPart[] = []
+  const lines = markdown.split("\n")
+
+  let inFence = false
+  let opener = ""
+  let language: string | undefined
+  let buffer: string[] = []
+
+  const flushText = () => {
+    const text = buffer.join("\n").trim()
+    if (text) parts.push({ type: "text", content: text, open: false })
+    buffer = []
+  }
+
+  const flushCode = (open: boolean) => {
+    parts.push({
+      type: "code",
+      content: buffer.join("\n"),
+      language,
+      open,
+    })
+    buffer = []
+    language = undefined
+  }
+
+  for (const line of lines) {
+    /* CommonMark fences: three or more backticks OR tildes. The closer must
+       repeat the opener's character at least as many times, or a \`\`\`\`
+       fence containing \`\`\` examples would close three lines early. */
+    const fence = line.match(/^\s*(`{3,}|~{3,})([\w+#-]*)\s*$/)
+
+    if (fence && !inFence) {
+      flushText()
+      inFence = true
+      opener = fence[1]
+      language = fence[2] || undefined
+      continue
+    }
+
+    if (
+      fence &&
+      inFence &&
+      fence[1][0] === opener[0] &&
+      fence[1].length >= opener.length &&
+      !fence[2]
+    ) {
+      flushCode(false)
+      inFence = false
+      continue
+    }
+
+    buffer.push(line)
+  }
+
+  /* A fence still open at the end of the string is the streaming case: the
+     closing delimiter has not arrived. Reporting it as code with `open` set is
+     what lets a transcript render the partial block instead of dropping it. */
+  if (inFence) flushCode(true)
+  else flushText()
+
+  return parts
+}
+
+/* -------------------------------------------------------------------------- */
+/*                                   Engine                                    */
+/* -------------------------------------------------------------------------- */
+
+type HighlighterLike = {
+  codeToHast: (code: string, options: Record) => unknown
+  getLoadedLanguages: () => string[]
+  loadLanguage: (lang: unknown) => Promise
+  loadTheme: (theme: unknown) => Promise
+}
+
+let highlighterPromise: Promise | null = null
+const loadedLanguages = new Set()
+const loadedThemes = new Set()
+
+/**
+ * One highlighter per page, on the JavaScript regex engine: oniguruma needs
+ * WebAssembly, which forces `'wasm-unsafe-eval'` into every consumer's CSP.
+ * `forgiving` keeps an inexpressible grammar pattern from taking the block
+ * down.
+ */
+async function loadHighlighter() {
+  if (!highlighterPromise) {
+    highlighterPromise = (async () => {
+      const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] =
+        await Promise.all([
+          import("shiki/core"),
+          import("shiki/engine/javascript"),
+        ])
+
+      return (await createHighlighterCore({
+        themes: [],
+        langs: [],
+        engine: createJavaScriptRegexEngine({ forgiving: true }),
+      })) as unknown as HighlighterLike
+    })()
+  }
+
+  return highlighterPromise
+}
+
+/**
+ * Themes register per name, on demand, exactly like languages.
+ *
+ * Baking the FIRST caller's pair into the singleton looks correct until a page
+ * holds two blocks with different `themes` props: the second silently renders
+ * in the first one's colours, because the singleton never learns about the
+ * request. Loading by name makes every block's prop actually mean something.
+ */
+const warnedThemes = new Set()
+
+async function ensureTheme(
+  highlighter: HighlighterLike,
+  name: string,
+  side: "light" | "dark" = "light"
+) {
+  /* Falls back to the MATCHING side (an unknown dark theme used to fall back
+     to github-light, silently rendering light colours in dark mode). */
+  const resolved =
+    name in codeBlockThemes ? name : DEFAULT_CODE_BLOCK_THEMES[side]
+  if (
+    resolved !== name &&
+    process.env.NODE_ENV !== "production" &&
+    !warnedThemes.has(name)
+  ) {
+    warnedThemes.add(name)
+    console.warn(
+      `[code-block] Unknown theme "${name}" - falling back to "${resolved}". ` +
+        "Register it in codeBlockThemes to use it."
+    )
+  }
+  if (loadedThemes.has(resolved)) return resolved
+  await highlighter.loadTheme(await codeBlockThemes[resolved]())
+  loadedThemes.add(resolved)
+  return resolved
+}
+
+async function ensureLanguage(highlighter: HighlighterLike, language: string) {
+  if (loadedLanguages.has(language)) return
+  const loader = codeBlockLanguages[language]
+  if (!loader) return
+  await highlighter.loadLanguage(await loader())
+  loadedLanguages.add(language)
+}
+
+/* -------------------------------------------------------------------------- */
+/*                            hast to normalised lines                         */
+/* -------------------------------------------------------------------------- */
+
+type HastNode = {
+  type: string
+  tagName?: string
+  value?: string
+  properties?: Record
+  children?: HastNode[]
+}
+
+const FONT_STYLE_BY_DECLARATION: Record = {
+  "font-style:italic": "italic",
+  "font-weight:bold": "bold",
+  "text-decoration:underline": "underline",
+}
+
+/** Splits shiki's inline `style` string into the fields a token carries. */
+function readTokenStyle(style: unknown): Omit {
+  if (typeof style !== "string") return {}
+
+  const out: Omit = {}
+  for (const declaration of style.split(";")) {
+    const trimmed = declaration.trim()
+    if (!trimmed) continue
+
+    const separator = trimmed.indexOf(":")
+    if (separator === -1) continue
+
+    const property = trimmed.slice(0, separator).trim()
+    const value = trimmed.slice(separator + 1).trim()
+
+    if (property === "color") out.color = value
+    else if (property === "--shiki-dark") out.colorDark = value
+    else {
+      const fontStyle = FONT_STYLE_BY_DECLARATION[`${property}:${value}`]
+      if (fontStyle) out.fontStyle = fontStyle
+    }
+  }
+  return out
+}
+
+/**
+ * shiki emits raw `class` (string or array), not hast's `className`; reading
+ * only `className` finds nothing, which looks like a transformer that never
+ * ran.
+ */
+function classListOf(node: HastNode): string[] {
+  const value = node.properties?.class ?? node.properties?.className
+  if (Array.isArray(value)) return value.map(String)
+  if (typeof value === "string") return value.split(/\s+/).filter(Boolean)
+  return []
+}
+
+/** Maps the classes shiki transformers put on a line onto line state. */
+function stateFromClasses(classes: string[]): CodeBlockLineState | undefined {
+  const state: CodeBlockLineState = {}
+  if (classes.includes("highlighted")) state.highlighted = true
+  if (classes.includes("focused")) state.focused = true
+  if (classes.includes("diff")) {
+    if (classes.includes("add")) state.diff = "add"
+    else if (classes.includes("remove")) state.diff = "remove"
+  }
+  for (const level of ["error", "warning", "info"] as const) {
+    if (classes.includes(level)) state.level = level
+  }
+  return Object.keys(state).length ? state : undefined
+}
+
+function collectTokens(
+  node: HastNode,
+  out: CodeBlockToken[],
+  inWord: boolean
+): void {
+  for (const child of node.children ?? []) {
+    if (child.type === "text") {
+      if (!child.value) continue
+      out.push(
+        inWord ? { content: child.value, word: true } : { content: child.value }
+      )
+      continue
+    }
+    if (child.type !== "element") continue
+
+    const classes = classListOf(child)
+    const childInWord = inWord || classes.includes("cb-word")
+    const style = readTokenStyle(child.properties?.style)
+    const hasStyle = Boolean(style.color || style.colorDark || style.fontStyle)
+
+    /* A styled leaf is a token; a wrapper (a decoration span) is descended into
+       so its own children keep their individual colours. */
+    const onlyText = (child.children ?? []).every(
+      (grandChild) => grandChild.type === "text"
+    )
+
+    if (hasStyle && onlyText) {
+      const content = (child.children ?? []).map((c) => c.value ?? "").join("")
+      if (!content) continue
+      out.push({
+        content,
+        ...style,
+        ...(childInWord ? { word: true } : {}),
+      })
+      continue
+    }
+
+    collectTokens(child, out, childInWord)
+  }
+}
+
+function findCodeElement(root: HastNode): HastNode | undefined {
+  if (root.type === "element" && root.tagName === "code") return root
+  for (const child of root.children ?? []) {
+    const found = findCodeElement(child)
+    if (found) return found
+  }
+  return undefined
+}
+
+/* -------------------------------------------------------------------------- */
+/*                              Identity reuse                                 */
+/* -------------------------------------------------------------------------- */
+
+const MAX_TRACKED_DOCUMENTS = 24
+
+/**
+ * Previous result per option signature, so a growing stream can reuse lines.
+ * Keyed WITH the caller's instanceKey: without it, two same-configured blocks
+ * evicted each other's entry on every interleaved pass. Bounded above because
+ * module state outlives requests on the server.
+ */
+const previousDocuments = new Map()
+
+function sameLine(a: CodeBlockLine, b: CodeBlockLine): boolean {
+  if (a.number !== b.number || a.text !== b.text) return false
+  if (a.tokens.length !== b.tokens.length) return false
+  if (JSON.stringify(a.state ?? null) !== JSON.stringify(b.state ?? null)) {
+    return false
+  }
+  for (let index = 0; index < a.tokens.length; index += 1) {
+    const left = a.tokens[index]
+    const right = b.tokens[index]
+    if (
+      left.content !== right.content ||
+      left.color !== right.color ||
+      left.colorDark !== right.colorDark ||
+      left.fontStyle !== right.fontStyle ||
+      left.word !== right.word
+    ) {
+      return false
+    }
+  }
+  return true
+}
+
+/**
+ * Swaps freshly built lines for the previous pass's objects where nothing
+ * changed. This is why streaming is cheap: the row is a reference-equality
+ * `memo`, so without this, appending one token to a 400 line file re-renders
+ * 400 subtrees per chunk.
+ */
+function reuseUnchangedLines(
+  key: string,
+  next: CodeBlockLine[]
+): CodeBlockLine[] {
+  const previous = previousDocuments.get(key)
+
+  if (previous) {
+    for (let index = 0; index < next.length; index += 1) {
+      const before = previous[index]
+      if (before && sameLine(before, next[index])) next[index] = before
+    }
+  }
+
+  previousDocuments.set(key, next)
+  if (previousDocuments.size > MAX_TRACKED_DOCUMENTS) {
+    const oldest = previousDocuments.keys().next().value
+    if (oldest !== undefined) previousDocuments.delete(oldest)
+  }
+
+  return next
+}
+
+/* -------------------------------------------------------------------------- */
+/*                                highlightCode                                */
+/* -------------------------------------------------------------------------- */
+
+function applyPropState(
+  lines: CodeBlockLine[],
+  options: CodeBlockHighlightOptions
+): void {
+  const highlighted = parseLineSpec(options.highlightedLines)
+  const focused = parseLineSpec(options.focusedLines)
+  const added = parseLineSpec(options.diff?.added)
+  const removed = parseLineSpec(options.diff?.removed)
+  const levels = {
+    error: parseLineSpec(options.lineLevels?.error),
+    warning: parseLineSpec(options.lineLevels?.warning),
+    info: parseLineSpec(options.lineLevels?.info),
+  }
+
+  lines.forEach((line, index) => {
+    const source = index + 1
+    const state: CodeBlockLineState = { ...line.state }
+
+    if (highlighted.has(source)) state.highlighted = true
+    if (focused.has(source)) state.focused = true
+    if (added.has(source)) state.diff = "add"
+    else if (removed.has(source)) state.diff = "remove"
+    for (const level of ["error", "warning", "info"] as const) {
+      if (levels[level].has(source)) state.level = level
+    }
+
+    line.state = Object.keys(state).length ? state : undefined
+  })
+}
+
+/**
+ * Highlights `code` into the renderer's line shape. Built on `codeToHast` so
+ * consumer transformers run and their line classes land in `line.state`,
+ * making prop state and `[!code ++]` notation one feature, not two code
+ * paths. Safe in a server component: the result is plain JSON.
+ */
+export async function highlightCode(
+  code: string,
+  options: CodeBlockHighlightOptions = {}
+): Promise {
+  const source = normalizeCode(code)
+  const startLine = options.startLine ?? 1
+  const language = resolveCodeBlockLanguage(options.language)
+
+  if (!language) return toPlainLines(source, startLine)
+
+  const themes = options.themes ?? DEFAULT_CODE_BLOCK_THEMES
+  const signature = JSON.stringify([
+    options.instanceKey ?? null,
+    language,
+    themes,
+    startLine,
+    options.highlightedLines ?? null,
+    options.highlightedWords ?? null,
+    options.focusedLines ?? null,
+    options.diff ?? null,
+    options.lineLevels ?? null,
+    (options.transformers ?? []).length,
+  ])
+
+  let root: HastNode
+  try {
+    const highlighter = await loadHighlighter()
+    const [light, dark] = await Promise.all([
+      ensureTheme(highlighter, themes.light),
+      ensureTheme(highlighter, themes.dark),
+    ])
+    await ensureLanguage(highlighter, language)
+
+    root = highlighter.codeToHast(source, {
+      lang: language,
+      themes: { light, dark },
+      defaultColor: "light",
+      cssVariablePrefix: "--shiki-",
+      decorations: buildWordDecorations(source, options.highlightedWords),
+      ...(options.transformers?.length
+        ? { transformers: options.transformers }
+        : {}),
+    }) as HastNode
+  } catch {
+    /* A missing grammar, an unloadable theme or a transformer throwing must not
+       take the surface down. Plain text is always readable. */
+    return toPlainLines(source, startLine)
+  }
+
+  const codeElement = findCodeElement(root)
+  if (!codeElement) return toPlainLines(source, startLine)
+
+  const lines: CodeBlockLine[] = []
+  for (const child of codeElement.children ?? []) {
+    if (child.type !== "element") continue
+    const tokens: CodeBlockToken[] = []
+    collectTokens(child, tokens, false)
+    lines.push({
+      tokens,
+      number: startLine + lines.length,
+      text: tokens.map((token) => token.content).join(""),
+      state: stateFromClasses(classListOf(child)),
+    })
+  }
+
+  if (!lines.length) return toPlainLines(source, startLine)
+
+  applyPropState(lines, options)
+  return reuseUnchangedLines(signature, lines)
+}
+
+/** Test seam: drops the singleton and every cached document. */
+export function resetCodeBlockHighlighter(): void {
+  highlighterPromise = null
+  loadedLanguages.clear()
+  loadedThemes.clear()
+  previousDocuments.clear()
+}
+
+/* -------------------------------------------------------------------------- */
+/*                                    ANSI                                     */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * The 16 SGR slots as CSS variables with readable defaults per theme, so a
+ * consumer retints terminal output next to their other design tokens
+ * (`--code-ansi-red`, `--code-ansi-bright-blue`, ...). 256-colour and
+ * truecolor sequences bypass the palette and emit their literal colour.
+ */
+const ANSI_SLOTS = [
+  "black",
+  "red",
+  "green",
+  "yellow",
+  "blue",
+  "magenta",
+  "cyan",
+  "white",
+  "bright-black",
+  "bright-red",
+  "bright-green",
+  "bright-yellow",
+  "bright-blue",
+  "bright-magenta",
+  "bright-cyan",
+  "bright-white",
+] as const
+
+const ANSI_LIGHT = [
+  "#3f3f46",
+  "#dc2626",
+  "#16a34a",
+  "#a16207",
+  "#2563eb",
+  "#9333ea",
+  "#0891b2",
+  "#71717a",
+  "#52525b",
+  "#ef4444",
+  "#22c55e",
+  "#ca8a04",
+  "#3b82f6",
+  "#a855f7",
+  "#06b6d4",
+  "#a1a1aa",
+]
+const ANSI_DARK = [
+  "#a1a1aa",
+  "#f87171",
+  "#4ade80",
+  "#facc15",
+  "#60a5fa",
+  "#c084fc",
+  "#22d3ee",
+  "#e4e4e7",
+  "#71717a",
+  "#fca5a5",
+  "#86efac",
+  "#fde047",
+  "#93c5fd",
+  "#d8b4fe",
+  "#67e8f9",
+  "#fafafa",
+]
+
+const ansiVar = (slot: number, fallback: string) =>
+  `var(--code-ansi-${ANSI_SLOTS[slot]}, ${fallback})`
+
+/** xterm 256-colour index to hex, computed rather than tabled. */
+function ansi256(index: number): string {
+  if (index < 16) return ANSI_DARK[index]
+  if (index >= 232) {
+    const v = 8 + (index - 232) * 10
+    const h = v.toString(16).padStart(2, "0")
+    return `#${h}${h}${h}`
+  }
+  const cube = [0, 95, 135, 175, 215, 255]
+  const n = index - 16
+  const to = (v: number) => cube[v].toString(16).padStart(2, "0")
+  return `#${to(Math.floor(n / 36))}${to(Math.floor(n / 6) % 6)}${to(n % 6)}`
+}
+
+// eslint-disable-next-line no-control-regex
+const SGR_RE = /\x1b\[([0-9;]*)m/g
+// eslint-disable-next-line no-control-regex
+/* Everything except SGR (the trailing `m`), which the tokenizer consumes -
+   this regex once matched SGR too and silently stripped every colour. */
+const OTHER_ESCAPES_RE =
+// eslint-disable-next-line no-control-regex
+  /\x1b(?:\[(?![0-9;]*m)[0-9;?]*[A-Za-z]|\][^\x07]*(?:\x07|\x1b\\)|[()][0-9A-B])/g
+
+type AnsiStyle = {
+  color?: string
+  colorDark?: string
+  bold?: boolean
+  italic?: boolean
+  underline?: boolean
+}
+
+/**
+ * Terminal output with SGR colour codes, as renderable lines: feed the result
+ * to the `lines` prop. Covers what agent stdout actually uses - 30-37 / 90-97
+ * foregrounds, 38;5;n and 38;2;r;g;b, bold, italic, underline and resets.
+ * Backgrounds and cursor movements are STRIPPED rather than rendered: a code
+ * surface has its own background, and a partial screen-drawing stream is
+ * better read as text than half-drawn.
+ */
+export function ansiToLines(text: string, startLine = 1): CodeBlockLine[] {
+  const clean = normalizeCode(text).replace(OTHER_ESCAPES_RE, "")
+
+  return clean.split("\n").map((raw, index) => {
+    const tokens: CodeBlockToken[] = []
+    const style: AnsiStyle = {}
+    let plain = ""
+    let last = 0
+
+    const flush = (content: string) => {
+      if (!content) return
+      const fontStyle = style.bold
+        ? ("bold" as const)
+        : style.italic
+          ? ("italic" as const)
+          : style.underline
+            ? ("underline" as const)
+            : undefined
+      tokens.push({
+        content,
+        color: style.color,
+        colorDark: style.colorDark,
+        fontStyle,
+      })
+    }
+
+    SGR_RE.lastIndex = 0
+    let match: RegExpExecArray | null
+    while ((match = SGR_RE.exec(raw))) {
+      flush(raw.slice(last, match.index))
+      plain += raw.slice(last, match.index)
+      last = match.index + match[0].length
+
+      const params = (match[1] || "0").split(";").map(Number)
+      for (let i = 0; i < params.length; i += 1) {
+        const code = params[i]
+        if (code === 0) {
+          delete style.color
+          delete style.colorDark
+          style.bold = style.italic = style.underline = false
+        } else if (code === 1) style.bold = true
+        else if (code === 3) style.italic = true
+        else if (code === 4) style.underline = true
+        else if (code === 22) style.bold = false
+        else if (code === 23) style.italic = false
+        else if (code === 24) style.underline = false
+        else if (code === 39) {
+          delete style.color
+          delete style.colorDark
+        } else if (code >= 30 && code <= 37) {
+          style.color = ansiVar(code - 30, ANSI_LIGHT[code - 30])
+          style.colorDark = ansiVar(code - 30, ANSI_DARK[code - 30])
+        } else if (code >= 90 && code <= 97) {
+          style.color = ansiVar(code - 82, ANSI_LIGHT[code - 82])
+          style.colorDark = ansiVar(code - 82, ANSI_DARK[code - 82])
+        } else if (code === 38 && params[i + 1] === 5) {
+          const hex = ansi256(params[i + 2] ?? 0)
+          style.color = style.colorDark = hex
+          i += 2
+        } else if (code === 38 && params[i + 1] === 2) {
+          const [r, g, b] = [
+            params[i + 2] ?? 0,
+            params[i + 3] ?? 0,
+            params[i + 4] ?? 0,
+          ]
+          const hex = `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`
+          style.color = style.colorDark = hex
+          i += 4
+        } else if (
+          code === 48 &&
+          (params[i + 1] === 5 || params[i + 1] === 2)
+        ) {
+          i += params[i + 1] === 5 ? 2 : 4
+        }
+        /* 40-47 / 100-107 backgrounds: consumed by falling through. */
+      }
+    }
+    flush(raw.slice(last))
+    plain += raw.slice(last)
+
+    return { number: startLine + index, text: plain, tokens }
+  })
+}
+
+/* -------------------------------------------------------------------------- */
+/*                                Unified diff                                 */
+/* -------------------------------------------------------------------------- */
+
+export type CodeBlockPatchFile = {
+  /** New-side path, or the old one for a deletion. */
+  file: string
+  /** Unified view: context, removed and added lines with dual gutter labels. */
+  lines: CodeBlockLine[]
+  added: number
+  removed: number
+  hunks: { header: string; at: number }[]
+}
+
+/**
+ * A `git diff` / unified patch, as renderable per-file line sets: feed each
+ * file's `lines` to the `lines` prop and the diff tints, `+`/`-` glyphs and
+ * dual old/new gutter numbers all come from the parse - no `diff` prop
+ * arithmetic against a hand-concatenated string. Tokens are plain; patches
+ * read by tint, not grammar.
+ */
+export function parseUnifiedDiff(patch: string): CodeBlockPatchFile[] {
+  const files: CodeBlockPatchFile[] = []
+  let current: CodeBlockPatchFile | null = null
+  let oldNumber = 0
+  let newNumber = 0
+  let width = 4
+
+  const push = (
+    text: string,
+    state: CodeBlockLineState | undefined,
+    gutter: string
+  ) => {
+    if (!current) return
+    current.lines.push({
+      number: current.lines.length + 1,
+      text,
+      tokens: [{ content: text }],
+      state,
+      gutter,
+    })
+  }
+  const pad = (value: number | null) =>
+    (value === null ? "" : String(value)).padStart(width)
+
+  for (const raw of normalizeCode(patch).split("\n")) {
+    const fileHeader = raw.match(/^diff --git a\/(.+) b\/(.+)$/)
+    const plusHeader = raw.match(/^\+\+\+ (?:b\/)?(.+)$/)
+    if (fileHeader || plusHeader) {
+      const name = fileHeader ? fileHeader[2] : plusHeader![1]
+      if (name !== "/dev/null" && (!current || current.file !== name)) {
+        current = { file: name, lines: [], added: 0, removed: 0, hunks: [] }
+        files.push(current)
+      }
+      continue
+    }
+    if (
+      /^(---|index |old mode|new mode|new file|deleted file|similarity|rename |Binary )/.test(
+        raw
+      )
+    ) {
+      continue
+    }
+
+    const hunk = raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/)
+    if (hunk && current) {
+      oldNumber = Number(hunk[1])
+      newNumber = Number(hunk[3])
+      width = Math.max(
+        String(oldNumber + Number(hunk[2] ?? 0)).length,
+        String(newNumber + Number(hunk[4] ?? 0)).length,
+        2
+      )
+      current.hunks.push({ header: raw, at: current.lines.length + 1 })
+      push(
+        hunk[5].trim() || raw,
+        { level: "info" },
+        `${"·".padStart(width)} ${"·".padStart(width)}`
+      )
+      continue
+    }
+    if (!current || (!raw && files.length === 0)) continue
+
+    if (raw.startsWith("+")) {
+      push(raw.slice(1), { diff: "add" }, `${pad(null)} ${pad(newNumber)}`)
+      newNumber += 1
+      current.added += 1
+    } else if (raw.startsWith("-")) {
+      push(raw.slice(1), { diff: "remove" }, `${pad(oldNumber)} ${pad(null)}`)
+      oldNumber += 1
+      current.removed += 1
+    } else if (raw.startsWith(" ") || raw === "") {
+      if (current.lines.length === 0 && raw === "") continue
+      push(raw.slice(1), undefined, `${pad(oldNumber)} ${pad(newNumber)}`)
+      oldNumber += 1
+      newNumber += 1
+    }
+  }
+
+  return files
+}
\ No newline at end of file
diff --git a/apps/web/src/components/reui/code-block/code-block.tsx b/apps/web/src/components/reui/code-block/code-block.tsx
new file mode 100644
index 0000000..025fe39
--- /dev/null
+++ b/apps/web/src/components/reui/code-block/code-block.tsx
@@ -0,0 +1,2236 @@
+"use client"
+
+// Title: Code Block
+// Description: Shadcn code block with Shiki highlighting, streaming, diffs, folding and per-line interaction for AI chat UIs.
+import {
+  Children,
+  createContext,
+  isValidElement,
+  memo,
+  useCallback,
+  useContext,
+  useDeferredValue,
+  useEffect,
+  useId,
+  useLayoutEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react"
+import type {
+  ComponentProps,
+  CSSProperties,
+  KeyboardEvent as ReactKeyboardEvent,
+  ReactNode,
+} from "react"
+import {
+  highlightCode,
+  markdownCodeProps,
+  markdownFences,
+  resolveCodeBlockLanguage,
+  stripNotationComments,
+  toPlainLines,
+} from "@/components/reui/code-block/code-block-highlight"
+import type {
+  CodeBlockDiffSpec,
+  CodeBlockLevelSpec,
+  CodeBlockLine,
+  CodeBlockLineActionsRender,
+  CodeBlockLineSpec,
+  CodeBlockThemes,
+  CodeBlockToken,
+  CodeBlockTransformer,
+  CodeBlockWordSpec,
+} from "@/components/reui/code-block/code-block-highlight"
+
+import { cn } from "@evobgp/ui/lib/utils"
+import { Button } from "@evobgp/ui/components/button"
+
+/* -------------------------------------------------------------------------- */
+/*                                   Context                                   */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Two contexts: the document changes on every streamed chunk, the config does
+ * not. Splitting them keeps a stream from re-rendering the chrome per chunk.
+ */
+type CodeBlockConfigValue = {
+  language?: string
+  resolvedLanguage?: string
+  showLineNumbers: boolean
+  wrap: boolean
+  setWrap: (wrap: boolean) => void
+  wrapControlled: boolean
+  expanded: boolean
+  setExpanded: (expanded: boolean) => void
+  collapsible: boolean
+  streaming: boolean
+  contentId: string
+}
+
+type CodeBlockDocumentValue = {
+  code: string
+  lines: CodeBlockLine[]
+  selected: Set
+  selectable: boolean
+  toggleLine: (line: number, extend: boolean) => void
+  clearSelection: () => void
+  foldable: boolean
+  foldRegions: CodeBlockFoldRegion[]
+  folded: Set
+  toggleFold: (start: number) => void
+  setFolded: (folded: number[]) => void
+}
+
+const CodeBlockConfigContext = createContext(null)
+const CodeBlockDocumentContext = createContext(
+  null
+)
+
+/** Lets the copy button tell a header placement from a floating one. */
+const CodeBlockHeaderContext = createContext(false)
+
+/**
+ * The root prepares every prop the code surface needs and publishes the
+ * bundle here. `CodeBlockContent` reads it to render the surface wherever the
+ * consumer composed it - typically inside their own ScrollArea - and the root
+ * renders its built-in scrolling surface only when no `CodeBlockContent` is
+ * found among its children.
+ */
+const CodeBlockSurfacePropsContext =
+  createContext(null)
+
+/**
+ * `CodeBlockLineActions` renders nothing itself: it registers its render prop
+ * here and the ACTIVE ROW renders it, so actions stay aligned when soft wrap
+ * gives lines different heights.
+ */
+export type CodeBlockLineActionsSide = "end" | "gutter"
+
+const CodeBlockActionsContext = createContext<{
+  register: (
+    owner: object,
+    render: CodeBlockLineActionsRender | null,
+    side?: CodeBlockLineActionsSide
+  ) => void
+} | null>(null)
+
+/* Next still server-renders "use client" files, and a bare useLayoutEffect
+   logs on every SSR pass. Same alias the event calendar ships. */
+const useIsoLayoutEffect =
+  typeof window !== "undefined" ? useLayoutEffect : useEffect
+
+function useInternalConfig(part: string): CodeBlockConfigValue {
+  const context = useContext(CodeBlockConfigContext)
+  if (!context) {
+    throw new Error(`${part} must be used within a CodeBlock`)
+  }
+  return context
+}
+
+function useCodeBlockConfig(
+  part: string
+): CodeBlockConfigValue & { code: string } {
+  const context = useContext(CodeBlockConfigContext)
+  const document = useContext(CodeBlockDocumentContext)
+  if (!context || !document) {
+    throw new Error(`${part} must be used within a CodeBlock`)
+  }
+  /* `code` lives on the DOCUMENT context (it changes per streamed chunk);
+     keeping it out of the config is what lets chrome that ignores the source
+     skip those re-renders. The public hook still returns it. */
+  return useMemo(
+    () => ({ ...context, code: document.code }),
+    [context, document.code]
+  )
+}
+
+/* -------------------------------------------------------------------------- */
+/*                                   Styling                                   */
+/* -------------------------------------------------------------------------- */
+
+/* Radius per style. Every style is named, including the zero rungs: a missing
+   rung is a style someone forgot. The parity suite enforces all-eight-or-one. */
+const ROOT_GHOST_CLASS = "[--code-block-bg:transparent]"
+
+const ROOT_RADIUS_CLASS =
+  "[--code-block-radius:var(--radius-lg)]"
+
+/*
+ * Colour vars use the BASE theme tokens (--card, --primary, ...), never the
+ * --color-* twins: those are static @theme literals that ignore a nested
+ * `.dark` scope, which once painted a white gutter cell on a forced-dark
+ * block. Tints are one system, 8% light / 12% dark (yellow a touch more),
+ * matching the *-light badge weights. This item ships the same cssVars.
+ */
+const ROOT_TOKEN_CLASS = [
+  "[--code-block-padding:--spacing(3)]",
+  "[--code-block-gutter-width:--spacing(9)]",
+  "[--code-block-gutter-gap:--spacing(2.5)]",
+  /* The fold channel sits between the numbers and the code, so both the number
+     cell's padding and the gutter width are expressed against it and a block
+     without folding pays nothing for it. */
+  "[--code-block-fold-width:--spacing(4.5)]",
+  "[--code-block-number-pad:var(--code-block-gutter-gap)]",
+  "data-[gutter-channel]:[--code-block-number-pad:calc(var(--code-block-gutter-gap)+var(--code-block-fold-width))]",
+  /* A diff column breathes: the +/- glyph gets a wider channel. */
+  "data-[has-diff]:[--code-block-gutter-gap:--spacing(4)]",
+  "[--code-block-line-height:1.5rem]",
+  "[--code-block-font-size:0.8125rem]",
+  "[--code-block-copy-inset:--spacing(2)]",
+  /* Where a PINNED control starts. With a header composed in, the root's top
+     edge is the header, so an unadjusted inset drops the copy button onto the
+     title row instead of the code. The header's own `min-h-9` is the height
+     assumed here, plus its 1px bottom border; override the variable for a
+     header built taller than one row. */
+  "[--code-block-header-height:--spacing(9)]",
+  "[--code-block-copy-top:var(--code-block-copy-inset)]",
+  "data-[has-header]:[--code-block-copy-top:calc(var(--code-block-header-height)+1px+var(--code-block-copy-inset))]",
+  /* Room the expanded block keeps under the last line for the floating
+     "Show less" control. */
+  "[--code-block-expand-clearance:--spacing(9)]",
+  /* What sits behind a sticky line number while the code scrolls under it.
+     `ghost` blanks it, because there the surface belongs to the wrapper. */
+  "[--code-block-bg:var(--card)]",
+  "[--code-block-highlight-bg:color-mix(in_oklch,var(--primary)_6%,transparent)]",
+  "dark:[--code-block-highlight-bg:color-mix(in_oklch,var(--primary)_12%,transparent)]",
+  "[--code-block-highlight-bar:color-mix(in_oklch,var(--primary)_60%,transparent)]",
+  "[--code-block-diff-add-bg:color-mix(in_oklch,var(--success)_8%,transparent)]",
+  "dark:[--code-block-diff-add-bg:color-mix(in_oklch,var(--success)_12%,transparent)]",
+  "[--code-block-diff-remove-bg:color-mix(in_oklch,var(--destructive)_8%,transparent)]",
+  "dark:[--code-block-diff-remove-bg:color-mix(in_oklch,var(--destructive)_12%,transparent)]",
+  "[--code-block-error-bg:color-mix(in_oklch,var(--destructive)_8%,transparent)]",
+  "dark:[--code-block-error-bg:color-mix(in_oklch,var(--destructive)_12%,transparent)]",
+  "[--code-block-warning-bg:color-mix(in_oklch,var(--warning)_10%,transparent)]",
+  "dark:[--code-block-warning-bg:color-mix(in_oklch,var(--warning)_14%,transparent)]",
+  "[--code-block-info-bg:color-mix(in_oklch,var(--info)_8%,transparent)]",
+  "dark:[--code-block-info-bg:color-mix(in_oklch,var(--info)_12%,transparent)]",
+  "[--code-block-caret-color:var(--primary)]",
+].join(" ")
+
+const ROOT_BASE_CLASS =
+  "group/code-block relative flex min-w-0 flex-col rounded-(--code-block-radius) text-left"
+
+const ROOT_SURFACE_CLASS =
+  "border border-border bg-card text-card-foreground bg-clip-padding"
+
+/**
+ * The token colour switch: tokens carry only `--cb-c` / `--cb-cd`, and these
+ * two rules recolour the whole block per theme instead of a `dark:` utility on
+ * every span. The `color:` hint is load-bearing: `text-[var(--x)]` is
+ * type-ambiguous to Tailwind and emits NO rule at all, which renders as a
+ * highlighter that silently failed. Same reason the font size uses `length:`.
+ */
+const TOKEN_COLOR_CLASS =
+  "[&_[data-slot=code-block-token]]:text-[color:var(--cb-c,currentColor)] dark:[&_[data-slot=code-block-token]]:text-[color:var(--cb-cd,currentColor)]"
+
+/**
+ * Line numbers are a CSS counter, never DOM: a pseudo-element cannot join a
+ * text selection, so copying yields exact source, and the gutter costs zero
+ * elements. `startLine` is a counter reset on the pre.
+ */
+const LINE_NUMBER_CLASS = [
+  "[[data-code-line-numbers]_&]:before:pointer-events-none",
+  "[[data-code-line-numbers]_&]:before:sticky",
+  "[[data-code-line-numbers]_&]:before:left-0",
+  "[[data-code-line-numbers]_&]:before:z-10",
+  "[[data-code-line-numbers]_&]:before:-ml-(--code-block-gutter-width)",
+  "[[data-code-line-numbers]_&]:before:inline-block",
+  "[[data-code-line-numbers]_&]:before:w-(--code-block-gutter-width)",
+  "[[data-code-line-numbers]_&]:before:pl-(--code-block-padding)",
+  "[[data-code-line-numbers]_&]:before:pr-(--code-block-number-pad)",
+  "[[data-code-line-numbers]_&]:before:bg-(--code-block-bg)",
+  "[[data-code-line-numbers]_&]:before:text-right",
+  "[[data-code-line-numbers]_&]:before:tabular-nums",
+  "[[data-code-line-numbers]_&]:before:text-muted-foreground/50",
+  "[[data-code-line-numbers]_&]:before:select-none",
+  "[[data-code-line-numbers]_&]:before:[counter-increment:cb-line]",
+  "[[data-code-line-numbers]_&]:before:content-[counter(cb-line)]",
+  /* A line carrying its own gutter label (a patch's dual numbers, a hunk
+     marker) renders that instead of the counter. */
+  "[[data-code-line-numbers]_&[data-gutter]]:before:content-[attr(data-gutter)]",
+  "[[data-code-line-numbers]_&[data-gutter]]:before:whitespace-pre",
+].join(" ")
+
+/** Diff glyphs ride in the same pseudo-element family, so they never copy. */
+/**
+ * The diff glyph gets its own channel, centred in the left padding (no
+ * numbers) or in the number-to-code gap. A fixed `left-1` had ~1px of
+ * clearance and would land on the first character.
+ */
+const LINE_DIFF_CLASS = [
+  "data-[diff]:after:absolute",
+  /* Above the number cell. That cell is sticky with an opaque backdrop at z-10,
+     so a glyph sharing its column is painted out at the default z. */
+  "data-[diff]:after:z-20",
+  "data-[diff]:after:left-0",
+  "data-[diff]:after:w-(--code-block-padding)",
+  "data-[diff]:after:text-center",
+  "[[data-code-line-numbers]_&]:data-[diff]:after:-left-(--code-block-gutter-gap)",
+  "[[data-code-line-numbers]_&]:data-[diff]:after:w-(--code-block-gutter-gap)",
+  "data-[diff]:after:select-none",
+  "data-[diff=add]:after:text-success",
+  "data-[diff=remove]:after:text-destructive",
+  "data-[diff=add]:after:content-['+']",
+  "data-[diff=remove]:after:content-['-']",
+].join(" ")
+
+const LINE_STATE_CLASS = [
+  "data-[highlighted]:bg-(--code-block-highlight-bg)",
+  "data-[highlighted]:shadow-[inset_2px_0_0_0_var(--code-block-highlight-bar)]",
+  "data-[diff=add]:bg-(--code-block-diff-add-bg)",
+  "data-[diff=remove]:bg-(--code-block-diff-remove-bg)",
+  "data-[level=error]:bg-(--code-block-error-bg)",
+  "data-[level=warning]:bg-(--code-block-warning-bg)",
+  "data-[level=info]:bg-(--code-block-info-bg)",
+  "data-[selected]:bg-accent",
+  /* Arrow navigation needs a visible position (WCAG 2.4.7); gated on the
+     selectable listbox so plain blocks stay inert on hover. */
+  "[[data-selectable]_&]:data-[active]:bg-muted/40",
+].join(" ")
+
+/**
+ * Focus mode dims the rest and clears on hover.
+ *
+ * Blur alone makes a snippet unreadable if the reader wanted the surrounding
+ * context after all, so hovering the block restores everything.
+ */
+/**
+ * Motion is streaming-only and compositor-only: rows keep identity across
+ * chunks, so the entry animation fires once per NEW line and a static block
+ * never animates. Tokens deliberately do not animate (the plain tail gaining
+ * colour remounts them mid-read). Reduced motion disables the row entry.
+ */
+const LINE_MOTION_CLASS =
+  "[[data-streaming]_&]:animate-in [[data-streaming]_&]:fade-in-0 [[data-streaming]_&]:slide-in-from-bottom-1 [[data-streaming]_&]:duration-150 [[data-streaming]_&]:ease-out motion-reduce:animate-none"
+
+/**
+ * Deliberately no per-token animation. The highlighter runs a chunk behind
+ * the stream and REPLACES the plain-text fallback lines with tokenised ones,
+ * which remounts every token span; a mount-keyed fade therefore re-flashed
+ * whole lines that were already readable, 300ms behind the caret. Colour
+ * arriving instantly reads as highlighting; re-fading reads as a glitch.
+ */
+const TOKEN_MOTION_CLASS = ""
+
+const LINE_FOCUS_CLASS =
+  "data-[blurred]:opacity-40 data-[blurred]:blur-[1.5px] data-[blurred]:transition-[opacity,filter] group-hover/code-block:data-[blurred]:opacity-100 group-hover/code-block:data-[blurred]:blur-none"
+
+/**
+ * `data-code-line`, not `data-line`: rehype-pretty-code apps ship a global
+ * `[data-line] span { color !important }` rule, and reusing its attribute
+ * rendered every token monochrome. Cost a real investigation.
+ */
+const LINE_BASE_CLASS = cn(
+  "relative block min-h-(--code-block-line-height) w-full px-(--code-block-padding) leading-(--code-block-line-height)",
+  /* With numbers on, the left inset lives inside the sticky cell instead, so
+     paying it here too would restore the double count the gutter just lost. */
+  "[[data-code-line-numbers]_&]:pl-0"
+)
+
+/* -------------------------------------------------------------------------- */
+/*                                    Folding                                  */
+/* -------------------------------------------------------------------------- */
+
+export type CodeBlockFoldRegion = {
+  /** Source line that owns the toggle and stays visible when folded. */
+  start: number
+  /** Last source line the region swallows. */
+  end: number
+}
+
+const INDENT_RE = /^[ \t]*/
+
+/**
+ * Regions come from indentation, not the grammar: shiki returns tokens, no
+ * AST, and indentation is language-agnostic where a brace matcher is not.
+ * A tab counts as two columns; only self-consistency matters.
+ */
+function computeFoldRegions(lines: CodeBlockLine[]): CodeBlockFoldRegion[] {
+  const indent = (text: string) =>
+    (INDENT_RE.exec(text)?.[0] ?? "").replace(/\t/g, "  ").length
+  const blank = (text: string) => text.trim().length === 0
+
+  /* One pass with an open-region stack instead of a nested scan per line:
+     the old shape was quadratic on monotonically indenting files and re-ran
+     per streamed chunk. A region closes when a non-blank line returns to its
+     opener's indent; trailing blanks belong to whatever follows. */
+  const regions: CodeBlockFoldRegion[] = []
+  const stack: { start: number; indent: number; last: number }[] = []
+  let previous: { number: number; indent: number } | null = null
+
+  for (const line of lines) {
+    if (blank(line.text)) continue
+    const own = indent(line.text)
+
+    while (stack.length && own <= stack[stack.length - 1].indent) {
+      const open = stack.pop()!
+      if (open.last > open.start) {
+        regions.push({ start: open.start, end: open.last })
+      }
+    }
+    for (const open of stack) open.last = line.number
+
+    if (previous && own > previous.indent) {
+      stack.push({
+        start: previous.number,
+        indent: previous.indent,
+        last: line.number,
+      })
+    }
+    previous = { number: line.number, indent: own }
+  }
+
+  while (stack.length) {
+    const open = stack.pop()!
+    if (open.last > open.start)
+      regions.push({ start: open.start, end: open.last })
+  }
+
+  return regions.sort((a, b) => a.start - b.start)
+}
+
+/** Frozen so the disabled path keeps a stable identity across renders. */
+const EMPTY_FOLD_REGIONS: CodeBlockFoldRegion[] = []
+
+/* -------------------------------------------------------------------------- */
+/*                                    Tokens                                   */
+/* -------------------------------------------------------------------------- */
+
+type TokenStyle = CSSProperties & Record<"--cb-c" | "--cb-cd", string>
+
+function tokenStyle(token: CodeBlockToken): CSSProperties | undefined {
+  if (!token.color && !token.colorDark && !token.fontStyle) return undefined
+
+  const style: Partial = {}
+  if (token.color) style["--cb-c"] = token.color
+  if (token.colorDark) style["--cb-cd"] = token.colorDark
+  if (token.fontStyle === "italic") style.fontStyle = "italic"
+  if (token.fontStyle === "bold") style.fontWeight = 700
+  if (token.fontStyle === "underline") style.textDecoration = "underline"
+  return style as CSSProperties
+}
+
+function CodeBlockTokens({ tokens }: { tokens: CodeBlockToken[] }) {
+  return (
+    <>
+      {tokens.map((token, index) => {
+        const style = tokenStyle(token)
+
+        /* An unstyled token needs no element at all. Whitespace and
+           punctuation are most of a file, so this removes most of the spans. */
+        if (!style && !token.word) {
+          return {token.content}
+        }
+
+        return (
+          
+            {token.content}
+          
+        )
+      })}
+    
+  )
+}
+
+/* -------------------------------------------------------------------------- */
+/*                                     Line                                    */
+/* -------------------------------------------------------------------------- */
+
+type CodeBlockLineRowProps = {
+  line: CodeBlockLine
+  selectable: boolean
+  selected: boolean
+  focusMode: boolean
+  active: boolean
+  caret: boolean
+  actions?: CodeBlockLineActionsRender | null
+  actionsSide?: CodeBlockLineActionsSide
+  domIdBase?: string
+  startLine?: number
+  onSelect?: (line: number, extend: boolean) => void
+  foldable?: boolean
+  foldRegion?: CodeBlockFoldRegion
+  folded?: boolean
+  onToggleFold?: (start: number) => void
+}
+
+/**
+ * Plain reference-equality `memo`, on purpose: the highlighter returns the
+ * SAME object for an unchanged line, so a streamed chunk re-renders one row.
+ * A custom comparator would spend that win walking every token.
+ */
+const CodeBlockLineRow = memo(function CodeBlockLineRow({
+  line,
+  selectable,
+  selected,
+  focusMode,
+  active,
+  caret,
+  actions,
+  actionsSide = "end",
+  domIdBase,
+  startLine = 1,
+  onSelect,
+  foldable,
+  foldRegion,
+  folded,
+  onToggleFold,
+}: CodeBlockLineRowProps) {
+  const state = line.state
+  const blurred = focusMode && !state?.focused
+  const lineDomId = domIdBase ? `${domIdBase}-L${line.number}` : undefined
+  const hiddenCount = foldRegion ? foldRegion.end - foldRegion.start : 0
+  /* The fold toggle owns the channel, so a fold-start row keeps its chevron
+     and sends the action back to the row end. */
+  const inGutter = actionsSide === "gutter" && !foldRegion
+
+  return (
+     {
+              if (event.shiftKey) event.preventDefault()
+            }
+          : undefined
+      }
+      onClick={
+        selectable && onSelect
+          ? (event) => onSelect(line.number, event.shiftKey)
+          : undefined
+      }
+      className={cn(
+        LINE_BASE_CLASS,
+        LINE_NUMBER_CLASS,
+        LINE_STATE_CLASS,
+        LINE_DIFF_CLASS,
+        LINE_FOCUS_CLASS,
+        LINE_MOTION_CLASS,
+        selectable && "cursor-pointer"
+      )}
+      /**
+       * Every row re-seeds the counter, making its number absolute rather
+       * than ordinal. That serves two features at once: folding can remove
+       * rows without renumbering the tail, and `content-visibility` can skip
+       * offscreen rows - a style-contained row cannot read a shared counter,
+       * which once rendered every gutter number as 1.
+       */
+      style={{ counterReset: `cb-line ${line.number - 1}` }}
+    >
+      {foldRegion && onToggleFold ? (
+        
+      ) : null}
+      {actions && active ? (
+         event.stopPropagation()}
+          className={cn(
+            /* Inside the 
, so the code surface's mono face, pre
+               whitespace and token color would cascade into real buttons
+               placed here. Reset to app typography at the boundary. */
+            "text-foreground z-20 items-center gap-1 font-sans whitespace-normal select-none",
+            !inGutter &&
+              "absolute end-2 top-0 flex h-(--code-block-line-height)",
+            /* ABSOLUTE and centred on the row's START EDGE: `left-0` is where
+               the code column begins (the row box already excludes the
+               gutter), and the -50% translate hangs the control half over the
+               number column, half over the code - the editor treatment. Being
+               out of flow on BOTH axes is the point: an in-flow version
+               pushed the code right by its own width, and an earlier block
+               level one broke the row onto a new line. The control travels
+               with the code on a horizontal scroll; it cannot also be sticky. */
+            inGutter &&
+              "absolute top-1/2 left-0 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center"
+          )}
+        >
+          {actions({ line: line.number, text: line.text, state })}
+        
+      ) : null}
+      
+        {/* An empty line needs an explicit break. Copying a selection relies on
+            the browser emitting a newline between block-level rows, and a row
+            with no text at all contributes neither text nor separator, so a
+            blank line silently vanishes from the pasted source. */}
+        {line.tokens.length === 0 ? 
: null} + + {folded && hiddenCount > 0 ? ( + /* `select-none` is doing real work: a manual drag-select over a + folded block would otherwise paste this chip into the middle of + the source. The copy button reads the raw code and never sees it. */ + + ) : null} + {caret ? ( + +
+ ) +}) + +/* -------------------------------------------------------------------------- */ +/* Root */ +/* -------------------------------------------------------------------------- */ + +export type CodeBlockProps = { + code?: string + language?: string + lines?: CodeBlockLine[] + themes?: CodeBlockThemes + highlight?: boolean + showLineNumbers?: boolean + startLine?: number + wrap?: boolean + defaultWrap?: boolean + onWrapChange?: (wrap: boolean) => void + maxLines?: number + variant?: "default" | "ghost" + label?: string + highlightedLines?: CodeBlockLineSpec + highlightedWords?: CodeBlockWordSpec[] + focusedLines?: CodeBlockLineSpec + diff?: CodeBlockDiffSpec + lineLevels?: CodeBlockLevelSpec + transformers?: CodeBlockTransformer[] + streaming?: boolean + selectable?: boolean + selectedLines?: number[] + defaultSelectedLines?: number[] + onSelectedLinesChange?: (lines: number[]) => void + /** Screen-reader text announced when a stream finishes, for localisation. */ + completeAnnouncement?: string + /** Collapsed state under `maxLines`, controlled. */ + expanded?: boolean + defaultExpanded?: boolean + onExpandedChange?: (expanded: boolean) => void + /** Detects fold regions from indentation and renders a toggle per region. */ + foldable?: boolean + /** + * Replaces the indentation heuristic with your own regions (source-numbered, + * like every line spec) - grammar folding, JSON blocks, patch hunks. + * Implies nothing about `foldable`; pass both. + */ + foldRegions?: CodeBlockFoldRegion[] + /** Folded regions by start line, 1-based within `code`. Controlled. */ + folded?: number[] + /** Folded regions by their start line, uncontrolled. */ + defaultFolded?: number[] + onFoldedChange?: (folded: number[]) => void + children?: ReactNode +} & Omit, "children" | "onSelect"> + +/** + * The root. `` is a complete block; + * passing `lines` from a server component skips the client highlighter and + * shiki entirely. Children are CHROME ONLY: the root renders the code surface + * itself, so child order never matters and a wrapper cannot break scrolling. + */ +function CodeBlock({ + code, + language, + lines: linesProp, + themes, + highlight = true, + showLineNumbers = false, + startLine = 1, + wrap: wrapProp, + defaultWrap = false, + onWrapChange, + maxLines, + variant = "default", + label, + highlightedLines, + highlightedWords, + focusedLines, + diff, + lineLevels, + transformers, + streaming = false, + selectable = false, + selectedLines, + defaultSelectedLines, + onSelectedLinesChange, + completeAnnouncement, + expanded: expandedProp, + defaultExpanded = false, + onExpandedChange, + foldable = false, + foldRegions: foldRegionsProp, + folded, + defaultFolded, + onFoldedChange, + className, + children, + ...props +}: CodeBlockProps) { + const contentId = useId() + const source = code ?? "" + const resolvedLanguage = resolveCodeBlockLanguage(language) + + /* The deferred copy is what a fast stream tokenises against. React keeps the + previous render on screen while the new one is prepared, so the block never + blanks and the main thread is never blocked by a chunk. */ + const deferredSource = useDeferredValue(source) + + const plainLines = useMemo( + () => toPlainLines(deferredSource, startLine), + [deferredSource, startLine] + ) + + /* The result is tagged with the exact inputs that produced it. Without the + tag, swapping `code` kept rendering the PREVIOUS document for the whole + async highlight pass - or spliced two files together when the old result + was shorter than the new source. */ + const [highlighted, setHighlighted] = useState<{ + source: string + spec: string + lines: CodeBlockLine[] + } | null>(null) + + const shouldHighlight = + !linesProp && highlight && Boolean(resolvedLanguage) && source.length > 0 + + /* Presentation props enter the effect as ONE serialized key: they are + inline literals at most call sites, and reference deps would re-tokenize + per parent render. The effect parses its inputs back out of the key, so + used and depended-on values cannot drift. `transformers` holds functions + and stays a reference dep - hoist it to module scope. */ + const specKey = JSON.stringify([ + themes ?? null, + startLine, + highlightedLines ?? null, + highlightedWords ?? null, + focusedLines ?? null, + diff ?? null, + lineLevels ?? null, + ]) + + const transformersWarnedRef = useRef(false) + const previousTransformersRef = useRef(transformers) + useEffect(() => { + if ( + process.env.NODE_ENV !== "production" && + streaming && + previousTransformersRef.current !== transformers && + !transformersWarnedRef.current + ) { + transformersWarnedRef.current = true + console.warn( + "[code-block] `transformers` changed identity during a stream, which " + + "re-tokenizes the whole document per chunk. Hoist the array to " + + "module scope or memoize it." + ) + } + previousTransformersRef.current = transformers + }, [transformers, streaming]) + + useEffect(() => { + if (!shouldHighlight) { + setHighlighted(null) + return + } + + const [ + specThemes, + specStartLine, + specHighlightedLines, + specHighlightedWords, + specFocusedLines, + specDiff, + specLineLevels, + ] = JSON.parse(specKey) as [ + CodeBlockThemes | null, + number, + CodeBlockLineSpec | null, + CodeBlockWordSpec[] | null, + CodeBlockLineSpec | null, + CodeBlockDiffSpec | null, + CodeBlockLevelSpec | null, + ] + + let active = true + void highlightCode(deferredSource, { + language, + instanceKey: contentId, + themes: specThemes ?? undefined, + transformers, + startLine: specStartLine, + highlightedLines: specHighlightedLines ?? undefined, + highlightedWords: specHighlightedWords ?? undefined, + focusedLines: specFocusedLines ?? undefined, + diff: specDiff ?? undefined, + lineLevels: specLineLevels ?? undefined, + }).then((next) => { + if (active) { + setHighlighted({ source: deferredSource, spec: specKey, lines: next }) + } + }) + + return () => { + active = false + } + }, [shouldHighlight, deferredSource, language, transformers, specKey]) + + /* Plain text is the floor, never a blank frame: the highlighter runs a + chunk behind a stream, so its lines show as-is and the remainder appends + as plain text that gains colour a frame later. */ + const lines = useMemo(() => { + if (linesProp) return linesProp + if (!shouldHighlight || !highlighted) return plainLines + /* The merge below is only valid under the STREAMING invariant: the + highlighted source must be a prefix of what is on screen, with the same + presentation spec. A swapped `code` or spec falls back to plain text + for one highlight pass instead of showing the previous document. */ + if ( + highlighted.spec !== specKey || + !deferredSource.startsWith(highlighted.source) + ) { + return plainLines + } + if (highlighted.lines.length >= plainLines.length) return highlighted.lines + return [...highlighted.lines, ...plainLines.slice(highlighted.lines.length)] + }, [ + linesProp, + shouldHighlight, + highlighted, + plainLines, + deferredSource, + specKey, + ]) + + const focusMode = useMemo( + () => lines.some((line) => line.state?.focused), + [lines] + ) + + const hasDiff = useMemo(() => lines.some((line) => line.state?.diff), [lines]) + + const [internalWrap, setInternalWrap] = useState(defaultWrap) + const wrap = wrapProp ?? internalWrap + const setWrap = useCallback( + (next: boolean) => { + if (wrapProp === undefined) setInternalWrap(next) + onWrapChange?.(next) + }, + [wrapProp, onWrapChange] + ) + + const collapsible = typeof maxLines === "number" && lines.length > maxLines + const [internalExpanded, setInternalExpanded] = useState(defaultExpanded) + const expanded = expandedProp ?? internalExpanded + const setExpanded = useCallback( + (next: boolean) => { + if (expandedProp === undefined) setInternalExpanded(next) + onExpandedChange?.(next) + }, + [expandedProp, onExpandedChange] + ) + const isExpanded = collapsible ? expanded : true + + const [internalSelected, setInternalSelected] = useState( + defaultSelectedLines ?? [] + ) + const selection = selectedLines ?? internalSelected + const selectedSet = useMemo(() => new Set(selection), [selection]) + const lastSelectedRef = useRef(null) + + /* Latest-value refs, so the toggle callbacks below can be created ONCE. + With state in their dep lists every selection or fold change minted new + identities, which reached every row through `onSelect`/`onToggleFold` and + busted the row memo the streaming design rests on. */ + const selectionStateRef = useRef({ + selection, + selectedSet, + controlled: selectedLines !== undefined, + onSelectedLinesChange, + }) + selectionStateRef.current = { + selection, + selectedSet, + controlled: selectedLines !== undefined, + onSelectedLinesChange, + } + + const commitSelection = useCallback((next: number[]) => { + if (!selectionStateRef.current.controlled) setInternalSelected(next) + selectionStateRef.current.onSelectedLinesChange?.(next) + }, []) + + const toggleLine = useCallback( + (line: number, extend: boolean) => { + const anchor = lastSelectedRef.current + const { selection, selectedSet } = selectionStateRef.current + + if (extend && anchor !== null) { + const from = Math.min(anchor, line) + const to = Math.max(anchor, line) + const range: number[] = [] + for (let value = from; value <= to; value += 1) range.push(value) + commitSelection(range) + return + } + + lastSelectedRef.current = line + commitSelection( + selectedSet.has(line) + ? selection.filter((value) => value !== line) + : [...selection, line].sort((a, b) => a - b) + ) + }, + [commitSelection] + ) + + /* Regions are SOURCE-numbered (1-based within `code`), like every other + line spec, so `folded` values and `highlightedLines` values interchange. */ + const foldRegions = useMemo(() => { + if (!foldable) return EMPTY_FOLD_REGIONS + if (foldRegionsProp) return foldRegionsProp + return computeFoldRegions(lines).map((region) => ({ + start: region.start - startLine + 1, + end: region.end - startLine + 1, + })) + }, [foldable, foldRegionsProp, lines, startLine]) + + const foldStarts = useMemo( + () => new Map(foldRegions.map((region) => [region.start, region])), + [foldRegions] + ) + + const [internalFolded, setInternalFolded] = useState( + defaultFolded ?? [] + ) + const foldedValue = folded ?? internalFolded + const foldedSet = useMemo(() => new Set(foldedValue), [foldedValue]) + + /* Same latest-value shape as selection, for the same row-memo reason. */ + const foldStateRef = useRef({ + foldedValue, + foldedSet, + controlled: folded !== undefined, + onFoldedChange, + }) + foldStateRef.current = { + foldedValue, + foldedSet, + controlled: folded !== undefined, + onFoldedChange, + } + + const commitFolded = useCallback((next: number[]) => { + if (!foldStateRef.current.controlled) setInternalFolded(next) + foldStateRef.current.onFoldedChange?.(next) + }, []) + + const toggleFold = useCallback( + (start: number) => { + const { foldedValue, foldedSet } = foldStateRef.current + commitFolded( + foldedSet.has(start) + ? foldedValue.filter((value) => value !== start) + : [...foldedValue, start].sort((a, b) => a - b) + ) + }, + [commitFolded] + ) + + /** + * A line is hidden when ANY folded region covers it, and that one rule is + * what makes nesting work without a tree: folding an outer region swallows + * the inner toggles as well, their own folded state survives untouched + * underneath, and unfolding the outer one restores exactly what the reader + * left behind rather than a flattened block. + */ + const renderedLines = useMemo(() => { + if (!foldable || foldedSet.size === 0) return lines + const hidden = new Set() + for (const start of foldedSet) { + const region = foldStarts.get(start) + if (!region) continue + for (let n = region.start + 1; n <= region.end; n += 1) { + hidden.add(n + startLine - 1) + } + } + return lines.filter((line) => !hidden.has(line.number)) + }, [foldable, foldedSet, foldStarts, lines, startLine]) + + const configValue = useMemo( + () => ({ + language, + resolvedLanguage, + showLineNumbers, + wrap, + setWrap, + wrapControlled: wrapProp !== undefined, + expanded: isExpanded, + setExpanded, + collapsible, + streaming, + contentId, + }), + [ + language, + resolvedLanguage, + showLineNumbers, + wrap, + setWrap, + wrapProp, + isExpanded, + collapsible, + streaming, + contentId, + ] + ) + + const clearSelection = useCallback( + () => commitSelection([]), + [commitSelection] + ) + + const documentValue = useMemo( + () => ({ + code: source, + lines, + selected: selectedSet, + selectable, + toggleLine, + clearSelection, + foldable, + foldRegions, + folded: foldedSet, + toggleFold, + setFolded: commitFolded, + }), + [ + source, + lines, + selectedSet, + selectable, + toggleLine, + clearSelection, + foldable, + foldRegions, + foldedSet, + toggleFold, + commitFolded, + ] + ) + + const visibleLines = collapsible && !isExpanded ? maxLines : undefined + + /* Detected from the element type rather than a `:has()` selector. This repo + has measured `:has()` at 85-120ms of style invalidation per DOM mutation, + and a streaming block mutates on every chunk, so the primitive uses none. */ + const hasHeader = containsElementType(children, CodeBlockHeader) + + const [lineActions, setLineActions] = + useState(null) + const [actionsSide, setActionsSide] = + useState("end") + const actionsOwnerRef = useRef(null) + const actionsRegistry = useMemo( + () => ({ + /* Wrapped in a setter callback: a render prop IS a function, so passing + it to setState bare would run it as a state updater. Ownership makes + a stale unmount a no-op and makes a SECOND live group loud in dev - + the registry holds one render prop, so the second silently won. */ + register: ( + owner: object, + render: CodeBlockLineActionsRender | null, + side: CodeBlockLineActionsSide = "end" + ) => { + if (render) { + if ( + process.env.NODE_ENV !== "production" && + actionsOwnerRef.current && + actionsOwnerRef.current !== owner + ) { + console.warn( + "[code-block] Two CodeBlockLineActions are mounted; the block " + + "renders ONE action group and the later mount replaces the " + + "earlier one." + ) + } + actionsOwnerRef.current = owner + } else if (actionsOwnerRef.current !== owner) { + return + } else { + actionsOwnerRef.current = null + } + setLineActions(() => render) + setActionsSide(side) + }, + }), + [] + ) + + /* One channel between the numbers and the code, shared by the fold toggle + and a gutter-side action. Reserved only when something asks for it, so a + plain block keeps the gutter it always had. */ + /* Only FOLDING reserves gutter space. A gutter-side action floats over the + row's start edge instead of widening anything, so a block with actions is + geometrically identical to one without. */ + const gutterChannel = foldable + + /* Walked rather than registered, so server HTML never carries a doubled + surface. Sees through plain wrappers; a consumer component boundary is + opaque, which the dev-mode guard in CodeBlockContent reports. */ + const hasContent = containsElementType(children, CodeBlockContent) + + const surfaceProps: CodeBlockSurfaceProps = { + lines: renderedLines, + gutterMax: lines[lines.length - 1]?.number ?? startLine, + foldable, + gutterChannel, + actionsSide, + foldStarts, + foldedSet, + onToggleFold: toggleFold, + expandClearance: collapsible && isExpanded, + wrap, + startLine, + showLineNumbers, + focusMode, + selectable, + selectedSet, + toggleLine, + visibleLines, + streaming, + label: label ?? (language ? `${language} code` : "Code"), + contentId, + actions: lineActions, + completeAnnouncement, + builtInSurfaceRendered: !hasContent, + } + + return ( + + + +
+ + {children} + {!hasContent && } + +
+
+
+
+ ) +} + +/** + * The code surface as a composable part: inside your own ScrollArea the block + * stops scrolling internally and the ancestor owns both axes (`maxLines`' + * height cap no longer applies). Omitted, the root renders the same surface + * itself, exactly as before. + */ +function CodeBlockContent({ className }: { className?: string }) { + const surface = useContext(CodeBlockSurfacePropsContext) + if (!surface) { + throw new Error("CodeBlockContent must be used within a CodeBlock") + } + + /* The render-time walk cannot see through a consumer's own component + boundary; when that happens the root also renders its built-in surface + and the code appears twice. Silent double content is the worst failure + mode, so development says exactly what to do. */ + useEffect(() => { + if (process.env.NODE_ENV === "production") return + if (surface.builtInSurfaceRendered) { + console.error( + "CodeBlockContent is hidden behind a component boundary, so CodeBlock " + + "also rendered its built-in surface and the code appears twice. " + + "Compose directly in the CodeBlock's children " + + "(plain wrappers like a ScrollArea are fine)." + ) + } + }, [surface.builtInSurfaceRendered]) + + return +} + +/** + * Sees through plain element wrappers (a ScrollArea, a div) but not through a + * consumer's own component boundary - unrendered children are opaque. Compose + * `CodeBlockContent` directly in the block's subtree. + */ +function containsElementType(node: ReactNode, type: unknown): boolean { + for (const child of Children.toArray(node)) { + if (!isValidElement(child)) continue + if (child.type === type) return true + const inner = (child.props as { children?: ReactNode }).children + if (inner && containsElementType(inner, type)) return true + } + return false +} + +/* -------------------------------------------------------------------------- */ +/* Surface */ +/* -------------------------------------------------------------------------- */ + +type CodeBlockSurfaceProps = { + lines: CodeBlockLine[] + wrap: boolean + startLine: number + showLineNumbers: boolean + focusMode: boolean + selectable: boolean + selectedSet: Set + toggleLine: (line: number, extend: boolean) => void + visibleLines?: number + gutterMax: number + foldable: boolean + gutterChannel: boolean + actionsSide: CodeBlockLineActionsSide + foldStarts: Map + foldedSet: Set + onToggleFold: (start: number) => void + expandClearance: boolean + streaming: boolean + label: string + contentId: string + actions?: CodeBlockLineActionsRender | null + completeAnnouncement?: string + /** true when the root rendered its own surface (no CodeBlockContent found). */ + builtInSurfaceRendered?: boolean + /** false = no scroll container of its own; an ancestor scrolls instead. */ + scroll?: boolean + className?: string +} + +function CodeBlockSurface({ + lines, + wrap, + startLine, + showLineNumbers, + focusMode, + selectable, + selectedSet, + toggleLine, + visibleLines, + gutterMax, + foldable, + gutterChannel, + actionsSide, + foldStarts, + foldedSet, + onToggleFold, + expandClearance, + streaming, + label, + contentId, + actions, + completeAnnouncement, + scroll = true, + className, +}: CodeBlockSurfaceProps) { + const viewportRef = useRef(null) + const preRef = useRef(null) + const [activeLine, setActiveLine] = useState(null) + const stickRef = useRef(true) + + /* Stick-to-bottom disengages the moment the reader scrolls up, and re-engages + when they come back. Reading the scroll position on the event is enough + here because nothing is written back during the same frame. */ + /* Announced once when a stream ends, so a screen reader learns the code is + complete without hearing it assembled character by character. */ + const [announcement, setAnnouncement] = useState("") + const wasStreaming = useRef(streaming) + useEffect(() => { + if (wasStreaming.current && !streaming) { + setAnnouncement( + completeAnnouncement ?? + `Code generation complete, ${lines.length} lines.` + ) + } + wasStreaming.current = streaming + }, [streaming, lines.length, completeAnnouncement]) + + /* Stick-to-bottom follows whichever element ACTUALLY scrolls - writing + scrollTop to the composed mode's inert viewport was a silent no-op. + Resolved per commit: a short block becomes its own scroller the moment + it outgrows its cap. */ + const resolveScroller = useCallback((): HTMLElement | null => { + const viewport = viewportRef.current + if (!viewport) return null + if (viewport.scrollHeight > viewport.clientHeight + 1) return viewport + + let node: HTMLElement | null = viewport.parentElement + while (node && node !== document.body) { + const overflowY = getComputedStyle(node).overflowY + if ( + (overflowY === "auto" || + overflowY === "scroll" || + overflowY === "overlay") && + node.scrollHeight > node.clientHeight + 1 + ) { + return node + } + node = node.parentElement + } + + /* An AI transcript's default shape scrolls the PAGE. The walk above never + reaches the root elements, so without this branch the stream wrote + scrollTop to an inert viewport and drifted off screen. */ + const page = document.scrollingElement as HTMLElement | null + if (page && page.scrollHeight > page.clientHeight + 1) return page + + return viewport + }, []) + + const handleScroll = useCallback(() => { + const viewport = viewportRef.current + if (!viewport) return + const distance = + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight + stickRef.current = distance < 24 + }, []) + + /* The ancestor case needs its own listener: the reader's intent has to be + recorded BEFORE the next chunk grows the content, or the distance already + includes what just arrived. Re-attached per chunk rather than diffed, + which is a handful of listener swaps a second and keeps this correct when + the scrolling element changes underneath. */ + useEffect(() => { + if (!streaming) return + const scroller = resolveScroller() + if (!scroller || scroller === viewportRef.current) return + + const onScroll = () => { + const distance = + scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight + stickRef.current = distance < 24 + } + + /* Page scrolls fire on window, not on the scrolling element. */ + const target: EventTarget = + scroller === document.scrollingElement ? window : scroller + target.addEventListener("scroll", onScroll, { passive: true }) + return () => target.removeEventListener("scroll", onScroll) + }, [streaming, lines.length, resolveScroller]) + + useIsoLayoutEffect(() => { + if (!streaming || !stickRef.current) return + const scroller = resolveScroller() + if (scroller) scroller.scrollTop = scroller.scrollHeight + }, [lines, streaming, resolveScroller]) + + /* One listener for the whole surface rather than a handler per line: the + active row is resolved from the event target, so the floating action group + costs one element no matter how long the file is. Focus is tracked as well + as hover, or the actions would be unreachable without a pointer. */ + const trackActive = useCallback( + (event: { target: EventTarget | null }) => { + if (!actions && !selectable) return + const target = event.target as HTMLElement | null + const row = target?.closest?.("[data-code-line]") as HTMLElement | null + const value = row?.getAttribute("data-code-line") + + /* No row under the pointer means the gutter, which lives in the
's
+       PADDING and so is covered by no row element. Keep the active line
+       rather than clearing it: clearing here unmounted a gutter action the
+       moment the pointer left the code toward it, so the control vanished
+       before it could be pressed. `onPointerLeave` still clears on exit. */
+      if (!value) return
+      setActiveLine(Number(value))
+    },
+    [actions, selectable]
+  )
+
+  const clearActive = useCallback(() => setActiveLine(null), [])
+
+  /* Rows and keyboard speak DISPLAYED numbers (the gutter, data-code-line);
+     selection state speaks SOURCE numbers like every other line spec. This is
+     the one conversion point between them. */
+  const toggleDisplayed = useCallback(
+    (displayed: number, extend: boolean) =>
+      toggleLine(displayed - startLine + 1, extend),
+    [toggleLine, startLine]
+  )
+
+  const handleKeyDown = useCallback(
+    (event: ReactKeyboardEvent) => {
+      if (!selectable) return
+
+      if (event.key === "Escape") {
+        setActiveLine(null)
+        return
+      }
+
+      /* Toggle IN PLACE. Shift+Arrow selects while moving, but without this a
+         keyboard user could never deselect a single line. */
+      if ((event.key === "Enter" || event.key === " ") && activeLine !== null) {
+        event.preventDefault()
+        toggleDisplayed(activeLine, event.shiftKey)
+        return
+      }
+
+      const isArrow = event.key === "ArrowDown" || event.key === "ArrowUp"
+      if (!isArrow && event.key !== "Home" && event.key !== "End") return
+      event.preventDefault()
+
+      /* Step by RENDERED index, not by number arithmetic: folding removes
+         rows, and numeric stepping walked the active line into hidden lines
+         where it silently vanished. */
+      let next: number
+      if (event.key === "Home") {
+        next = lines[0]?.number ?? startLine
+      } else if (event.key === "End") {
+        next = lines[lines.length - 1]?.number ?? startLine
+      } else {
+        const step = event.key === "ArrowDown" ? 1 : -1
+        const index = lines.findIndex((line) => line.number === activeLine)
+        const nextIndex =
+          index === -1
+            ? step === 1
+              ? 0
+              : lines.length - 1
+            : Math.min(lines.length - 1, Math.max(0, index + step))
+        next = lines[nextIndex]?.number ?? startLine
+      }
+
+      setActiveLine(next)
+      const row = preRef.current?.querySelector(
+        `[data-code-line="${next}"]`
+      )
+      row?.scrollIntoView({ block: "nearest" })
+      if (isArrow && event.shiftKey) toggleDisplayed(next, true)
+    },
+    [selectable, lines, startLine, activeLine, toggleDisplayed]
+  )
+
+  const lastLineNumber = lines[lines.length - 1]?.number
+
+  /* `startLine` is just a counter reset. Gutter width follows the widest
+     number in the DOCUMENT (not the folded view, or folding the tail would
+     shift every row); `ch` is one digit in the mono `pre`, and the floor of 2
+     stops the column twitching as a stream crosses line 9. Lines carrying
+     their own gutter label (a patch's dual numbers) widen it to the longest
+     label instead. */
+  const gutterDigits = Math.max(
+    2,
+    String(gutterMax ?? startLine).length,
+    ...lines.map((line) => line.gutter?.length ?? 0)
+  )
+
+  const surfaceStyle = {
+    "--code-block-gutter-width": gutterChannel
+      ? `calc(${gutterDigits}ch + var(--code-block-fold-width) + var(--code-block-gutter-gap) + var(--code-block-padding))`
+      : `calc(${gutterDigits}ch + var(--code-block-gutter-gap) + var(--code-block-padding))`,
+    counterReset: `cb-line ${startLine - 1}`,
+  } as CSSProperties
+
+  return (
+    
+ {/* A polite status, deliberately NOT an aria-live region on the code + itself: a live region over streaming code narrates every token. */} + + {announcement} + + {/* A plain overflow container, deliberately not a ScrollArea primitive. + The scroll must stay INSIDE the block (the sticky gutter, the + stick-to-bottom stream and `maxLines` all anchor to it), but nothing + here needs custom scrollbar machinery, and dropping it leaves the + block with no UI-library dependency at all - which is why both twins + are the same file. Compose Card, Frame, or your own ScrollArea + around the block for outer framing. */} +
+
+          {/* `content-visibility: auto` is a measured dead end on BOTH axes:
+                  its style containment breaks the shared gutter counter
+                  (every number renders as 1), and per-row it still fails -
+                  the paint containment clips the number cell, which is
+                  deliberately pulled left OUT of the row's box (verified in
+                  Chrome: forcing one row visible restored only that row's
+                  number). Windowing here means rendering fewer rows, not
+                  containing them. */}
+          {/* presentation, so the listbox owns its options directly. */}
+          
+            {lines.map((line) => (
+              
+            ))}
+          
+        
+
+
+ ) +} + +/* -------------------------------------------------------------------------- */ +/* Chrome */ +/* -------------------------------------------------------------------------- */ + +/** + * Opt-in header. Absent, the block renders no chrome, so it nests in a Card or + * Frame without a doubled bar. Presence flips `data-has-header` on the root, + * detected from the element type rather than a `:has()` selector. + */ +function CodeBlockHeader({ className, ...props }: ComponentProps<"div">) { + return ( + +
+ + ) +} + +function CodeBlockTitle({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ) +} + +/** + * The resolved language as a label, no props needed. It shows what the + * highlighter actually resolved, so an unsupported grammar becomes visible + * instead of silently rendering plain. + */ +function CodeBlockLanguage({ + className, + children, + ...props +}: ComponentProps<"span">) { + const { language, resolvedLanguage } = useInternalConfig("CodeBlockLanguage") + const label = children ?? resolvedLanguage ?? language + + if (!label) return null + + return ( + + {label} + + ) +} + +/* -------------------------------------------------------------------------- */ +/* Copy button */ +/* -------------------------------------------------------------------------- */ + +function CopyIcon({ copied }: { copied: boolean }) { + return copied ? ( + + ) : ( + + ) +} + +export type CodeBlockCopyButtonProps = { + value?: string + timeout?: number + onCopy?: (value: string) => void + onCopyError?: (error: unknown) => void + /** Accessible names, for localisation. */ + labels?: { copy?: string; copied?: string } + /** `pinned` floats over the surface; `inline` sits in a header. */ + position?: "auto" | "pinned" | "inline" + alwaysVisible?: boolean +} & Omit, "value" | "onCopy"> + +/** + * Copy button. Inline inside a header, pinned over the surface anywhere else. + * Pinning is absolute against the root, not `position: sticky`: sticky still + * slides away under HORIZONTAL scroll, and code scrolls sideways constantly. + * Notation comments are stripped from the payload, matching what is rendered. + */ +function CodeBlockCopyButton({ + value, + timeout = 2000, + onCopy, + onCopyError, + labels, + position = "auto", + alwaysVisible = false, + className, + variant = "ghost", + size, + children, + ...props +}: CodeBlockCopyButtonProps) { + const context = useContext(CodeBlockConfigContext) + const inHeader = useContext(CodeBlockHeaderContext) + const [copied, setCopied] = useState(false) + const [copyFailed, setCopyFailed] = useState(false) + + const resolvedPosition = + position === "auto" ? (inHeader ? "inline" : "pinned") : position + + /* A header is chrome, so the button takes the smaller icon rung there and the + full one when it floats over the code. Both are per-style ladders in the + shadcn button, so this stays style-aware instead of pinning a pixel size. */ + const resolvedSize = + size ?? (resolvedPosition === "inline" ? "icon-sm" : "icon") + + useEffect(() => { + if (!copied || timeout === 0) return + const id = window.setTimeout(() => setCopied(false), timeout) + return () => window.clearTimeout(id) + }, [copied, timeout]) + + useEffect(() => { + if (!copyFailed) return + const id = window.setTimeout(() => setCopyFailed(false), 2000) + return () => window.clearTimeout(id) + }, [copyFailed]) + + const document_ = useContext(CodeBlockDocumentContext) + + const handleClick = useCallback(() => { + /* Server-highlighted blocks pass `lines` and no `code`, so the document + text is the fallback; without it this rendered a labelled control that + did nothing on the exact path the docs advertise. */ + const fallback = + document_?.code || document_?.lines.map((line) => line.text).join("\n") + const payload = stripNotationComments(value ?? fallback ?? "") + if (!payload) return + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + return + } + + navigator.clipboard.writeText(payload).then( + () => { + setCopied(true) + onCopy?.(payload) + }, + /* Rejection is routine: a denied permission, or a document that lost + focus. Without the handler it surfaced as an unhandled rejection in + the consumer's error monitoring. */ + (error: unknown) => { + setCopyFailed(true) + onCopyError?.(error) + } + ) + }, [value, context, document_, onCopy, onCopyError]) + + return ( + + ) +} + +/* -------------------------------------------------------------------------- */ +/* Wrap and expand */ +/* -------------------------------------------------------------------------- */ + +function CodeBlockWrapToggle({ + className, + children, + variant = "ghost", + size = "sm", + onClick, + ...props +}: ComponentProps) { + const { wrap, setWrap } = useInternalConfig("CodeBlockWrapToggle") + + return ( + + ) +} + +/** + * Expands a block collapsed by `maxLines`. + * + * Renders nothing when the content is shorter than the cut, so a consumer can + * always compose it without guarding on line count themselves. + */ +function CodeBlockExpandButton({ + className, + children, + variant = "ghost", + size = "sm", + onClick, + ...props +}: ComponentProps) { + const { expanded, setExpanded, collapsible, contentId } = useInternalConfig( + "CodeBlockExpandButton" + ) + + if (!collapsible) return null + + return ( +
+ +
+ ) +} + +/* -------------------------------------------------------------------------- */ +/* Line actions */ +/* -------------------------------------------------------------------------- */ + +export type CodeBlockLineActionsProps = { + children: CodeBlockLineActionsRender + /** + * `end` floats the group over the end of the active row. `gutter` puts it in + * the channel beside the line number, which is where a "add this line" + * affordance belongs. A fold toggle owns that channel when both are on, so a + * gutter action on a fold-start row falls back to `end`. + */ + side?: CodeBlockLineActionsSide +} + +/** + * Actions on the hovered or focused line. ONE group exists for the whole + * block, rendered inside the active row: a 2,000 line file mounts one button + * group, not 2,000. + */ +const LANGUAGE_EXTENSIONS: Record = { + bash: "sh", + c: "c", + cpp: "cpp", + csharp: "cs", + css: "css", + diff: "patch", + docker: "dockerfile", + go: "go", + graphql: "graphql", + html: "html", + java: "java", + javascript: "js", + json: "json", + jsx: "jsx", + kotlin: "kt", + markdown: "md", + php: "php", + python: "py", + ruby: "rb", + rust: "rs", + sql: "sql", + swift: "swift", + toml: "toml", + tsx: "tsx", + typescript: "ts", + xml: "xml", + yaml: "yaml", +} + +export type CodeBlockDownloadButtonProps = { + /** Text to save. Defaults to the block's code with notation stripped. */ + value?: string + /** Defaults to `code.` from the block's language. */ + filename?: string + onDownload?: (filename: string) => void + /** `pinned` floats over the surface; `inline` sits in a header. */ + position?: "auto" | "pinned" | "inline" + alwaysVisible?: boolean + /** Accessible name, for localisation. */ + label?: string +} & Omit, "value"> + +/** + * Saves the block's code as a file - the sibling the copy button was missing + * for builder-style output, where the result IS a file and disk is the next + * step. Same placement contract as the copy button. + */ +function CodeBlockDownloadButton({ + value, + filename, + onDownload, + position = "auto", + alwaysVisible = false, + label, + className, + variant = "ghost", + size, + children, + ...props +}: CodeBlockDownloadButtonProps) { + const context = useContext(CodeBlockConfigContext) + const document_ = useContext(CodeBlockDocumentContext) + const inHeader = useContext(CodeBlockHeaderContext) + + const resolvedPosition = + position === "auto" ? (inHeader ? "inline" : "pinned") : position + const resolvedSize = + size ?? (resolvedPosition === "inline" ? "icon-sm" : "icon") + + const handleClick = useCallback(() => { + const fallback = + document_?.code || document_?.lines.map((line) => line.text).join("\n") + const payload = stripNotationComments(value ?? fallback ?? "") + if (!payload || typeof window === "undefined") return + + const extension = + LANGUAGE_EXTENSIONS[context?.resolvedLanguage ?? ""] ?? "txt" + const name = filename ?? `code.${extension}` + const url = URL.createObjectURL( + new Blob([payload], { type: "text/plain;charset=utf-8" }) + ) + const anchor = window.document.createElement("a") + anchor.href = url + anchor.download = name + anchor.click() + /* Deferred: revoking synchronously races the browser starting the save. */ + window.setTimeout(() => URL.revokeObjectURL(url), 1000) + onDownload?.(name) + }, [value, filename, context, document_, onDownload]) + + return ( + + ) +} + +function CodeBlockLineActions({ + children, + side = "end", +}: CodeBlockLineActionsProps) { + const registry = useContext(CodeBlockActionsContext) + const owner = useRef({}).current + + useEffect(() => { + registry?.register(owner, children, side) + return () => registry?.register(owner, null) + }, [registry, owner, children, side]) + + return null +} + +/** + * Live folding for anything composed inside the block - a "fold all" control, + * a region count. Mirrors `useCodeBlockSelection`; region and start numbers + * are source-based like every line spec. + */ +function useCodeBlockFolding() { + const document = useContext(CodeBlockDocumentContext) + if (!document) { + throw new Error("useCodeBlockFolding must be used within a CodeBlock") + } + + const { foldable, foldRegions, folded, toggleFold, setFolded } = document + const foldedStarts = useMemo( + () => [...folded].sort((a, b) => a - b), + [folded] + ) + const foldAll = useCallback( + () => setFolded(foldRegions.map((region) => region.start)), + [setFolded, foldRegions] + ) + const unfoldAll = useCallback(() => setFolded([]), [setFolded]) + + return { + foldable, + regions: foldRegions, + foldedStarts, + toggleFold, + foldAll, + unfoldAll, + } +} + +/** + * Live selection for anything composed inside the block - a line action, a + * header control. Returns the selected lines as a sorted array plus the same + * toggle the rows use, so an "add selection to chat" affordance can act on the + * whole range instead of only the hovered line. + */ +function useCodeBlockSelection() { + const document = useContext(CodeBlockDocumentContext) + if (!document) { + throw new Error("useCodeBlockSelection must be used within a CodeBlock") + } + + const { selected, selectable, toggleLine, clearSelection } = document + const selectedLines = useMemo( + () => [...selected].sort((a, b) => a - b), + [selected] + ) + + return { selectable, selectedLines, toggleLine, clearSelection } +} + +/* Re-exported so a consumer wires an AI transcript from one import path. */ +export { markdownCodeProps, markdownFences } +export { + ansiToLines, + parseUnifiedDiff, +} from "@/components/reui/code-block/code-block-highlight" +export type { + CodeBlockDiffSpec, + CodeBlockHighlightOptions, + CodeBlockLevelSpec, + CodeBlockLine, + CodeBlockLineActionsRender, + CodeBlockLineSpec, + CodeBlockLineState, + CodeBlockPatchFile, + CodeBlockThemes, + CodeBlockToken, + CodeBlockTransformer, + CodeBlockWordSpec, +} from "@/components/reui/code-block/code-block-highlight" + +export { + CodeBlock, + CodeBlockCopyButton, + CodeBlockDownloadButton, + CodeBlockExpandButton, + CodeBlockHeader, + CodeBlockLanguage, + CodeBlockLineActions, + CodeBlockTitle, + CodeBlockContent, + CodeBlockWrapToggle, + useCodeBlockConfig, + useCodeBlockFolding, + useCodeBlockSelection, +} \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-cell-selection.tsx b/apps/web/src/components/reui/data-grid/data-grid-cell-selection.tsx new file mode 100644 index 0000000..6d9e020 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-cell-selection.tsx @@ -0,0 +1,2605 @@ +import { useEffect, useRef, useState } from "react" +import type { + CSSProperties, + KeyboardEvent as ReactKeyboardEvent, + ReactNode, + RefObject, +} from "react" +import { createPortal } from "react-dom" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import type { + DataGridCellChange, + DataGridCellRejection, + DataGridCellsChangeDetails, + DataGridColumnCellEdit, + DataGridTableInstance, +} from "@/components/reui/data-grid/data-grid" +import { Subscribe } from "@tanstack/react-table" + +import { cn } from "@evobgp/ui/lib/utils" +import { Button } from "@evobgp/ui/components/button" + +/** Where a finished edit sends the focused cell, or null to stay. */ +type DataGridEditorAdvance = "down" | "up" | "right" | "left" | null + +interface DataGridFocusCellOptions { + /** Also open the cell's editor, the way Enter would. */ + edit?: boolean +} + +/** Imperative surface of the cell-selection controller. */ +interface DataGridCellSelectionApi { + /** + * Moves the grid's cell focus AND the document focus together, retrying + * briefly while the row mounts, so a freshly created row is typeable + * without an extra click. `edit: true` also opens the cell's editor the + * way Enter would. Registered once the controller is wired; null while + * cell selection is off. + */ + focusCell: ( + rowId: string, + columnId: string, + options?: DataGridFocusCellOptions + ) => void + /** Clears every range and the focused cell. */ + clearSelection: () => void + /** Scrolls a rendered cell into view without moving focus. */ + scrollToCell: (rowId: string, columnId: string) => void +} + +/** One open built-in editor: which cell, which control, and its text. */ +interface DataGridEditorSession { + rowId: string + columnId: string + control: "text" | "textarea" + /** What the editor opens with: the typed seed or the formatted value. */ + initialValue: string + /** The formatted current value; an unchanged commit dispatches nothing. */ + baseline: string + /** Accessible name for the overlay control, from the column's header. */ + label: string +} + +/* ------------------------------------------------------------------------- * + * Clipboard text: pure, node-testable. + * ------------------------------------------------------------------------- */ + +function serializeDataGridClipboardField(field: string): string { + // Excel and Sheets quote a field containing a delimiter and double inner + // quotes; everything else ships bare. + return /[\t\n\r"]/.test(field) + ? '"' + field.replaceAll('"', '""') + '"' + : field +} + +/** Row-major grid to TSV, CRLF rows, Excel-style quoting. */ +function serializeDataGridClipboardText(grid: string[][]): string { + return grid + .map((line) => line.map(serializeDataGridClipboardField).join("\t")) + .join("\r\n") +} + +/** + * TSV to a row-major grid. A stateful scan, not a split: a quoted field may + * contain tabs, quotes and both newline flavors, which is exactly what Excel + * emits for a multi-line cell. + */ +function parseDataGridClipboardText(text: string): string[][] { + const rows: string[][] = [] + let row: string[] = [] + let field = "" + let quoted = false + let i = 0 + + while (i < text.length) { + const ch = text[i]! + if (quoted) { + if (ch === '"') { + if (text[i + 1] === '"') { + field += '"' + i += 2 + continue + } + quoted = false + i += 1 + continue + } + field += ch + i += 1 + continue + } + if (ch === '"' && field === "") { + quoted = true + i += 1 + continue + } + if (ch === "\t") { + row.push(field) + field = "" + i += 1 + continue + } + if (ch === "\r" || ch === "\n") { + if (ch === "\r" && text[i + 1] === "\n") i += 1 + row.push(field) + rows.push(row) + row = [] + field = "" + i += 1 + continue + } + field += ch + i += 1 + } + row.push(field) + rows.push(row) + + // Excel terminates the payload with one newline; that final empty line is + // an artifact of the format, not a data row. + const last = rows[rows.length - 1] + if (rows.length > 1 && last?.length === 1 && last[0] === "") rows.pop() + + return rows +} + +/** Repeats a block cyclically to fill rowCount x columnCount. */ +function tileDataGridClipboardBlock( + block: string[][], + rowCount: number, + columnCount: number +): string[][] { + const tiled: string[][] = [] + for (let r = 0; r < rowCount; r++) { + const source = block[r % block.length] ?? [] + const line: string[] = [] + for (let c = 0; c < columnCount; c++) { + line.push(source[c % (source.length || 1)] ?? "") + } + tiled.push(line) + } + return tiled +} + +function escapeDataGridClipboardHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") +} + +// A minimal text/html flavor alongside the TSV keeps type fidelity when the +// paste target is Excel or Sheets. Newlines inside a field become
, the +// HTML representation of a multi-line cell; raw newlines would collapse to +// spaces on paste. +function renderDataGridClipboardHtml(grid: string[][]): string { + const body = grid + .map( + (line) => + "" + + line + .map( + (field) => + "" + + escapeDataGridClipboardHtml(field).replace( + /\r\n|\r|\n/g, + "
" + ) + + "" + ) + .join("") + + "" + ) + .join("") + return "" + body + "
" +} + +/* ------------------------------------------------------------------------- * + * Selection geometry: walks in the feature's own index space. + * ------------------------------------------------------------------------- */ + +/** + * Columns in render order, the same [start, center, end] space + * cellSelectionFeature resolves its bounds against. + */ +function getDataGridDisplayOrderedColumns( + table: DataGridTableInstance +) { + return [ + ...table.getStartVisibleLeafColumns(), + ...table.getCenterVisibleLeafColumns(), + ...table.getEndVisibleLeafColumns(), + ] +} + +function getDataGridWritableCellEdit( + column: { + columnDef: { meta?: { cellEdit?: DataGridColumnCellEdit } } + }, + /** + * The row a write targets. Function-form `editable` is evaluated against + * it; without a row the column counts as writable in principle, the + * coarse check the fill preview's column filter uses. + */ + row?: TData +): DataGridColumnCellEdit | null { + const cellEdit = column.columnDef.meta?.cellEdit + if (!cellEdit || cellEdit.editable === false) return null + if ( + typeof cellEdit.editable === "function" && + row !== undefined && + !cellEdit.editable(row) + ) { + return null + } + return cellEdit +} + +/** + * Ids of the rows the view can actually show: the page slice plus rows the + * DOM renders outside it (pinned rows under keepPinnedRows). Selection + * bounds live in pre-paginated display order, so a range whose corners are + * both visible can still span off-page rows in between - a page-boundary + * range to a bottom-pinned draft is the standing example. Every batch walk + * (copy, clear, paste, fill) filters rows through this set so the cells a + * batch touches are exactly the cells the selection paints. + */ +function getDataGridRenderedRowIds( + table: DataGridTableInstance, + viewport: HTMLElement | null | undefined +): Set | null { + if (!viewport) return null + // When the page slice covers the whole display order (manual pagination, + // virtualization, no pagination) no row can be off-page: skip the walk. + if (table.getRowModel().rows.length === table.getRowsInDisplayOrder().length) { + return null + } + const rendered = new Set( + table.getRowModel().rows.map((row: { id: string }) => row.id) + ) + for (const rowEl of Array.from( + viewport.querySelectorAll("tbody tr[data-row-id]") + )) { + const id = rowEl.getAttribute("data-row-id") + if (id) rendered.add(id) + } + return rendered +} + +/** + * Cells of the current selection the view can actually show, under the + * same row filter the batch builders use, so a count shown next to the + * selection always matches what is painted and what a batch will touch. + * The feature's own getSelectedCellCount spans off-page rows instead. + */ +function getDataGridVisibleSelectedCellCount( + table: DataGridTableInstance, + /** The grid's body viewport; null falls back to the full-range count. */ + viewport: HTMLElement | null +): number { + const bounds = table.getCellSelectionBounds() + if (!bounds.length) return 0 + const rows = table.getRowsInDisplayOrder() + const columns = getDataGridDisplayOrderedColumns(table) + const rendered = getDataGridRenderedRowIds(table, viewport) + let count = 0 + for (const bound of bounds) { + let rowCount = 0 + for (let r = bound.minRowIndex; r <= bound.maxRowIndex; r++) { + const row = rows[r] + if (row && (!rendered || rendered.has(row.id))) rowCount += 1 + } + let columnCount = 0 + for (let c = bound.minColumnIndex; c <= bound.maxColumnIndex; c++) { + const column = columns[c] + if (column && column.columnDef.enableCellSelection !== false) { + columnCount += 1 + } + } + count += rowCount * columnCount + } + return count +} + +/** + * The bound of the ACTIVE (most recent) region. getCellSelectionBounds() + * sorts geometrically, so its last entry is merely the bottom-right + * rectangle; the focused cell anchors the newest range, so the bound + * containing it is the active one. Falls back to the geometric last when + * the focused cell sits in no bound (the newest range was an exclude). + */ +function getDataGridActiveBound( + table: DataGridTableInstance +) { + const bounds = table.getCellSelectionBounds() + if (!bounds.length) return null + const focused = table.getFocusedCell() + if (focused) { + // O(1): v9 caches the display index on the row itself. + const rowIndex = focused.row.getDisplayIndex() + const columnIndex = getDataGridDisplayOrderedColumns(table).findIndex( + (column) => column.id === focused.column.id + ) + const containing = bounds.find( + (bound) => + rowIndex >= bound.minRowIndex && + rowIndex <= bound.maxRowIndex && + columnIndex >= bound.minColumnIndex && + columnIndex <= bound.maxColumnIndex + ) + if (containing) return containing + } + return bounds[bounds.length - 1] ?? null +} + +/** + * Resolves the cell one visual step away from a given cell: selectable + * columns for horizontal, RENDERED row order for vertical. The feature's + * own move/extend resolve rows through the page slice and stall on pinned + * rows (a bottom-pinned draft), while id-addressed cells work everywhere. + * "edge" means the step is at the grid boundary; null means the position + * is not resolvable here and the caller should use the feature's move. + */ +function getDataGridStepTarget( + table: DataGridTableInstance, + viewport: HTMLElement, + from: { rowId: string; columnId: string }, + direction: "up" | "down" | "left" | "right" +): { rowId: string; columnId: string } | "edge" | null { + if (direction === "left" || direction === "right") { + const selectable = getDataGridDisplayOrderedColumns(table).filter( + (column) => column.columnDef.enableCellSelection !== false + ) + const index = selectable.findIndex( + (column) => column.id === from.columnId + ) + if (index === -1) return null + const next = selectable[index + (direction === "right" ? 1 : -1)] + return next ? { rowId: from.rowId, columnId: next.id } : "edge" + } + const renderedIds = Array.from( + viewport.querySelectorAll("tbody tr[data-row-id]") + ).map((row) => row.getAttribute("data-row-id")) + const index = renderedIds.indexOf(from.rowId) + if (index === -1) return null + const nextId = renderedIds[index + (direction === "down" ? 1 : -1)] + return nextId ? { rowId: nextId, columnId: from.columnId } : "edge" +} + +/** + * Shift+Arrow: the feature keeps focus on the anchor and grows the range + * from its active corner. When its own extend cannot resolve the step + * (the active corner sits on a pinned row, or the next visual row is a + * pinned row outside the page slice), re-derive the active corner from + * the bounds and step it in visual space through selectCellRange. + */ +function extendDataGridSelection( + table: DataGridTableInstance, + viewport: HTMLElement, + direction: "up" | "down" | "left" | "right" +): { rowId: string; columnId: string } | null { + // The range's active corner: focus stays the anchor, so the corner is + // the bound's opposite one. Returned to the caller so the view can + // follow the growing edge rather than the anchor. + const activeCorner = () => { + const focused = table.getFocusedCell() + if (!focused) return null + const bound = getDataGridActiveBound(table) + if (!bound) return null + const rows = table.getRowsInDisplayOrder() + const allColumns = getDataGridDisplayOrderedColumns(table) + const rowIndex = focused.row.getDisplayIndex() + const columnIndex = allColumns.findIndex( + (column) => column.id === focused.column.id + ) + return { + rowId: + rows[ + rowIndex === bound.minRowIndex + ? bound.maxRowIndex + : bound.minRowIndex + ]?.id ?? focused.row.id, + columnId: + allColumns[ + columnIndex === bound.minColumnIndex + ? bound.maxColumnIndex + : bound.minColumnIndex + ]?.id ?? focused.column.id, + } + } + const boundKey = () => { + const bounds = table.getCellSelectionBounds() + const bound = bounds[bounds.length - 1] + return bound + ? `${bounds.length}:${bound.minRowIndex}-${bound.maxRowIndex}:${bound.minColumnIndex}-${bound.maxColumnIndex}` + : "none" + } + const before = boundKey() + table.extendCellSelection(direction) + if (boundKey() !== before) return activeCorner() + const focused = table.getFocusedCell() + if (!focused) return null + const active = activeCorner() ?? { + rowId: focused.row.id, + columnId: focused.column.id, + } + const target = getDataGridStepTarget(table, viewport, active, direction) + if (!target || target === "edge") return null + table.selectCellRange( + { + anchorRowId: focused.row.id, + anchorColumnId: focused.column.id, + focusRowId: target.rowId, + focusColumnId: target.columnId, + }, + { mode: "replace" } + ) + return target +} + +/** + * The active region as formatted strings, row-major. Columns opted out of + * selection are compacted away, matching the feature's own ranges-data + * behavior, and rows the view cannot show are skipped when a viewport is + * given. Null when nothing is selected. + */ +function getDataGridActiveRegionGrid( + table: DataGridTableInstance, + viewport?: HTMLElement | null +): string[][] | null { + const bound = getDataGridActiveBound(table) + if (!bound) return null + + const rows = table.getRowsInDisplayOrder() + const columns = getDataGridDisplayOrderedColumns(table) + const rendered = getDataGridRenderedRowIds(table, viewport) + const grid: string[][] = [] + + for (let r = bound.minRowIndex; r <= bound.maxRowIndex; r++) { + const row = rows[r] + if (!row || (rendered && !rendered.has(row.id))) continue + const cells = row.getAllCellsByColumnId() + const line: string[] = [] + for (let c = bound.minColumnIndex; c <= bound.maxColumnIndex; c++) { + const column = columns[c] + if (!column || column.columnDef.enableCellSelection === false) continue + const value = cells[column.id]?.getValue() + const format = column.columnDef.meta?.cellEdit?.format + line.push( + format ? format(value, row.original) : String(value ?? "") + ) + } + grid.push(line) + } + + return grid.length ? grid : null +} + +/** + * A clear/cut batch over the selection's writable cells. Non-writable columns + * are skipped silently: clearing is an intent over what the grid owns, and a + * rejection per read-only cell would be noise. Null when nothing clears. + */ +function buildDataGridClearDetails( + table: DataGridTableInstance, + source: "clear" | "cut", + activeRegionOnly: boolean, + viewport?: HTMLElement | null +): DataGridCellsChangeDetails | null { + const allBounds = table.getCellSelectionBounds() + const activeBound = getDataGridActiveBound(table) + const bounds = activeRegionOnly + ? activeBound + ? [activeBound] + : [] + : allBounds + if (!bounds.length) return null + + const rows = table.getRowsInDisplayOrder() + const columns = getDataGridDisplayOrderedColumns(table) + const rendered = getDataGridRenderedRowIds(table, viewport) + const changes: DataGridCellChange[] = [] + + for (const bound of bounds) { + for (let r = bound.minRowIndex; r <= bound.maxRowIndex; r++) { + const row = rows[r] + if (!row || (rendered && !rendered.has(row.id))) continue + const cells = row.getAllCellsByColumnId() + for (let c = bound.minColumnIndex; c <= bound.maxColumnIndex; c++) { + const column = columns[c] + if (!column || column.columnDef.enableCellSelection === false) continue + const cellEdit = getDataGridWritableCellEdit(column, row.original) + if (!cellEdit) continue + changes.push({ + rowId: row.id, + columnId: column.id, + row: row.original, + previousValue: cells[column.id]?.getValue(), + value: cellEdit.clearValue ?? null, + }) + } + } + } + + return changes.length ? { source, changes, rejected: [] } : null +} + +type DataGridPasteTarget = { + /** Display indexes of the pasted region's first and last row. */ + startRowIndex: number + endRowIndex: number + /** How many rows a paste actually wrote (hidden rows are skipped). */ + rowCount?: number + /** Display indexes of the pasted region's first and last column. */ + startColumnIndex: number + endColumnIndex: number +} + +/** + * Resolves a parsed clipboard block into a paste batch, Excel semantics: + * a 1x1 block fills the whole selected region; a block tiles when the region + * is an exact multiple of it; otherwise it pastes once from the region's + * top-left, clamped at the grid edges (rows are never grown). + */ +function buildDataGridPasteDetails( + table: DataGridTableInstance, + block: string[][], + viewport?: HTMLElement | null +): { + details: DataGridCellsChangeDetails + target: DataGridPasteTarget +} | null { + if (!block.length) return null + const bound = getDataGridActiveBound(table) + if (!bound) return null + + const rows = table.getRowsInDisplayOrder() + const columns = getDataGridDisplayOrderedColumns(table) + const rendered = getDataGridRenderedRowIds(table, viewport) + + // Copy COMPACTS opt-out columns away, so paste maps block columns over the + // same compacted space: selectable columns only, starting at the region's + // first selectable column and running past the region for a wider block, + // clamped at the grid edge. Walking raw display offsets instead would let + // an interior opt-out column swallow a block column silently. + const targetColumns: Array<{ + column: (typeof columns)[number] + displayIndex: number + }> = [] + let regionColumns = 0 + for (let c = bound.minColumnIndex; c < columns.length; c++) { + const column = columns[c] + if (!column || column.columnDef.enableCellSelection === false) continue + targetColumns.push({ column, displayIndex: c }) + if (c <= bound.maxColumnIndex) regionColumns += 1 + } + if (!regionColumns) return null + + const blockRows = block.length + const blockColumns = block.reduce((max, line) => Math.max(max, line.length), 1) + const regionRows = bound.maxRowIndex - bound.minRowIndex + 1 + + let rowCount: number + let columnCount: number + if (blockRows === 1 && blockColumns === 1) { + rowCount = regionRows + columnCount = regionColumns + } else if ( + regionRows % blockRows === 0 && + regionColumns % blockColumns === 0 + ) { + rowCount = regionRows + columnCount = regionColumns + } else { + rowCount = blockRows + columnCount = blockColumns + } + + const tiled = tileDataGridClipboardBlock(block, rowCount, columnCount) + const startRowIndex = bound.minRowIndex + + // Rows the block maps onto: the next rowCount rows the view can show, + // starting at the region's top. Off-page rows between a page's tail and + // a pinned draft are skipped rather than silently written. + const targetRows: Array<{ row: (typeof rows)[number]; displayIndex: number }> = + [] + for (let r = startRowIndex; r < rows.length; r++) { + if (targetRows.length === rowCount) break + const row = rows[r] + if (!row || (rendered && !rendered.has(row.id))) continue + targetRows.push({ row, displayIndex: r }) + } + + const changes: DataGridCellChange[] = [] + const rejected: DataGridCellRejection[] = [] + + for (let r = 0; r < targetRows.length; r++) { + const row = targetRows[r]!.row + const cells = row.getAllCellsByColumnId() + for (let c = 0; c < columnCount; c++) { + const column = targetColumns[c]?.column + if (!column) continue + const raw = tiled[r]?.[c] ?? "" + const cellEdit = getDataGridWritableCellEdit(column, row.original) + if (!cellEdit) { + rejected.push({ + rowId: row.id, + columnId: column.id, + raw, + reason: "readonly", + }) + continue + } + const value = cellEdit.parse ? cellEdit.parse(raw, row.original) : raw + if (value === undefined) { + rejected.push({ + rowId: row.id, + columnId: column.id, + raw, + reason: "invalid", + }) + continue + } + changes.push({ + rowId: row.id, + columnId: column.id, + row: row.original, + previousValue: cells[column.id]?.getValue(), + value, + }) + } + } + + if (!changes.length && !rejected.length) return null + const lastTarget = + targetColumns[Math.min(columnCount, targetColumns.length) - 1] + return { + details: { source: "paste", changes, rejected }, + target: { + startRowIndex, + endRowIndex: targetRows[targetRows.length - 1]?.displayIndex ?? startRowIndex, + rowCount: targetRows.length, + startColumnIndex: targetColumns[0]!.displayIndex, + endColumnIndex: lastTarget!.displayIndex, + }, + } +} + +/** + * The inverse batch: every change's value and previousValue swapped and + * rejections dropped. Feed it through the same state update onCellsChange + * uses to implement undo; inverting the inverse is redo. + */ +function invertDataGridCellsChange( + details: DataGridCellsChangeDetails +): DataGridCellsChangeDetails { + return { + source: details.source, + changes: details.changes.map((change) => ({ + ...change, + value: change.previousValue, + previousValue: change.value, + })), + rejected: [], + } +} + +function selectDataGridRegion( + table: DataGridTableInstance, + target: DataGridPasteTarget +) { + const rows = table.getRowsInDisplayOrder() + const columns = getDataGridDisplayOrderedColumns(table) + const anchorRow = rows[target.startRowIndex] + const anchorColumn = columns[target.startColumnIndex] + const focusRow = rows[Math.min(target.endRowIndex, rows.length - 1)] + const focusColumn = columns[target.endColumnIndex] + if (!anchorRow || !anchorColumn || !focusRow || !focusColumn) return + table.selectCellRange( + { + anchorRowId: anchorRow.id, + anchorColumnId: anchorColumn.id, + focusRowId: focusRow.id, + focusColumnId: focusColumn.id, + }, + { mode: "replace" } + ) +} + +/* ------------------------------------------------------------------------- * + * Fill handle drag session. + * ------------------------------------------------------------------------- */ + +/** + * Imperative document-level session, the column-resize pattern: preview state + * is written as `data-cell-fill-target` attributes outside React, so the drag + * costs zero re-renders until release. v1 fills down or right (dominant axis) + * by repeating the source region's values; rows outside the rendered + * viewport of a virtualized body cannot be targeted mid-drag. + * + * Vertical distance is measured over the rows the view can show, so a drag + * onto a pinned draft fills exactly the rows the preview marked and never + * the off-page rows between. A cross-column fill round-trips through the + * source's format and the target's parse - the paste contract - because + * adjacent grid columns are heterogeneous; a parse rejection lands in the + * batch's rejected list, while cells that cannot be written are skipped + * and never tinted by the preview. + */ +// The fill drag's feedback: ONE dashed border (the Sheets fill marquee) +// around the whole pending region - the source PLUS the extension - so the +// drag reads as one growing region, never as a second box glued under the +// source. While the session runs +// the viewport carries data-cell-filling and the source cells' own +// selection chrome rests, so this element is the only painter and nothing +// can double at the junction. z-[35]: above the sticky pinned cells +// (z 30) so the border survives crossing a pinned column; below the +// sticky header (z-40). +const dataGridGestureOutlineClasses = + "outline-primary pointer-events-none absolute z-[35] outline-1 outline-dashed -outline-offset-1" + +function startDataGridFillSession(options: { + table: DataGridTableInstance + viewport: HTMLElement + onCellsChange: ((details: DataGridCellsChangeDetails) => void) | null +}) { + const { table, viewport, onCellsChange } = options + // Fill over a multi-region selection is undefined territory; Excel refuses + // it too. + if (table.getCellSelectionBounds().length > 1) return + const bound = getDataGridActiveBound(table) + if (!bound) return + + const rows = table.getRowsInDisplayOrder() + const columns = getDataGridDisplayOrderedColumns(table) + // The vertical walk space: rows the view can show, in display order. The + // DOM ids seed it (a drag can only target rendered rows), and one pass + // over the cached display array resolves them without building per-row + // wrappers for a 50k-row model. + const domRowIds = new Set() + for (const rowEl of Array.from( + viewport.querySelectorAll("tbody tr[data-row-id]") + )) { + const id = rowEl.getAttribute("data-row-id") + if (id) domRowIds.add(id) + } + const visibleRows: Array<{ row: (typeof rows)[number]; displayIndex: number }> = + [] + for (let i = 0; i < rows.length; i++) { + const row = rows[i]! + if (domRowIds.has(row.id)) { + visibleRows.push({ row, displayIndex: i }) + } + } + const visibleIndexById = new Map( + visibleRows.map((entry, index) => [entry.row.id, index]) + ) + // The source region and its last row, both in visible space. + const sourceRows = visibleRows.filter( + ({ displayIndex }) => + displayIndex >= bound.minRowIndex && displayIndex <= bound.maxRowIndex + ) + const sourceEndVisibleIndex = sourceRows.length + ? visibleIndexById.get(sourceRows[sourceRows.length - 1]!.row.id)! + : -1 + if (sourceEndVisibleIndex === -1) return + const columnIndexById = new Map( + columns.map((column, index) => [column.id, index]) + ) + const isFillableColumn = (column: (typeof columns)[number]) => + column.columnDef.enableCellSelection !== false && + !!getDataGridWritableCellEdit(column) + + let extension: { axis: "row" | "column"; count: number } | null = null + let previewCells: Element[] = [] + + const previewOutline = viewport.ownerDocument.createElement("div") + previewOutline.setAttribute("data-slot", "data-grid-cell-fill-preview") + previewOutline.className = dataGridGestureOutlineClasses + previewOutline.style.display = "none" + viewport.appendChild(previewOutline) + viewport.setAttribute("data-cell-filling", "") + + // The region border wraps source plus extension: its corners are the + // source region's first cell and the farthest cell the drag reaches + // (the source's own last cell while nothing is extended). + const sourceCorner = (rowId: string, columnId: string | undefined) => + columnId + ? viewport.querySelector( + `tr[data-row-id="${CSS.escape(rowId)}"] td[data-col-id="${CSS.escape(columnId)}"]` + ) + : null + const positionPreviewOutline = () => { + const first = sourceRows.length + ? sourceCorner(sourceRows[0]!.row.id, columns[bound.minColumnIndex]?.id) + : null + const lastSource = sourceRows.length + ? sourceCorner( + sourceRows[sourceRows.length - 1]!.row.id, + columns[bound.maxColumnIndex]?.id + ) + : null + const far = previewCells.length + ? previewCells[previewCells.length - 1]! + : lastSource + if (!first || !far) { + previewOutline.style.display = "none" + return + } + const firstRect = first.getBoundingClientRect() + const farRect = far.getBoundingClientRect() + const viewportRect = viewport.getBoundingClientRect() + const left = Math.min(firstRect.left, farRect.left) + const top = Math.min(firstRect.top, farRect.top) + previewOutline.style.display = "block" + previewOutline.style.left = `${left - viewportRect.left + viewport.scrollLeft}px` + previewOutline.style.top = `${top - viewportRect.top + viewport.scrollTop}px` + previewOutline.style.width = `${Math.max(firstRect.right, farRect.right) - left}px` + previewOutline.style.height = `${Math.max(firstRect.bottom, farRect.bottom) - top}px` + } + + const clearPreview = () => { + for (const cell of previewCells) cell.removeAttribute("data-cell-fill-target") + previewCells = [] + positionPreviewOutline() + } + + // Target rows and columns for the current extension: rows in visible + // space, and only the columns the commit would actually write, so the + // preview never tints a cell the release will skip. + const getExtensionTargets = () => { + if (!extension) return null + const columnEntries = columns.map((column, displayIndex) => ({ + column, + displayIndex, + })) + return extension.axis === "row" + ? { + rowEntries: visibleRows.slice( + sourceEndVisibleIndex + 1, + sourceEndVisibleIndex + 1 + extension.count + ), + columnEntries: columnEntries + .slice(bound.minColumnIndex, bound.maxColumnIndex + 1) + .filter(({ column }) => isFillableColumn(column)), + } + : { + rowEntries: sourceRows, + columnEntries: columnEntries + .slice( + bound.maxColumnIndex + 1, + bound.maxColumnIndex + 1 + extension.count + ) + .filter(({ column }) => isFillableColumn(column)), + } + } + + const applyPreview = () => { + clearPreview() + const targets = getExtensionTargets() + if (!targets) return + for (const { row } of targets.rowEntries) { + const rowElement = viewport.querySelector( + `tr[data-row-id="${CSS.escape(row.id)}"]` + ) + if (!rowElement) continue + for (const { column } of targets.columnEntries) { + if (!getDataGridWritableCellEdit(column, row.original)) continue + const cellElement = rowElement.querySelector( + `td[data-col-id="${CSS.escape(column.id)}"]` + ) + if (!cellElement) continue + cellElement.setAttribute("data-cell-fill-target", "true") + previewCells.push(cellElement) + } + } + + positionPreviewOutline() + } + + const handleMove = (event: MouseEvent) => { + const element = document.elementFromPoint(event.clientX, event.clientY) + const cellElement = element?.closest?.("td[data-col-id]") + const rowElement = cellElement?.closest?.("tr[data-row-id]") + if (!cellElement || !rowElement || !viewport.contains(cellElement)) return + + const rowVisibleIndex = visibleIndexById.get( + rowElement.getAttribute("data-row-id") ?? "" + ) + const columnIndex = columnIndexById.get( + cellElement.getAttribute("data-col-id") ?? "" + ) + if (rowVisibleIndex === undefined || columnIndex === undefined) return + + const rowsDelta = Math.max(0, rowVisibleIndex - sourceEndVisibleIndex) + const columnsDelta = Math.max(0, columnIndex - bound.maxColumnIndex) + const next: typeof extension = + rowsDelta === 0 && columnsDelta === 0 + ? null + : rowsDelta >= columnsDelta + ? { axis: "row", count: rowsDelta } + : { axis: "column", count: columnsDelta } + + if (next?.axis === extension?.axis && next?.count === extension?.count) { + return + } + extension = next + applyPreview() + } + + const finish = (commit: boolean) => { + document.removeEventListener("mousemove", handleMove) + document.removeEventListener("mouseup", handleUp) + document.removeEventListener("keydown", handleKey, true) + clearPreview() + previewOutline.remove() + viewport.removeAttribute("data-cell-filling") + if (!commit || !extension || !onCellsChange) return + const targets = getExtensionTargets() + if (!targets || !targets.rowEntries.length) return + + const changes: DataGridCellChange[] = [] + const rejected: DataGridCellRejection[] = [] + + if (extension.axis === "row") { + for (let offset = 0; offset < targets.rowEntries.length; offset++) { + const targetRow = targets.rowEntries[offset]!.row + const sourceRow = sourceRows[offset % sourceRows.length]!.row + const targetCells = targetRow.getAllCellsByColumnId() + const sourceCells = sourceRow.getAllCellsByColumnId() + for (const { column } of targets.columnEntries) { + if (!getDataGridWritableCellEdit(column, targetRow.original)) { + continue + } + changes.push({ + rowId: targetRow.id, + columnId: column.id, + row: targetRow.original, + previousValue: targetCells[column.id]?.getValue(), + value: sourceCells[column.id]?.getValue(), + }) + } + } + } else { + const regionColumns = bound.maxColumnIndex - bound.minColumnIndex + 1 + for (const { row } of targets.rowEntries) { + const cells = row.getAllCellsByColumnId() + for (const { column: targetColumn, displayIndex } of targets.columnEntries) { + const offset = displayIndex - (bound.maxColumnIndex + 1) + const sourceColumn = + columns[bound.minColumnIndex + (offset % regionColumns)] + if (!sourceColumn) continue + const targetEdit = getDataGridWritableCellEdit( + targetColumn, + row.original + ) + if (!targetEdit) continue + const sourceValue = cells[sourceColumn.id]?.getValue() + let value: unknown = sourceValue + // Crossing columns is a type boundary: round-trip through the + // source's format and the target's parse, exactly what pasting + // the same cells would do. Same-column fills keep raw values. + if (targetColumn.id !== sourceColumn.id && targetEdit.parse) { + const sourceFormat = + sourceColumn.columnDef.meta?.cellEdit?.format + const raw = sourceFormat + ? sourceFormat(sourceValue, row.original) + : String(sourceValue ?? "") + const parsed = targetEdit.parse(raw, row.original) + if (parsed === undefined) { + rejected.push({ + rowId: row.id, + columnId: targetColumn.id, + raw, + reason: "invalid", + }) + continue + } + value = parsed + } + changes.push({ + rowId: row.id, + columnId: targetColumn.id, + row: row.original, + previousValue: cells[targetColumn.id]?.getValue(), + value, + }) + } + } + } + + if (changes.length || rejected.length) { + onCellsChange({ source: "fill", changes, rejected }) + } + + // Grow the selection over source plus filled cells, Excel's post-fill + // shape. + selectDataGridRegion(table, { + startRowIndex: bound.minRowIndex, + endRowIndex: + extension.axis === "row" + ? targets.rowEntries[targets.rowEntries.length - 1]!.displayIndex + : bound.maxRowIndex, + startColumnIndex: bound.minColumnIndex, + endColumnIndex: + bound.maxColumnIndex + + (extension.axis === "column" ? extension.count : 0), + }) + } + + const handleUp = () => finish(true) + // Capture phase so a cancel cannot also reach the grid's own Escape + // handling and clear the selection underneath the drag. + const handleKey = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + event.stopPropagation() + finish(false) + } + + document.addEventListener("mousemove", handleMove) + document.addEventListener("mouseup", handleUp) + document.addEventListener("keydown", handleKey, true) +} + +/* ------------------------------------------------------------------------- * + * Controller. + * ------------------------------------------------------------------------- */ + +function isDataGridEditableTarget(target: EventTarget | null): boolean { + // data-cell-interactive is the same opt-out the mousedown guard honors, so + // a custom widget keeps its own keys and clipboard as well as its clicks. + return ( + target instanceof Element && + target.closest( + "input, textarea, select, [contenteditable], [data-cell-interactive]" + ) != null + ) +} + +// Keys belong to a focused in-cell control the same way clicks do: the +// button, link and checkbox set the mousedown guard exempts must keep its +// native Enter, Space and Tab, or an in-cell button is keyboard-dead. +// Clipboard stays on the narrower guard: copying the grid selection while a +// button happens to hold focus is what a spreadsheet user expects. +function isDataGridInteractiveKeyTarget(target: EventTarget | null): boolean { + return ( + isDataGridEditableTarget(target) || + (target instanceof Element && + target.closest('button, a, [role="checkbox"]') != null) + ) +} + +/** + * Headless spreadsheet controller: keyboard navigation, clipboard, clear and + * the fill-handle drag. Mount once inside ``, next to the table. + * Renders only a hidden anchor; the real listeners attach to the grid's own + * body viewport, which also receives focus (container-focus model), so a + * virtualized row unmounting can never strand `document.activeElement`. + */ +function DataGridCellSelection({ + apiRef, + clipboard = true, + keyboard = true, +}: { + /** Receives the controller's imperative API, e.g. for create-row flows. */ + apiRef?: RefObject + /** Native copy, cut and paste handling. Defaults to true. */ + clipboard?: boolean + /** + * Arrows (with Ctrl/Cmd edge jumps), Home/End, PageUp/PageDown, Enter, + * F2, type-to-edit, Tab, Ctrl/Cmd+A, Delete, Escape. Defaults to true. + */ + keyboard?: boolean +}) { + // The context value serves `table` and `props` through getters over the + // provider's own refs, so a context object captured at mount keeps handing + // out the CURRENT instances; no ref mirror is needed here. + const context = useDataGrid() + + const anchorRef = useRef(null) + // The body viewport, once the wiring effect resolves it; the built-in + // editor overlay portals into it so it scrolls with the cells. + const [viewportEl, setViewportEl] = useState(null) + const [editorSession, setEditorSession] = + useState(null) + const { table } = context + const enabled = + !!context.props.tableLayout?.cellSelection && + table.atoms.cellSelection != null + + useEffect(() => { + if (!enabled) return + const anchor = anchorRef.current + if (!anchor) return + + const root = (anchor.closest('[data-slot="data-grid"]') ?? + anchor.parentElement) as HTMLElement | null + if (!root) return + + // Split header/body grids render two viewports; the body one owns focus. + const viewports = Array.from( + root.querySelectorAll('[data-slot="data-grid-table-viewport"]') + ) + const viewport = + viewports.find((node) => + node.querySelector('[data-slot="data-grid-table-body"]') + ) ?? viewports[0] + if (!viewport) return + + const getTable = () => context.table + const getOnCellsChange = () => context.props.onCellsChange ?? null + // "single" collapses every grow gesture to the focused cell. + const isRangeSelectionEnabled = () => + context.props.tableLayout?.cellSelectionMode !== "single" + + // The focusable element of the container-focus model. The table carries + // role="grid", so putting DOM focus (and aria-activedescendant) on it is + // what lets a screen reader follow the focused cell. + const focusTarget = + viewport.querySelector( + 'table[data-slot="data-grid-table"]' + ) ?? viewport + + const scrollTdIntoView = (cell: Element | null) => + // Optional call: jsdom and some embedded contexts ship elements + // without scrollIntoView. + cell?.scrollIntoView?.({ + block: "nearest", + inline: "nearest", + behavior: "instant", + }) + + // Points assistive tech at the virtually focused cell. + const syncActiveDescendant = () => { + const cell = viewport.querySelector("td[data-cell-focused]") + if (cell?.id) focusTarget.setAttribute("aria-activedescendant", cell.id) + else focusTarget.removeAttribute("aria-activedescendant") + } + + const scrollFocusedIntoView = ( + targetRowIndex?: number, + rowCount?: number, + // A Shift-extend grows away from the focused cell, which stays the + // anchor; the caller names the range's active corner so the view + // follows the growing edge instead of snapping back to the anchor. + explicitCell?: { rowId: string; columnId: string } + ) => { + const queryScrollCell = () => + explicitCell + ? viewport.querySelector( + `tr[data-row-id="${CSS.escape(explicitCell.rowId)}"] td[data-col-id="${CSS.escape(explicitCell.columnId)}"]` + ) + : viewport.querySelector("td[data-cell-focused]") + // After the atom write React still has to commit the data attributes; + // rAF lands after that commit. Instant, because the app-level + // scroll-smooth would otherwise animate every keystroke. + requestAnimationFrame(() => { + const cell = queryScrollCell() + syncActiveDescendant() + if (cell || targetRowIndex == null || !rowCount) { + scrollTdIntoView(cell) + return + } + // Virtualization: the jump target is not mounted, so there is no td + // to scroll to. The spacer rows keep scrollHeight proportional to + // the row count, so estimate the offset, let the virtualizer mount + // the region, then finish with a precise nearest-scroll. + let scroller: HTMLElement | null = viewport + while (scroller && scroller.scrollHeight <= scroller.clientHeight) { + scroller = scroller.parentElement + } + if (!scroller) return + scroller.scrollTop = + ((targetRowIndex + 0.5) / rowCount) * scroller.scrollHeight - + scroller.clientHeight / 2 + requestAnimationFrame(() => { + scrollTdIntoView(queryScrollCell()) + syncActiveDescendant() + }) + }) + } + + // Opens an editor for the focused writable cell: the grid's own overlay + // for a column with a built-in `control`, the consumer's via + // `onCellEditRequest` otherwise, and as a last resort the cell's own + // interactive content (activated the way a mouse would). Returns false + // when nothing can open so the caller falls back to plain navigation. + const requestCellEdit = (initialText?: string): boolean => { + const table = getTable() + const cell = table.getFocusedCell() + if (!cell) return false + const cellEdit = getDataGridWritableCellEdit( + cell.column, + cell.row.original + ) + if (cellEdit?.control) { + const baseline = cellEdit.format + ? cellEdit.format(cell.getValue(), cell.row.original) + : String(cell.getValue() ?? "") + const headerTitle = cell.column.columnDef.meta?.headerTitle + const header = cell.column.columnDef.header + setEditorSession({ + rowId: cell.row.id, + columnId: cell.column.id, + control: cellEdit.control, + initialValue: initialText ?? baseline, + baseline, + label: + typeof headerTitle === "string" + ? headerTitle + : typeof header === "string" + ? header + : cell.column.id, + }) + return true + } + const onCellEditRequest = context.props.onCellEditRequest + if (cellEdit && onCellEditRequest) { + onCellEditRequest({ + rowId: cell.row.id, + columnId: cell.column.id, + row: cell.row.original, + previousValue: cell.getValue(), + initialText, + }) + return true + } + // Generic activation: a custom control rendered in the cell (a + // select trigger, a combobox input, a button) toggles from the + // keyboard without any consumer wiring - focus it and click it, + // the same gesture the mouse performs. The lookup is ORDERED so an + // incidental button (a chip's remove button before a combobox + // input) never outranks the cell's primary control. + // Deliberate activation only (Enter, F2, double-click): typing a + // printable character must never toggle a control it cannot type + // into, and there is nowhere to put the character. + if (initialText !== undefined) return false + const focusedCell = viewport.querySelector("td[data-cell-focused]") + const control = focusedCell + ? [ + '[role="combobox"]', + "select", + "input, textarea", + 'button, a, [role="checkbox"], [tabindex]:not([tabindex="-1"])', + ] + .map((candidate) => + focusedCell.querySelector(candidate) + ) + .find(Boolean) + : null + if (!control) return false + control.focus() + control.click() + return true + } + + // The imperative surface. A consumer that creates a row cannot focus + // its cell through state alone: setFocusedCell needs the row in the + // table model, DOM focus needs the focus target, and both race the + // commit that mounts the row. One bounded timer retry (timers, unlike + // animation frames, run in background tabs) settles all of it in order. + let focusRetryTimer: ReturnType | undefined + const focusCell: DataGridCellSelectionApi["focusCell"] = ( + rowId, + columnId, + options + ) => { + // A newer call supersedes any chain still polling for an older row. + clearTimeout(focusRetryTimer) + let attempts = 12 + const attempt = () => { + // The grid can unmount mid-retry; a stale timer must go quiet. + if (!viewport.isConnected) return + const cell = viewport.querySelector( + `tr[data-row-id="${CSS.escape(rowId)}"] td[data-col-id="${CSS.escape(columnId)}"]` + ) + if (!cell) { + if (attempts-- > 0) focusRetryTimer = setTimeout(attempt, 32) + return + } + getTable().setFocusedCell(rowId, columnId) + focusTarget.focus() + // The cell's id is static, so assistive tech can point at it ahead + // of the data-cell-focused commit the rAF sync waits for. + if (cell.id) focusTarget.setAttribute("aria-activedescendant", cell.id) + scrollTdIntoView(cell) + if (!options?.edit) return + // The built-in overlay measures td[data-cell-focused], so the edit + // request waits for that attribute to land. + let editAttempts = 12 + const attemptEdit = () => { + if (!cell.isConnected) return + if (cell.hasAttribute("data-cell-focused")) requestCellEdit() + else if (editAttempts-- > 0) { + focusRetryTimer = setTimeout(attemptEdit, 32) + } + } + attemptEdit() + } + attempt() + } + const clearSelection = () => { + getTable().resetCellSelection(true) + requestAnimationFrame(syncActiveDescendant) + } + const scrollToCell = (rowId: string, columnId: string) => { + scrollTdIntoView( + viewport.querySelector( + `tr[data-row-id="${CSS.escape(rowId)}"] td[data-col-id="${CSS.escape(columnId)}"]` + ) + ) + } + if (apiRef) apiRef.current = { focusCell, clearSelection, scrollToCell } + + // Resolves jump targets (Home, End, Ctrl+Arrows, PageUp/PageDown) in the + // feature's display-index space and lands them through the range API: + // plain jumps collapse to the target cell, Shift jumps keep the active + // range's opposite corner as the anchor, the Excel model. + const jumpFocus = ( + rowTarget: "first" | "last" | "same" | { delta: number }, + columnTarget: "first" | "last" | "same", + extend: boolean + ): boolean => { + const table = getTable() + const focused = table.getFocusedCell() + if (!focused) return false + const rows = table.getRowsInDisplayOrder() + const allColumns = getDataGridDisplayOrderedColumns(table) + const selectable = allColumns.filter( + (column) => column.columnDef.enableCellSelection !== false + ) + if (!rows.length || !selectable.length) return false + const rowIndex = focused.row.getDisplayIndex() + const targetRowIndex = + rowTarget === "first" + ? 0 + : rowTarget === "last" + ? rows.length - 1 + : rowTarget === "same" + ? rowIndex + : Math.min( + rows.length - 1, + Math.max(0, rowIndex + rowTarget.delta) + ) + // Pagination renders a window of the display order; walk the target + // back toward the focus until it lands on a rendered row, so a jump + // can never focus an off-page row the view cannot show. DOM rows + // join the set so pinned rows outside the page slice stay reachable. + let clampedRowIndex = targetRowIndex + // With the whole display order in the page slice nothing is off-page + // and the clamp cannot move, so skip building the id set. + if (table.getRowModel().rows.length !== rows.length) { + const rendered = new Set( + table.getRowModel().rows.map((row: { id: string }) => row.id) + ) + for (const rowEl of Array.from( + viewport.querySelectorAll("tbody tr[data-row-id]") + )) { + rendered.add(rowEl.getAttribute("data-row-id")) + } + const step = clampedRowIndex >= rowIndex ? -1 : 1 + while ( + clampedRowIndex !== rowIndex && + rows[clampedRowIndex] && + !rendered.has(rows[clampedRowIndex].id) + ) { + clampedRowIndex += step + } + } + const targetRow = rows[clampedRowIndex] + const targetColumn = + columnTarget === "first" + ? selectable[0] + : columnTarget === "last" + ? selectable[selectable.length - 1] + : focused.column + if (!targetRow || !targetColumn) return false + if (extend) { + const bound = getDataGridActiveBound(table) + const columnIndex = allColumns.findIndex( + (column) => column.id === focused.column.id + ) + let anchorRowId = focused.row.id + let anchorColumnId = focused.column.id + if (bound) { + anchorRowId = + rows[ + rowIndex === bound.minRowIndex + ? bound.maxRowIndex + : bound.minRowIndex + ]?.id ?? anchorRowId + anchorColumnId = + allColumns[ + columnIndex === bound.minColumnIndex + ? bound.maxColumnIndex + : bound.minColumnIndex + ]?.id ?? anchorColumnId + } + table.selectCellRange( + { + anchorRowId, + anchorColumnId, + focusRowId: targetRow.id, + focusColumnId: targetColumn.id, + }, + { mode: "replace" } + ) + } else { + table.setFocusedCell(targetRow.id, targetColumn.id) + } + scrollFocusedIntoView( + clampedRowIndex, + rows.length, + // The extend keeps focus on the anchor; follow the jump target. + extend + ? { rowId: targetRow.id, columnId: targetColumn.id } + : undefined + ) + return true + } + + // One visual-space focus step with the feature's move as the fallback + // for unresolvable positions; at an edge the focus stays put. + const moveFocusVisual = ( + direction: "up" | "down" | "left" | "right" + ): void => { + const table = getTable() + const focused = table.getFocusedCell() + const target = focused + ? getDataGridStepTarget( + table, + viewport, + { rowId: focused.row.id, columnId: focused.column.id }, + direction + ) + : null + if (target === "edge") return + if (target) { + table.setFocusedCell(target.rowId, target.columnId) + return + } + table.moveCellSelection(direction) + } + + // One viewport's worth of rows for PageUp/PageDown, measured from the + // live layout; 10 when nothing is measurable (jsdom). Data rows only: + // the virtual table's spacer rows carry the whole scroll offset as + // height and would collapse the page size to a single row. + const getPageJumpSize = (): number => { + const row = viewport.querySelector("tbody tr[data-row-id]") + // The nearest ancestor that actually scrolls; in the scroll-area + // layout the viewport's parent is a full-height content wrapper + // whose clientHeight would make the page span the whole table. + let scroller: HTMLElement | null = viewport + while (scroller && scroller.scrollHeight <= scroller.clientHeight) { + scroller = scroller.parentElement + } + const rowHeight = row?.getBoundingClientRect().height || 0 + const viewHeight = scroller?.clientHeight || viewport.clientHeight + if (!rowHeight || !viewHeight) return 10 + return Math.max(1, Math.floor(viewHeight / rowHeight) - 1) + } + + // The rows appended after the data rows: the add-row affordance and a + // consumer appendRow draft. Positional, because an id-less tr is not + // enough - expanded-detail rows interleave BETWEEN data rows and the + // empty-state row stands alone, and neither belongs to this region. + // Virtual spacer rows are aria-hidden and never match. + const getAppendedRows = (): HTMLElement[] => { + const body = viewport.querySelector("tbody") + const appended: HTMLElement[] = [] + // Backwards from the end, stopping at the first data row: O(appended) + // instead of an array of every rendered row per keystroke. + let rowEl = body?.lastElementChild + while (rowEl && !rowEl.hasAttribute("data-row-id")) { + if ( + rowEl instanceof HTMLElement && + !rowEl.hasAttribute("aria-hidden") + ) { + appended.unshift(rowEl) + } + rowEl = rowEl.previousElementSibling + } + return appended + } + + const focusFirstIn = (row: HTMLElement | undefined): boolean => { + const focusable = row?.querySelector( + 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + if (!focusable) return false + focusable.focus() + // A control that refuses focus (hidden, inert) must not report + // success, or the arrow key that called this dead-ends. + return row!.ownerDocument.activeElement === focusable + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return + + // Unified keyboard for the appended region: ArrowDown/ArrowUp walk + // between its rows, and ArrowUp from its first row returns to the + // grid's cell navigation. Every other key stays native there. + const appendedRow = + event.target instanceof Element + ? (event.target.closest( + "tbody > tr:not([data-row-id])" + ) as HTMLElement | null) + : null + if (appendedRow && viewport.contains(appendedRow)) { + const appended = getAppendedRows() + const index = appended.indexOf(appendedRow) + // An id-less row that is NOT appended (expanded detail content, + // the empty-state row) keeps every key native. + if (index === -1) return + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return + if (event.key === "ArrowDown") { + if (focusFirstIn(appended[index + 1])) event.preventDefault() + return + } + if (index > 0 && focusFirstIn(appended[index - 1])) { + event.preventDefault() + return + } + // Back into the grid, on whatever cell was focused last. + focusTarget.focus() + syncActiveDescendant() + event.preventDefault() + return + } + + if (isDataGridInteractiveKeyTarget(event.target)) { + // The ARIA grid pattern's exit gesture: Escape on an in-cell + // control hands DOM focus back to the grid so navigation resumes. + // A control that consumed the key first (closing its own popup) + // never lets it reach here unprevented, and portal-rendered + // popups keep their Escape entirely outside the viewport. + if (event.key === "Escape") { + const cell = (event.target as Element).closest?.("td[data-col-id]") + if (cell && viewport.contains(cell)) { + event.preventDefault() + event.stopPropagation() + focusTarget.focus() + syncActiveDescendant() + } + } + return + } + const table = getTable() + const rtl = getComputedStyle(viewport).direction === "rtl" + const horizontal = (direction: "left" | "right") => + rtl ? (direction === "left" ? "right" : "left") : direction + + // Ahead of the switch so plain letters still reach the type-to-edit + // fall-through below. Matched by code as well as key, so non-Latin + // layouts (where the key is the layout's own character) still work. + if ( + (event.key === "a" || event.key === "A" || event.code === "KeyA") && + (event.metaKey || event.ctrlKey) && + // AltGr arrives as Ctrl+Alt; that chord types a character on + // central-European layouts and is not a select-all. + !event.altKey + ) { + if (isRangeSelectionEnabled()) { + table.selectAllCells() + // Select-all moves the feature's focus to the range corner, + // which can sit off-page; keep assistive tech pointed right. + requestAnimationFrame(syncActiveDescendant) + } + event.preventDefault() + return + } + + // Explicit copy and cut chords; see writeSelectionToClipboard for + // why the native copy event cannot carry these. Paste stays on the + // native event, which browsers do fire at the focused element. + if ((event.metaKey || event.ctrlKey) && !event.altKey && clipboard) { + const isCopy = + event.key === "c" || event.key === "C" || event.code === "KeyC" + const isCut = + event.key === "x" || event.key === "X" || event.code === "KeyX" + if (isCopy || isCut) { + event.preventDefault() + void writeSelectionToClipboard().then((written) => { + if (!written) return + context.props.onCellsCopy?.({ ...written, cut: isCut }) + if (!isCut) return + const onCellsChange = getOnCellsChange() + if (!onCellsChange) return + const details = buildDataGridClearDetails( + getTable(), + "cut", + true, + viewport + ) + if (details) onCellsChange(details) + }) + return + } + } + + switch (event.key) { + case "ArrowUp": + case "ArrowDown": + case "ArrowLeft": + case "ArrowRight": { + const direction = + event.key === "ArrowUp" + ? ("up" as const) + : event.key === "ArrowDown" + ? ("down" as const) + : horizontal(event.key === "ArrowLeft" ? "left" : "right") + if (event.metaKey || event.ctrlKey) { + // Ctrl/Cmd+Arrow jumps to the grid edge in that direction. + const jumped = + direction === "up" + ? jumpFocus("first", "same", event.shiftKey && isRangeSelectionEnabled()) + : direction === "down" + ? jumpFocus("last", "same", event.shiftKey && isRangeSelectionEnabled()) + : jumpFocus( + "same", + direction === "left" ? "first" : "last", + event.shiftKey && isRangeSelectionEnabled() + ) + if (jumped) event.preventDefault() + return + } + // ArrowDown on the last rendered row steps into the appended + // region (a draft row, or the add-row affordance), keeping one + // keyboard model across the whole table. + if (direction === "down" && !event.shiftKey) { + const focused = table.getFocusedCell() + const renderedRows = viewport.querySelectorAll( + "tbody tr[data-row-id]" + ) + const lastRenderedId = renderedRows[ + renderedRows.length - 1 + ]?.getAttribute("data-row-id") + if ( + focused && + lastRenderedId && + focused.row.id === lastRenderedId && + focusFirstIn(getAppendedRows()[0]) + ) { + event.preventDefault() + return + } + } + if (event.shiftKey && isRangeSelectionEnabled()) { + const corner = extendDataGridSelection(table, viewport, direction) + event.preventDefault() + scrollFocusedIntoView(undefined, undefined, corner ?? undefined) + return + } + // Plain arrows step in visual space so navigation crosses + // pinned rows and pagination windows the feature's own move + // cannot resolve; at a visual edge the focus stays put. + const focused = table.getFocusedCell() + const target = focused + ? getDataGridStepTarget( + table, + viewport, + { rowId: focused.row.id, columnId: focused.column.id }, + direction + ) + : null + if (target === "edge") { + event.preventDefault() + return + } + if (target) { + table.setFocusedCell(target.rowId, target.columnId) + event.preventDefault() + scrollFocusedIntoView() + return + } + table.moveCellSelection(direction) + event.preventDefault() + scrollFocusedIntoView() + return + } + case "Home": + case "End": { + // Home/End: row start or end; with Ctrl/Cmd, the grid's corners. + const edge = event.key === "Home" ? ("first" as const) : ("last" as const) + const jumped = + event.metaKey || event.ctrlKey + ? jumpFocus(edge, edge, event.shiftKey && isRangeSelectionEnabled()) + : jumpFocus("same", edge, event.shiftKey && isRangeSelectionEnabled()) + if (jumped) event.preventDefault() + return + } + case "PageUp": + case "PageDown": { + const delta = (event.key === "PageUp" ? -1 : 1) * getPageJumpSize() + if (jumpFocus({ delta }, "same", event.shiftKey && isRangeSelectionEnabled())) { + event.preventDefault() + } + return + } + case "Enter": { + // The Notion/Airtable flow: Enter opens the editor when one is + // wired; the Excel move-down stays the fallback. + if (!event.shiftKey && requestCellEdit()) { + event.preventDefault() + return + } + moveFocusVisual(event.shiftKey ? "up" : "down") + event.preventDefault() + scrollFocusedIntoView() + return + } + case "F2": { + if (requestCellEdit()) event.preventDefault() + return + } + case "Tab": { + const before = table.getFocusedCell() + // Logical directions, no RTL swap: Tab means "next cell" in both + // reading directions, exactly like DOM tab order. + moveFocusVisual(event.shiftKey ? "left" : "right") + const after = table.getFocusedCell() + // Trap Tab only while it moved; at the edges focus leaves the grid, + // which keyboard users need to escape it at all. + if (after && after.id !== before?.id) { + event.preventDefault() + scrollFocusedIntoView() + } + return + } + case "Delete": + case "Backspace": { + const onCellsChange = getOnCellsChange() + if (!onCellsChange) return + const details = buildDataGridClearDetails( + table, + "clear", + false, + viewport + ) + if (details) onCellsChange(details) + event.preventDefault() + return + } + case "Escape": { + // Consume the key only while it has a selection to clear, so a + // grid inside a dialog still lets Escape close the dialog. + if (table.getCellSelectionBounds().length) { + table.resetCellSelection(true) + // The focused-cell attribute is gone after the commit; + // aria-activedescendant must not keep naming it. + requestAnimationFrame(syncActiveDescendant) + event.preventDefault() + event.stopPropagation() + } + return + } + } + + // Space on a cell whose content is a checkbox toggles it, the + // row-select idiom, so the selection column works by keyboard too. + if (event.key === " ") { + const checkbox = viewport + .querySelector("td[data-cell-focused]") + ?.querySelector( + '[role="checkbox"], input[type="checkbox"]' + ) + if (checkbox) { + checkbox.click() + event.preventDefault() + return + } + } + + // Type-to-edit: a printable character on the focused cell opens the + // editor seeded with it, replacing the value the way Notion and + // Airtable do. Plain Ctrl/Cmd chords stay shortcuts, but Ctrl+Alt is + // AltGr and bare Alt is the macOS Option layer - both type characters. + if ( + event.key.length === 1 && + !event.metaKey && + !(event.ctrlKey && !event.altKey) && + requestCellEdit(event.key) + ) { + event.preventDefault() + return + } + + // Space that opened nothing (read-only cell) must not scroll the page + // out from under the focused cell. + if (event.key === " " && table.getFocusedCell()) { + event.preventDefault() + } + } + + // Writes the active region to the system clipboard in both flavors. + // Needed because a native copy event only fires for a text selection + // or an editable target, and a focused grid is neither - Cmd/Ctrl+C + // would silently do nothing on macOS, Windows and Linux alike. The + // async API needs a secure context; the execCommand fallback covers + // the rest with the TSV flavor alone. + const writeSelectionToClipboard = async (): Promise<{ + text: string + grid: string[][] + } | null> => { + const grid = getDataGridActiveRegionGrid(getTable(), viewport) + if (!grid) return null + const text = serializeDataGridClipboardText(grid) + try { + if ( + typeof ClipboardItem !== "undefined" && + navigator.clipboard?.write + ) { + await navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": new Blob([text], { type: "text/plain" }), + "text/html": new Blob([renderDataGridClipboardHtml(grid)], { + type: "text/html", + }), + }), + ]) + return { text, grid } + } + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + return { text, grid } + } + } catch { + // Denied or unavailable; the legacy path below still applies. + } + const doc = viewport.ownerDocument + const scratch = doc.createElement("textarea") + scratch.value = text + scratch.style.position = "fixed" + scratch.style.opacity = "0" + doc.body.appendChild(scratch) + scratch.select() + let copied = false + try { + copied = doc.execCommand("copy") + } catch { + copied = false + } + scratch.remove() + focusTarget.focus() + return copied ? { text, grid } : null + } + + const copySelection = (event: ClipboardEvent, cut: boolean): boolean => { + const grid = getDataGridActiveRegionGrid(getTable(), viewport) + if (!grid || !event.clipboardData) return false + event.preventDefault() + const text = serializeDataGridClipboardText(grid) + event.clipboardData.setData("text/plain", text) + event.clipboardData.setData("text/html", renderDataGridClipboardHtml(grid)) + context.props.onCellsCopy?.({ text, grid, cut }) + return true + } + + const handleCopy = (event: ClipboardEvent) => { + if (isDataGridEditableTarget(event.target)) return + copySelection(event, false) + } + + const handleCut = (event: ClipboardEvent) => { + if (isDataGridEditableTarget(event.target)) return + if (!copySelection(event, true)) return + const onCellsChange = getOnCellsChange() + if (!onCellsChange) return + const details = buildDataGridClearDetails( + getTable(), + "cut", + true, + viewport + ) + if (details) onCellsChange(details) + } + + const handlePaste = (event: ClipboardEvent) => { + if (isDataGridEditableTarget(event.target)) return + const onCellsChange = getOnCellsChange() + if (!onCellsChange) return + const text = event.clipboardData?.getData("text/plain") + if (!text) return + event.preventDefault() + const table = getTable() + const result = buildDataGridPasteDetails( + table, + parseDataGridClipboardText(text), + viewport + ) + if (!result) return + onCellsChange(result.details) + // The pasted region becomes the selection, the documented contract; + // single mode keeps just the focused cell instead. + if (isRangeSelectionEnabled()) { + selectDataGridRegion(table, result.target) + } + } + + // Custom-control cells follow the same two-step gesture as editor + // cells: the first click on an UNFOCUSED cell only focuses it, the + // second activates the control, so moving the focus around can never + // fire actions. Radix triggers open on pointerdown and Base UI ones + // on click, so the press is intercepted at both; checkboxes keep + // their one-click toggle (the row-select idiom), data-cell-interactive + // widgets own all their gestures, and single-click edit mode skips + // the interception outright. + let pressedUnfocusedControl = false + const getTwoStepControl = (target: EventTarget | null): Element | null => { + if (!(target instanceof Element)) return null + if (target.closest("[data-cell-interactive]")) return null + if (target.closest('[role="checkbox"], input[type="checkbox"]')) { + return null + } + // Opt-in for widgets whose ACTIVE surface is bigger than any generic + // selector can know - a combobox whose whole blank chips strip opens + // it, a canvas editor. Marking the wrapper data-cell-control makes a + // press anywhere on it two-step, exactly like a bare button. + const marked = target.closest("[data-cell-control]") + if (marked) return marked + return target.closest( + 'button, a, select, input, textarea, [contenteditable], [role="combobox"]' + ) + } + const handleControlPointerDown = (event: PointerEvent) => { + pressedUnfocusedControl = false + if (event.button !== 0) return + if (event.shiftKey || event.ctrlKey || event.metaKey) return + if (context.props.tableLayout?.cellEditMode === "click") return + const control = getTwoStepControl(event.target) + const cell = control?.closest("td[data-col-id]") + if (!control || !cell || !viewport.contains(cell)) return + if (cell.hasAttribute("data-cell-focused")) return + const column = getTable().getColumn( + cell.getAttribute("data-col-id") ?? "" + ) + if (!column || column.columnDef.enableCellSelection === false) return + pressedUnfocusedControl = true + // Stops a Radix trigger's pointerdown open before React's root + // delegation sees it; the mouse events that focus the cell ride on. + event.stopPropagation() + } + const handleControlClick = (event: MouseEvent) => { + if (!pressedUnfocusedControl) return + pressedUnfocusedControl = false + // Swallows the activation click a Base UI trigger listens for. + event.preventDefault() + event.stopPropagation() + } + const handleControlMouseDown = (event: MouseEvent) => { + if (!pressedUnfocusedControl) return + // A Base UI trigger opens on the React-delegated mousedown, and all + // root-delegated handlers die together when the event stops here - + // including the td's own focus logic, so the selection start runs + // imperatively: same feature handler, same drag session. + event.stopPropagation() + event.preventDefault() + const cell = (event.target as Element | null)?.closest?.( + "td[data-col-id]" + ) + const rowId = cell + ?.closest("tr[data-row-id]") + ?.getAttribute("data-row-id") + const columnId = cell?.getAttribute("data-col-id") + if (!rowId || !columnId) return + const tableNow = getTable() + const cellApi = tableNow + .getRowsInDisplayOrder() + .find((row) => row.id === rowId) + ?.getAllCellsByColumnId()[columnId] + if (!cellApi) return + if (isRangeSelectionEnabled()) { + cellApi.getSelectionStartHandler()(event) + } else { + tableNow.setFocusedCell(rowId, columnId) + } + focusTarget.focus() + requestAnimationFrame(syncActiveDescendant) + } + + // Drag-to-select outranks in-cell controls: when a drag grew the + // range, the click that fires on release is swallowed in capture so a + // trigger under the pointer never opens. The selection key comparison + // is what distinguishes a drag from a motionless click made while a + // multi-cell selection already exists (a press on "+ Add row" or an + // in-cell control changes nothing, and its click must go through). + let dragStartedOnCell = false + let dragStartSelectionKey = "" + const getSelectionKey = () => { + const table = getTable() + const bounds = table.getCellSelectionBounds() + const bound = bounds[bounds.length - 1] + return `${table.getSelectedCellCount()}:${bounds.length}:${ + bound + ? `${bound.minRowIndex},${bound.minColumnIndex},${bound.maxRowIndex},${bound.maxColumnIndex}` + : "" + }` + } + const handleDragMouseUp = () => { + const wasDrag = + dragStartedOnCell && + getTable().getSelectedCellCount() > 1 && + getSelectionKey() !== dragStartSelectionKey + dragStartedOnCell = false + viewport.removeAttribute("data-cell-selecting") + if (!wasDrag) return + const squelch = (clickEvent: MouseEvent) => { + clickEvent.stopPropagation() + clickEvent.preventDefault() + } + viewport.addEventListener("click", squelch, { capture: true }) + setTimeout( + () => viewport.removeEventListener("click", squelch, true), + 0 + ) + } + + const handleMouseDown = (event: MouseEvent) => { + if (event.button !== 0) return + // The cell a click focuses reaches assistive tech once React commits. + requestAnimationFrame(syncActiveDescendant) + const target = event.target as Element | null + // A modifier press is a selection gesture all the way through, the + // Sheets contract: extending must not hand the keyboard to whatever + // control sits under the pointer, or the follow-up Shift+arrows land + // in a date button or a combobox input instead of growing the range. + // The extension itself runs through the cell's own React handlers, so + // the event must NOT be defaultPrevented - the delegated handlers + // treat that as consumed and the extension never happens. Only the + // browser's focus default is unwanted; it runs after the listeners, + // so the grid reclaims focus one task later. + if ( + (event.shiftKey || event.metaKey || event.ctrlKey) && + isRangeSelectionEnabled() && + target?.closest?.("td[data-col-id]") + ) { + setTimeout(() => focusTarget.focus(), 0) + } + // Any td press can become a drag; the squelch only arms when the + // range actually grew by release time. The attribute hides the fill + // handle for the session, so its overhang never sits in the drag + // path or churns from cell to cell while the range grows. + dragStartedOnCell = !!target?.closest?.("td") + if (dragStartedOnCell) { + dragStartSelectionKey = getSelectionKey() + viewport.setAttribute("data-cell-selecting", "") + } + if (!target?.closest?.('[data-slot="data-grid-cell-fill-handle"]')) return + // Ahead of React's root delegation, so the td underneath never starts a + // plain range drag from the handle. + event.preventDefault() + event.stopPropagation() + startDataGridFillSession({ + table: getTable(), + viewport, + onCellsChange: getOnCellsChange(), + }) + } + + // Single-click editing, the opt-in mode: a plain click that landed + // on the (now) focused cell opens its editor immediately. Drags were + // squelched in capture before this bubble listener, and modifier + // clicks are selection gestures. + const handleClickToEdit = (event: MouseEvent) => { + if (context.props.tableLayout?.cellEditMode !== "click") return + if (event.shiftKey || event.ctrlKey || event.metaKey) return + if (isDataGridInteractiveKeyTarget(event.target)) return + const cell = (event.target as HTMLElement | null)?.closest?.("td") + if (!cell?.hasAttribute("data-cell-focused")) return + requestCellEdit() + } + + // Double-click opens the editor on the cell the first click focused, + // matching Sheets. Interactive content keeps its own double-clicks, and + // the clicked cell must BE the focused one, or a double-click on a + // non-selectable cell would edit whichever cell held focus before. + const handleDoubleClick = (event: MouseEvent) => { + if (isDataGridInteractiveKeyTarget(event.target)) return + const target = event.target as HTMLElement | null + const cell = target?.closest?.("td") + if (!cell?.hasAttribute("data-cell-focused")) return + requestCellEdit() + } + + const previousTabIndex = focusTarget.getAttribute("tabindex") + const previousOutline = focusTarget.style.outline + if (keyboard) { + focusTarget.tabIndex = 0 + // The focused CELL ring is the indicator; the browser's outline around + // the whole grid on focus() would double it. + focusTarget.style.outline = "none" + viewport.addEventListener("keydown", handleKeyDown) + } + if (clipboard) { + viewport.addEventListener("copy", handleCopy) + viewport.addEventListener("cut", handleCut) + viewport.addEventListener("paste", handlePaste) + } + viewport.addEventListener("pointerdown", handleControlPointerDown) + viewport.addEventListener("mousedown", handleControlMouseDown) + viewport.addEventListener("click", handleControlClick) + viewport.addEventListener("mousedown", handleMouseDown) + viewport.addEventListener("click", handleClickToEdit) + viewport.addEventListener("dblclick", handleDoubleClick) + viewport.ownerDocument.addEventListener("mouseup", handleDragMouseUp) + // Selection-change notifications, read through the context getter so + // the latest callback fires without re-running this effect. The + // snapshot is only built when a callback is actually wired. + const toBound = (bound: { + minRowIndex: number + maxRowIndex: number + minColumnIndex: number + maxColumnIndex: number + }) => ({ + minRowIndex: bound.minRowIndex, + maxRowIndex: bound.maxRowIndex, + minColumnIndex: bound.minColumnIndex, + maxColumnIndex: bound.maxColumnIndex, + }) + const selectionSubscription = table.atoms.cellSelection?.subscribe(() => { + const onCellSelectionChange = context.props.onCellSelectionChange + if (!onCellSelectionChange) return + const tableNow = getTable() + const focusedCell = tableNow.getFocusedCell() + const activeBound = getDataGridActiveBound(tableNow) + onCellSelectionChange({ + focused: focusedCell + ? { rowId: focusedCell.row.id, columnId: focusedCell.column.id } + : null, + bounds: tableNow.getCellSelectionBounds().map(toBound), + activeBound: activeBound ? toBound(activeBound) : null, + visibleCellCount: getDataGridVisibleSelectedCellCount( + tableNow, + viewport + ), + }) + }) + setViewportEl(viewport) + + return () => { + if (keyboard) { + if (previousTabIndex === null) focusTarget.removeAttribute("tabindex") + else focusTarget.setAttribute("tabindex", previousTabIndex) + focusTarget.style.outline = previousOutline + focusTarget.removeAttribute("aria-activedescendant") + viewport.removeEventListener("keydown", handleKeyDown) + } + if (clipboard) { + viewport.removeEventListener("copy", handleCopy) + viewport.removeEventListener("cut", handleCut) + viewport.removeEventListener("paste", handlePaste) + } + viewport.removeEventListener("pointerdown", handleControlPointerDown) + viewport.removeEventListener("mousedown", handleControlMouseDown) + viewport.removeEventListener("click", handleControlClick) + viewport.removeEventListener("mousedown", handleMouseDown) + viewport.removeEventListener("click", handleClickToEdit) + viewport.removeEventListener("dblclick", handleDoubleClick) + viewport.ownerDocument.removeEventListener("mouseup", handleDragMouseUp) + viewport.removeAttribute("data-cell-selecting") + selectionSubscription?.unsubscribe() + clearTimeout(focusRetryTimer) + if (apiRef) apiRef.current = null + setViewportEl(null) + setEditorSession(null) + } + // Context getters serve fresh table/props inside every handler, so the + // effect re-runs only when the table itself is replaced. apiRef is only + // read and written here: keeping it out of the deps means an inline ref + // object cannot tear the listeners down every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, keyboard, clipboard, table.store]) + + // The editor overlay covers the cell but not the fill handle's + // straddling half, which would poke out beneath it; the viewport flags + // the session so the handle hides, as it does during a drag. + useEffect(() => { + if (!viewportEl || !editorSession) return + viewportEl.setAttribute("data-cell-editing", "") + return () => viewportEl.removeAttribute("data-cell-editing") + }, [viewportEl, editorSession]) + + // Closing an editor the user finished with keys hands focus back to the + // viewport so navigation continues where the edit left off; a commit can + // advance first (Tab across, and Enter down or up when + // cellEditEnterAdvance opts into the Sheets flow). A blur-initiated close + // must NOT refocus: the browser already moved focus where the user + // clicked, and yanking it back would reroute their next keystrokes into + // the grid. + const closeEditorSession = ( + advance: DataGridEditorAdvance, + refocus = true + ) => { + setEditorSession(null) + if (advance) { + // Same visual-space step as keyboard navigation, so a commit on a + // pinned row advances like any other row. + const tableNow = context.table + const focused = tableNow.getFocusedCell() + const target = + focused && viewportEl + ? getDataGridStepTarget( + tableNow, + viewportEl, + { rowId: focused.row.id, columnId: focused.column.id }, + advance + ) + : null + if (target && target !== "edge") { + tableNow.setFocusedCell(target.rowId, target.columnId) + } else if (!target) { + tableNow.moveCellSelection(advance) + } + } + if (!refocus) return + requestAnimationFrame(() => { + const focusEl = + viewportEl?.querySelector( + 'table[data-slot="data-grid-table"]' + ) ?? viewportEl + focusEl?.focus() + const cell = viewportEl?.querySelector( + "td[data-cell-focused]" + ) + if (cell?.id) focusEl?.setAttribute("aria-activedescendant", cell.id) + cell?.scrollIntoView?.({ + block: "nearest", + inline: "nearest", + behavior: "instant", + }) + }) + } + + // An unchanged commit dispatches nothing, the Excel model; a parse + // rejection reports through the batch's rejected list instead of writing. + const commitEditorSession = ( + raw: string, + advance: DataGridEditorAdvance, + refocus = true + ) => { + const session = editorSession + closeEditorSession(advance, refocus) + const onCellsChange = context.props.onCellsChange + if (!session || !onCellsChange || raw === session.baseline) return + const tableNow = context.table + const row = tableNow + .getRowsInDisplayOrder() + .find((candidate) => candidate.id === session.rowId) + const column = tableNow.getColumn(session.columnId) + const cellEdit = + column && row + ? getDataGridWritableCellEdit(column, row.original) + : null + if (!row || !column || !cellEdit) return + const previousValue = row.getAllCellsByColumnId()[column.id]?.getValue() + let value: unknown = raw + if (cellEdit.parse) { + const parsed = cellEdit.parse(raw, row.original) + if (parsed === undefined) { + onCellsChange({ + source: "edit", + changes: [], + rejected: [ + { + rowId: session.rowId, + columnId: session.columnId, + raw, + reason: "invalid", + }, + ], + }) + return + } + value = parsed + } + onCellsChange({ + source: "edit", + changes: [ + { + rowId: session.rowId, + columnId: session.columnId, + row: row.original, + previousValue, + value, + }, + ], + rejected: [], + }) + } + + return ( + <> +