"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, }