{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "layout-blocks-panel",
  "title": "Layout Blocks Panel",
  "description": "A virtualized list of layout blocks with typed badges and source navigation.",
  "dependencies": [
    "@tanstack/react-virtual@^3.13.26",
    "@base-ui/react@^1.4.1"
  ],
  "registryDependencies": [
    "utils",
    "scroll-area"
  ],
  "files": [
    {
      "path": "components/extend/layout-blocks-panel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ScrollArea as ScrollAreaPrimitive } from \"@base-ui/react/scroll-area\"\nimport { useVirtualizer } from \"@tanstack/react-virtual\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  ScrollArea as InlineScrollArea,\n  ScrollBar,\n} from \"@/components/ui/scroll-area\"\nimport { IconPlaceholder } from \"@/components/icon-placeholder\"\n\nfunction AlignBoxBottomCenterGlyph(props: InlineRegistryIconProps) {\n  return (\n    <IconPlaceholder\n      lucide=\"AlignVerticalJustifyEnd\"\n      tabler=\"IconAlignBoxBottomCenter\"\n      hugeicons=\"AlignBoxBottomCenterIcon\"\n      phosphor=\"AlignBottomIcon\"\n      remixicon=\"RiAlignBottom\"\n      {...props}\n    />\n  )\n}\nfunction Heading01Glyph(props: InlineRegistryIconProps) {\n  return (\n    <IconPlaceholder\n      lucide=\"Heading1\"\n      tabler=\"IconH1\"\n      hugeicons=\"Heading01Icon\"\n      phosphor=\"TextHOneIcon\"\n      remixicon=\"RiH1\"\n      {...props}\n    />\n  )\n}\nfunction ImageCompositionGlyph(props: InlineRegistryIconProps) {\n  return (\n    <IconPlaceholder\n      lucide=\"Image\"\n      tabler=\"IconPhoto\"\n      hugeicons=\"ImageCompositionIcon\"\n      phosphor=\"ImageSquareIcon\"\n      remixicon=\"RiImageLine\"\n      {...props}\n    />\n  )\n}\nfunction LeftToRightListBulletGlyph(props: InlineRegistryIconProps) {\n  return (\n    <IconPlaceholder\n      lucide=\"Columns3Icon\"\n      tabler=\"IconLayoutColumns\"\n      hugeicons=\"LeftToRightListBulletIcon\"\n      phosphor=\"ColumnsIcon\"\n      remixicon=\"RiLayoutColumnLine\"\n      {...props}\n    />\n  )\n}\nfunction ParagraphGlyph(props: InlineRegistryIconProps) {\n  return (\n    <IconPlaceholder\n      lucide=\"Pilcrow\"\n      tabler=\"IconPilcrow\"\n      hugeicons=\"ParagraphIcon\"\n      phosphor=\"ParagraphIcon\"\n      remixicon=\"RiParagraph\"\n      {...props}\n    />\n  )\n}\nfunction Table01Glyph(props: InlineRegistryIconProps) {\n  return (\n    <IconPlaceholder\n      lucide=\"Table\"\n      tabler=\"IconTable\"\n      hugeicons=\"Table01Icon\"\n      phosphor=\"TableIcon\"\n      remixicon=\"RiTableLine\"\n      {...props}\n    />\n  )\n}\nfunction TextCenterlineCenterTopGlyph(props: InlineRegistryIconProps) {\n  return (\n    <IconPlaceholder\n      lucide=\"PanelTop\"\n      tabler=\"IconLayoutNavbar\"\n      hugeicons=\"TextCenterlineCenterTopIcon\"\n      phosphor=\"AlignTopSimpleIcon\"\n      remixicon=\"RiLayoutTopLine\"\n      {...props}\n    />\n  )\n}\nfunction TextNumberSignGlyph(props: InlineRegistryIconProps) {\n  return (\n    <IconPlaceholder\n      lucide=\"Hash\"\n      tabler=\"IconHash\"\n      hugeicons=\"TextNumberSignIcon\"\n      phosphor=\"HashIcon\"\n      remixicon=\"RiHashtag\"\n      {...props}\n    />\n  )\n}\nexport type LayoutBlockType =\n  | \"heading\"\n  | \"paragraph\"\n  | \"list\"\n  | \"table\"\n  | \"figure\"\n  | \"header\"\n  | \"footer\"\n  | \"page_number\"\nexport type LayoutBlockItem = {\n  id: string\n  type: LayoutBlockType\n  label?: string\n  text: string\n  page?: number\n  confidence?: number\n}\nexport const layoutBlockStyles: Record<\n  LayoutBlockType,\n  {\n    label: string\n    icon: React.ComponentType<InlineRegistryIconProps>\n    color: string\n    overlay: string\n    mutedOverlay: string\n    ring: string\n    badge: string\n  }\n> = {\n  heading: {\n    label: \"Heading\",\n    icon: Heading01Glyph,\n    color: \"#8b5cf6\",\n    overlay:\n      \"border-violet-500/70 bg-violet-500/10 shadow-[0_4px_16px_rgb(139_92_246_/_12%)]\",\n    mutedOverlay: \"border-violet-500/35 bg-violet-500/5\",\n    ring: \"border-violet-500/60 bg-violet-500/5\",\n    badge:\n      \"bg-violet-50 text-violet-600 dark:bg-violet-300/10 dark:text-violet-300\",\n  },\n  paragraph: {\n    label: \"Paragraph\",\n    icon: ParagraphGlyph,\n    color: \"#3b82f6\",\n    overlay:\n      \"border-blue-500/70 bg-blue-500/10 shadow-[0_4px_16px_rgb(59_130_246_/_12%)]\",\n    mutedOverlay: \"border-blue-500/35 bg-blue-500/5\",\n    ring: \"border-blue-500/60 bg-blue-500/5\",\n    badge: \"bg-blue-50 text-blue-600 dark:bg-blue-300/10 dark:text-blue-300\",\n  },\n  list: {\n    label: \"List\",\n    icon: LeftToRightListBulletGlyph,\n    color: \"#10b981\",\n    overlay:\n      \"border-emerald-500/70 bg-emerald-500/10 shadow-[0_4px_16px_rgb(16_185_129_/_12%)]\",\n    mutedOverlay: \"border-emerald-500/35 bg-emerald-500/5\",\n    ring: \"border-emerald-500/60 bg-emerald-500/5\",\n    badge:\n      \"bg-emerald-50 text-emerald-600 dark:bg-emerald-300/10 dark:text-emerald-300\",\n  },\n  table: {\n    label: \"Table\",\n    icon: Table01Glyph,\n    color: \"#f59e0b\",\n    overlay:\n      \"border-amber-500/70 bg-amber-500/10 shadow-[0_4px_16px_rgb(245_158_11_/_12%)]\",\n    mutedOverlay: \"border-amber-500/35 bg-amber-500/5\",\n    ring: \"border-amber-500/60 bg-amber-500/5\",\n    badge:\n      \"bg-amber-50 text-amber-600 dark:bg-amber-300/10 dark:text-amber-300\",\n  },\n  figure: {\n    label: \"Figure\",\n    icon: ImageCompositionGlyph,\n    color: \"#f43f5e\",\n    overlay:\n      \"border-rose-500/70 bg-rose-500/10 shadow-[0_4px_16px_rgb(244_63_94_/_12%)]\",\n    mutedOverlay: \"border-rose-500/35 bg-rose-500/5\",\n    ring: \"border-rose-500/60 bg-rose-500/5\",\n    badge: \"bg-rose-50 text-rose-600 dark:bg-rose-300/10 dark:text-rose-300\",\n  },\n  header: {\n    label: \"Header\",\n    icon: TextCenterlineCenterTopGlyph,\n    color: \"#06b6d4\",\n    overlay:\n      \"border-cyan-500/70 bg-cyan-500/10 shadow-[0_4px_16px_rgb(6_182_212_/_12%)]\",\n    mutedOverlay: \"border-cyan-500/35 bg-cyan-500/5\",\n    ring: \"border-cyan-500/60 bg-cyan-500/5\",\n    badge: \"bg-cyan-50 text-cyan-600 dark:bg-cyan-300/10 dark:text-cyan-300\",\n  },\n  footer: {\n    label: \"Footer\",\n    icon: AlignBoxBottomCenterGlyph,\n    color: \"#64748b\",\n    overlay:\n      \"border-slate-500/70 bg-slate-500/10 shadow-[0_4px_16px_rgb(100_116_139_/_12%)]\",\n    mutedOverlay: \"border-slate-500/35 bg-slate-500/5\",\n    ring: \"border-slate-500/60 bg-slate-500/5\",\n    badge:\n      \"bg-slate-50 text-slate-600 dark:bg-slate-300/10 dark:text-slate-300\",\n  },\n  page_number: {\n    label: \"Page number\",\n    icon: TextNumberSignGlyph,\n    color: \"#71717a\",\n    overlay:\n      \"border-zinc-500/70 bg-zinc-500/10 shadow-[0_4px_16px_rgb(113_113_122_/_12%)]\",\n    mutedOverlay: \"border-zinc-500/35 bg-zinc-500/5\",\n    ring: \"border-zinc-500/60 bg-zinc-500/5\",\n    badge: \"bg-zinc-50 text-zinc-600 dark:bg-zinc-300/10 dark:text-zinc-300\",\n  },\n}\nconst BLOCK_ROW_MIN_ESTIMATE = 92\nconst BLOCK_ROW_VERTICAL_CHROME = 62\nconst BLOCK_LINE_HEIGHT = 20\nconst BLOCK_ESTIMATED_CHARS_PER_LINE = 42\nconst BLOCK_ROW_GAP = 8\nconst BLOCK_LIST_PADDING = 12\nfunction estimateRowHeight(block: LayoutBlockItem, clampText: boolean) {\n  const plainText = block.text\n    .replace(/<[^>]*>/g, \"\")\n    .replace(/\\s+/g, \" \")\n    .trim()\n  const explicitLineCount = block.text.split(/\\n+/).filter(Boolean).length\n  const wrappedLineCount = Math.max(\n    explicitLineCount,\n    Math.ceil(plainText.length / BLOCK_ESTIMATED_CHARS_PER_LINE)\n  )\n  const contentHeight =\n    Math.max(1, clampText ? Math.min(3, wrappedLineCount) : wrappedLineCount) *\n    BLOCK_LINE_HEIGHT\n  const typeExtraHeight =\n    block.type === \"figure\" ? 44 : block.type === \"table\" ? 28 : 0\n  return Math.max(\n    BLOCK_ROW_MIN_ESTIMATE,\n    BLOCK_ROW_VERTICAL_CHROME + contentHeight + typeExtraHeight + BLOCK_ROW_GAP\n  )\n}\nfunction LayoutBlockCard<Block extends LayoutBlockItem>({\n  block,\n  isActive,\n  onFocusBlock,\n  onBlurBlock,\n  onSelectBlock,\n  renderText,\n}: {\n  block: Block\n  isActive: boolean\n  onFocusBlock: (block: Block) => void\n  onBlurBlock?: () => void\n  onSelectBlock?: (block: Block) => void\n  renderText?: (text: string) => React.ReactNode\n}) {\n  const style = layoutBlockStyles[block.type]\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={isActive}\n      onMouseLeave={onBlurBlock}\n      onBlur={onBlurBlock}\n      onClick={() => {\n        onFocusBlock(block)\n        onSelectBlock?.(block)\n      }}\n      onMouseEnter={() => onFocusBlock(block)}\n      onFocus={() => onFocusBlock(block)}\n      className={cn(\n        \"w-full rounded-lg border bg-background p-3 text-left hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n        isActive && cn(\"shadow-[0_0_0_1px_rgb(0_0_0_/_4%)]\", style.ring)\n      )}\n    >\n      <div className=\"min-w-0\">\n        <div className=\"flex items-center justify-between gap-2\">\n          <div className=\"flex min-w-0 items-center gap-2\">\n            <div\n              className={cn(\n                \"inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium\",\n                style.badge\n              )}\n            >\n              <style.icon className=\"size-3.5\" />\n              {block.label ?? style.label}\n            </div>\n            {block.confidence !== undefined && (\n              <div className=\"truncate text-xs text-muted-foreground\">\n                {Math.round(block.confidence * 100)}%\n              </div>\n            )}\n          </div>\n          {block.page !== undefined && (\n            <div className=\"shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground\">\n              p. {block.page}\n            </div>\n          )}\n        </div>\n        <div className=\"mt-2 text-sm text-foreground/90\">\n          {renderText ? (\n            renderText(block.text)\n          ) : (\n            <p className=\"line-clamp-3 text-[13px] leading-5 wrap-anywhere whitespace-pre-line\">\n              {block.text}\n            </p>\n          )}\n        </div>\n      </div>\n    </button>\n  )\n}\nexport function LayoutBlocksPanel<Block extends LayoutBlockItem>({\n  activeBlockId,\n  blocks,\n  className,\n  heading,\n  ariaLabel,\n  emptyMessage = \"No layout blocks found.\",\n  onBlockFocus,\n  onBlockBlur,\n  onBlockSelect,\n  renderText,\n}: {\n  activeBlockId?: string\n  blocks: readonly Block[]\n  className?: string\n  heading?: string\n  ariaLabel?: string\n  emptyMessage?: string\n  onBlockFocus?: (block: Block) => void\n  onBlockBlur?: () => void\n  onBlockSelect?: (block: Block) => void\n  renderText?: (text: string) => React.ReactNode\n}) {\n  const scrollViewportRef = React.useRef<HTMLDivElement | null>(null)\n  const [localActiveBlockId, setLocalActiveBlockId] = React.useState<string>()\n  const activeId = activeBlockId ?? localActiveBlockId\n  const focusedBlockId = blocks.some((block) => block.id === activeId)\n    ? activeId\n    : blocks[0]?.id\n  const estimateBlockSize = React.useCallback(\n    (index: number) => {\n      const block = blocks[index]\n      return block\n        ? estimateRowHeight(block, !renderText)\n        : BLOCK_ROW_MIN_ESTIMATE\n    },\n    [blocks, renderText]\n  )\n  const virtualizer = useVirtualizer({\n    count: blocks.length,\n    estimateSize: estimateBlockSize,\n    getItemKey: (index) => blocks[index]?.id ?? index,\n    getScrollElement: () => scrollViewportRef.current,\n    overscan: 6,\n  })\n  const focusBlock = React.useCallback(\n    (block: Block) => {\n      setLocalActiveBlockId(block.id)\n      onBlockFocus?.(block)\n    },\n    [onBlockFocus]\n  )\n  return (\n    <aside\n      aria-label={ariaLabel}\n      className={cn(\"flex h-[420px] min-h-0 flex-col bg-background\", className)}\n    >\n      {heading && (\n        <div className=\"flex items-center justify-between border-b px-4 py-3 text-sm font-medium\">\n          {heading}\n          <span className=\"text-xs text-muted-foreground\">{blocks.length}</span>\n        </div>\n      )}\n      <InlineScrollArea2\n        className=\"min-h-0 flex-1\"\n        scrollFade\n        viewportRef={scrollViewportRef}\n      >\n        {blocks.length ? (\n          <div\n            className=\"relative\"\n            style={{\n              height: virtualizer.getTotalSize() + BLOCK_LIST_PADDING * 2,\n            }}\n          >\n            {virtualizer.getVirtualItems().map((virtualRow) => {\n              const block = blocks[virtualRow.index]\n              if (!block) return null\n              return (\n                <div\n                  key={virtualRow.key}\n                  ref={virtualizer.measureElement}\n                  data-index={virtualRow.index}\n                  className=\"absolute top-0 right-3 left-3 pb-2 [contain:layout_paint]\"\n                  style={{\n                    transform: `translateY(${virtualRow.start + BLOCK_LIST_PADDING}px)`,\n                  }}\n                >\n                  <LayoutBlockCard\n                    block={block}\n                    isActive={block.id === focusedBlockId}\n                    onFocusBlock={focusBlock}\n                    onBlurBlock={onBlockBlur}\n                    onSelectBlock={onBlockSelect}\n                    renderText={renderText}\n                  />\n                </div>\n              )\n            })}\n          </div>\n        ) : (\n          <div className=\"p-3\">\n            <div\n              role=\"status\"\n              className=\"rounded-lg border bg-muted/30 p-3 text-sm text-muted-foreground\"\n            >\n              {emptyMessage}\n            </div>\n          </div>\n        )}\n      </InlineScrollArea2>\n    </aside>\n  )\n}\ntype InlineRegistryIconProps = Omit<\n  React.ComponentProps<\"svg\">,\n  \"children\" | \"strokeWidth\"\n> & { strokeWidth?: number }\nfunction InlineScrollArea2({\n  className,\n  children,\n  orientation = \"both\",\n  scrollFade = false,\n  scrollbarGutter = false,\n  scrollbarOverflowOnly = false,\n  viewportClassName,\n  viewportProps,\n  viewportRef,\n  ...props\n}: InlineScrollAreaProps) {\n  const {\n    className: viewportPropsClassName,\n    ref: viewportPropsRef,\n    ...resolvedViewportProps\n  } = viewportProps ?? {}\n  const composedViewportRef = React.useMemo(\n    () => InlineComposeRefs(viewportPropsRef, viewportRef),\n    [viewportPropsRef, viewportRef]\n  )\n\n  if (\n    !viewportProps &&\n    !viewportRef &&\n    !viewportClassName &&\n    !scrollFade &&\n    !scrollbarGutter &&\n    !scrollbarOverflowOnly\n  ) {\n    return (\n      <InlineScrollArea\n        {...props}\n        className={cn(\n          \"size-full min-h-0\",\n          orientation === \"horizontal\" &&\n            \"[&>[data-orientation=vertical]]:hidden\",\n          className\n        )}\n      >\n        {children}\n        {orientation !== \"vertical\" ? (\n          <ScrollBar orientation=\"horizontal\" />\n        ) : null}\n      </InlineScrollArea>\n    )\n  }\n\n  return (\n    <ScrollAreaPrimitive.Root\n      className={cn(\n        \"size-full min-h-0\",\n        scrollbarOverflowOnly &&\n          \"[&:not(:has([data-slot=scroll-area-viewport][data-has-overflow-x]))_[data-orientation=horizontal]]:hidden [&:not(:has([data-slot=scroll-area-viewport][data-has-overflow-y]))_[data-orientation=vertical]]:hidden\",\n        className\n      )}\n      {...props}\n    >\n      <ScrollAreaPrimitive.Viewport\n        {...resolvedViewportProps}\n        ref={composedViewportRef}\n        className={cn(\n          \"h-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          scrollFade &&\n            \"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] [--fade-size:1.5rem]\",\n          scrollbarGutter && orientation !== \"vertical\" && \"pb-3.5\",\n          scrollbarGutter && orientation !== \"horizontal\" && \"pe-3.5\",\n          viewportPropsClassName,\n          viewportClassName\n        )}\n        data-slot=\"scroll-area-viewport\"\n      >\n        {children}\n      </ScrollAreaPrimitive.Viewport>\n      {orientation !== \"horizontal\" ? (\n        <ScrollBar orientation=\"vertical\" />\n      ) : null}\n      {orientation !== \"vertical\" ? (\n        <ScrollBar orientation=\"horizontal\" />\n      ) : null}\n      {orientation === \"both\" ? <ScrollAreaPrimitive.Corner /> : null}\n    </ScrollAreaPrimitive.Root>\n  )\n}\ntype InlineScrollAreaProps = ScrollAreaPrimitive.Root.Props & {\n  orientation?: \"vertical\" | \"horizontal\" | \"both\"\n  scrollFade?: boolean\n  scrollbarGutter?: boolean\n  scrollbarOverflowOnly?: boolean\n  viewportClassName?: string\n  viewportProps?: ScrollAreaPrimitive.Viewport.Props\n  viewportRef?: React.Ref<HTMLDivElement>\n}\nfunction InlineComposeRefs<T>(...refs: Array<React.Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (!ref) continue\n      if (typeof ref === \"function\") ref(node)\n      else ref.current = node\n    }\n  }\n}\n",
      "type": "registry:ui",
      "target": "@components/extend/layout-blocks-panel.tsx"
    }
  ],
  "categories": [
    "documents"
  ],
  "type": "registry:ui"
}
