{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segmented-document",
  "title": "Segmented Document",
  "description": "Shared segmented-document model, provider, viewport controller, and anchor navigation for split, partition, source, OCR, and bbox viewers.",
  "registryDependencies": [
    "@retab/segments",
    "@retab/segment-interaction",
    "@retab/use-segment-interaction",
    "@retab/effect-key",
    "@retab/use-keyed-layout-effect",
    "@retab/use-keyed-mount-effect",
    "@retab/use-mount-effect"
  ],
  "files": [
    {
      "path": "registry/new-york-v4/ui/segmented-document-model.ts",
      "content": "import { segmentsPageCount, type Segment } from \"@/lib/segments\";\n\nexport type DocumentSegment = Segment & {\n  /** Stable domain id for the item that produced this segment. */\n  sourceId?: string;\n};\n\nexport type SegmentBounds = {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n};\n\nexport type SegmentAnchor = {\n  id: string;\n  segmentId: string;\n  /** 1-based page/frame number. */\n  pageNumber: number;\n  /** Normalized page-local rectangle. Omit for whole-page anchors. */\n  bounds?: SegmentBounds;\n};\n\nexport type SegmentedPage = {\n  pageNumber: number;\n  width?: number;\n  height?: number;\n};\n\nexport type SegmentRow = {\n  id: string;\n  label?: string;\n  /** Generic display grouping only; domain vote/output semantics stay outside. */\n  segments: DocumentSegment[];\n};\n\nexport type SegmentedDocumentModel = {\n  pages: SegmentedPage[];\n  /** Viewport/navigation projection used for page ownership and jumps. */\n  segments: DocumentSegment[];\n  /** Optional page-local targets for segment-level navigation and overlays. */\n  anchors?: SegmentAnchor[];\n  /** Optional generic row projection for visual ribbons or grouped legends. */\n  rows?: SegmentRow[];\n};\n\nexport function createSegmentedDocumentModel({\n  anchors,\n  pageCount,\n  pages,\n  rows,\n  segments,\n}: {\n  anchors?: SegmentAnchor[];\n  pageCount?: number;\n  pages?: SegmentedPage[];\n  rows?: SegmentRow[];\n  segments: DocumentSegment[];\n}): SegmentedDocumentModel {\n  return {\n    pages:\n      pages ?? createSegmentedPages(pageCount ?? segmentsPageCount(segments)),\n    segments,\n    ...(anchors ? { anchors } : null),\n    ...(rows ? { rows } : null),\n  };\n}\n\nexport function createSegmentedPages(pageCount: number): SegmentedPage[] {\n  const count =\n    Number.isFinite(pageCount) && pageCount > 0 ? Math.floor(pageCount) : 0;\n  return Array.from({ length: count }, (_, index) => ({\n    pageNumber: index + 1,\n  }));\n}\n",
      "type": "registry:ui",
      "target": "@ui/segmented-document-model.ts"
    },
    {
      "path": "registry/new-york-v4/ui/segmented-document-provider.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { SegmentedDocumentModel } from \"./segmented-document-model\";\nimport {\n  useSegmentViewportController,\n  type SegmentedDocumentViewport,\n} from \"./use-segment-viewport-controller\";\n\ntype SegmentedDocumentContextValue = {\n  model: SegmentedDocumentModel;\n  viewport: SegmentedDocumentViewport;\n};\n\nconst SegmentedDocumentContext =\n  React.createContext<SegmentedDocumentContextValue | null>(null);\n\nexport function SegmentedDocumentProvider({\n  children,\n  model,\n}: {\n  children: React.ReactNode;\n  model: SegmentedDocumentModel;\n}) {\n  const viewport = useSegmentViewportController({ segments: model.segments });\n  const value = React.useMemo<SegmentedDocumentContextValue>(\n    () => ({ model, viewport }),\n    [model, viewport],\n  );\n\n  return (\n    <SegmentedDocumentContext.Provider value={value}>\n      {children}\n    </SegmentedDocumentContext.Provider>\n  );\n}\n\nfunction useSegmentedDocumentContext(): SegmentedDocumentContextValue {\n  const context = React.useContext(SegmentedDocumentContext);\n  if (!context) {\n    throw new Error(\"SegmentedDocumentProvider context is missing.\");\n  }\n  return context;\n}\n\nexport function useSegmentedDocumentViewport(): SegmentedDocumentViewport {\n  return useSegmentedDocumentContext().viewport;\n}\n\nexport function useSegmentedDocumentModel(): SegmentedDocumentModel {\n  return useSegmentedDocumentContext().model;\n}\n",
      "type": "registry:ui",
      "target": "@ui/segmented-document-provider.tsx"
    },
    {
      "path": "registry/new-york-v4/ui/segmented-item-link.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type {\n  DocumentSegment,\n  SegmentAnchor,\n} from \"./segmented-document-model\";\nimport {\n  useSegmentedDocumentModel,\n  useSegmentedDocumentViewport,\n} from \"./segmented-document-provider\";\nimport { useKeyedMountEffect } from \"@/hooks/use-keyed-mount-effect\";\nimport { joinEffectKey } from \"@/lib/effect-key\";\n\nexport type SegmentedItemNavigationOptions = {\n  behavior?: ScrollBehavior;\n  clearPreview?: boolean;\n};\n\nexport type SegmentedItemLink = {\n  activeAnchor: SegmentAnchor | null;\n  activeAnchors: readonly SegmentAnchor[];\n  activeItemId: string | null;\n  activeSegment: DocumentSegment | null;\n  clearPreview: () => void;\n  navigateItem: (\n    itemId: string,\n    options?: SegmentedItemNavigationOptions,\n  ) => void;\n  previewItem: (itemId: string | null) => void;\n  selectItem: (itemId: string | null) => void;\n  selectedItemId: string | null;\n};\n\nexport type SegmentedItemLinkOptions = {\n  initialItemId?: string | null;\n};\n\nconst EMPTY_SEGMENT_ANCHORS: readonly SegmentAnchor[] = [];\n\nexport function useSegmentedItemLink(\n  options: SegmentedItemLinkOptions = {},\n): SegmentedItemLink {\n  const model = useSegmentedDocumentModel();\n  const viewport = useSegmentedDocumentViewport();\n  const [selectedItemId, setSelectedItemId] = React.useState<string | null>(\n    null,\n  );\n  const segmentByItemId = React.useMemo(\n    () =>\n      new Map(\n        model.segments.map((segment) => [\n          segment.sourceId ?? segment.id,\n          segment,\n        ]),\n      ),\n    [model.segments],\n  );\n  const anchorsBySegmentId = React.useMemo(\n    () =>\n      (model.anchors ?? []).reduce((anchorsBySegmentId, anchor) => {\n        const anchors = anchorsBySegmentId.get(anchor.segmentId);\n        if (anchors) {\n          anchors.push(anchor);\n        } else {\n          anchorsBySegmentId.set(anchor.segmentId, [anchor]);\n        }\n        return anchorsBySegmentId;\n      }, new Map<string, SegmentAnchor[]>()),\n    [model.anchors],\n  );\n  const previewSegment =\n    model.segments.find(\n      (segment) => segment.id === viewport.model.previewSegmentId,\n    ) ?? null;\n  const selectedSegment = selectedItemId\n    ? (segmentByItemId.get(selectedItemId) ?? null)\n    : null;\n  const activeSegment = previewSegment ?? selectedSegment;\n  const activeItemId = activeSegment\n    ? (activeSegment.sourceId ?? activeSegment.id)\n    : selectedItemId;\n  const activeAnchors = activeSegment\n    ? (anchorsBySegmentId.get(activeSegment.id) ?? EMPTY_SEGMENT_ANCHORS)\n    : EMPTY_SEGMENT_ANCHORS;\n  const activeAnchor = activeAnchors[0] ?? null;\n\n  useKeyedMountEffect(joinEffectKey([segmentByItemId, selectedItemId]), () => {\n    if (selectedItemId && !segmentByItemId.has(selectedItemId)) {\n      setSelectedItemId(null);\n    }\n  });\n\n  useKeyedMountEffect(\n    joinEffectKey([options.initialItemId, segmentByItemId, selectedItemId]),\n    () => {\n      const initialItemId = options.initialItemId ?? null;\n      if (\n        !initialItemId ||\n        selectedItemId != null ||\n        !segmentByItemId.has(initialItemId)\n      ) {\n        return;\n      }\n      setSelectedItemId(initialItemId);\n    },\n  );\n\n  const clearPreview = viewport.interaction.clearPreview;\n\n  const navigateSegment = React.useCallback(\n    (segment: DocumentSegment, options?: SegmentedItemNavigationOptions) => {\n      const anchor = anchorsBySegmentId.get(segment.id)?.[0];\n      if (anchor) {\n        viewport.navigation.scrollToAnchor(anchor, options);\n        return;\n      }\n      viewport.navigation.scrollToSegmentStart(segment, options);\n    },\n    [anchorsBySegmentId, viewport.navigation],\n  );\n\n  const navigateItem = React.useCallback(\n    (itemId: string, options?: SegmentedItemNavigationOptions) => {\n      const segment = segmentByItemId.get(itemId);\n      if (!segment) return;\n\n      navigateSegment(segment, options);\n    },\n    [navigateSegment, segmentByItemId],\n  );\n\n  const previewItem = React.useCallback(\n    (itemId: string | null) => {\n      if (!itemId) {\n        viewport.interaction.clearPreview();\n        return;\n      }\n\n      const segment = segmentByItemId.get(itemId);\n      if (!segment) return;\n\n      viewport.interaction.previewSegment(segment.id);\n    },\n    [segmentByItemId, viewport.interaction],\n  );\n\n  const selectItem = React.useCallback(\n    (itemId: string | null) => {\n      if (!itemId) {\n        setSelectedItemId(null);\n        return;\n      }\n\n      if (!segmentByItemId.has(itemId)) return;\n\n      setSelectedItemId(itemId);\n      viewport.interaction.clearPreview();\n    },\n    [segmentByItemId, viewport.interaction],\n  );\n\n  return React.useMemo(\n    () => ({\n      activeAnchor,\n      activeAnchors,\n      activeItemId,\n      activeSegment,\n      clearPreview,\n      navigateItem,\n      previewItem,\n      selectItem,\n      selectedItemId,\n    }),\n    [\n      activeAnchor,\n      activeAnchors,\n      activeItemId,\n      activeSegment,\n      clearPreview,\n      navigateItem,\n      previewItem,\n      selectItem,\n      selectedItemId,\n    ],\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/segmented-item-link.ts"
    },
    {
      "path": "registry/new-york-v4/ui/use-segment-viewport-controller.ts",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useMountEffect } from \"@/hooks/use-mount-effect\";\n\nimport {\n  getSegmentInteractionState,\n  type SegmentInteraction,\n  type SegmentInteractionState,\n} from \"@/lib/segment-interaction\";\nimport { firstSegmentPage, type Segment } from \"@/lib/segments\";\n\nimport type {\n  DocumentSegment,\n  SegmentAnchor,\n} from \"./segmented-document-model\";\nimport { useSegmentInteraction } from \"./use-segment-interaction\";\nimport { useKeyedMountEffect } from \"@/hooks/use-keyed-mount-effect\";\nimport { useKeyedLayoutEffect } from \"@/hooks/use-keyed-layout-effect\";\nimport { joinEffectKey } from \"@/lib/effect-key\";\n\nconst RAIL_VISIBILITY_MARGIN = 24;\nconst PROGRAMMATIC_SCROLL_WINDOW_MS = 120;\nconst USER_SCROLL_IDLE_MS = 400;\n\nexport interface SegmentViewportModel extends SegmentInteractionState {\n  scrollProgress: number;\n}\n\nexport type SegmentDocumentHandle = {\n  getViewportElement?: () => HTMLElement | null;\n  scrollToPage: (page: number, options?: ScrollToOptions) => void;\n  scrollToAnchor?: (anchor: SegmentAnchor, options?: ScrollToOptions) => void;\n  scrollToPageArea?: (\n    target: {\n      pageNumber: number;\n      top: number;\n      left?: number;\n      width?: number;\n      height?: number;\n    },\n    options?: ScrollToOptions,\n  ) => void;\n};\n\nexport type SegmentNavigationOptions = ScrollToOptions & {\n  clearPreview?: boolean;\n};\n\nexport interface SegmentViewportController {\n  model: SegmentViewportModel;\n  interaction: SegmentInteraction;\n  documentHandlers: {\n    onCurrentPageChange: (page: number) => void;\n    onScrollProgressChange: (progress: number) => void;\n    setDocumentHandle: (handle: SegmentDocumentHandle | null) => void;\n  };\n  navigation: {\n    scrollToPage: (page: number, options?: SegmentNavigationOptions) => void;\n    scrollToSegmentStart: (\n      segment: DocumentSegment,\n      options?: SegmentNavigationOptions,\n    ) => void;\n    scrollToAnchor: (\n      anchor: SegmentAnchor,\n      options?: SegmentNavigationOptions,\n    ) => void;\n  };\n  rail: {\n    setViewportElement: (element: HTMLElement | null) => void;\n    setPageElement: (page: number, element: HTMLElement | null) => void;\n    onPointerEnter: () => void;\n    onPointerLeave: () => void;\n    onScroll: () => void;\n  };\n}\n\ninterface RailFollowState {\n  isPointerInsideRail: boolean;\n  isUserScrollingRail: boolean;\n  lastProgrammaticScrollAt: number;\n  idleTimer: number | null;\n}\n\nexport function useSegmentViewportController({\n  segments,\n}: {\n  segments: Segment[];\n}): SegmentViewportController {\n  const [currentPage, setCurrentPage] = React.useState<number | null>(1);\n  const [scrollProgress, setScrollProgress] = React.useState(0);\n  const documentHandleRef = React.useRef<SegmentDocumentHandle | null>(null);\n  const interaction = useSegmentInteraction();\n  const { clearPreview } = interaction;\n  const railViewportRef = React.useRef<HTMLElement | null>(null);\n  const pageElementByNumberRef = React.useRef(new Map<number, HTMLElement>());\n  const railFollowStateRef = React.useRef<RailFollowState>({\n    isPointerInsideRail: false,\n    isUserScrollingRail: false,\n    lastProgrammaticScrollAt: 0,\n    idleTimer: null,\n  });\n\n  const model = React.useMemo<SegmentViewportModel>(() => {\n    const interactionState = getSegmentInteractionState({\n      segments,\n      currentPage,\n      interaction,\n    });\n\n    return {\n      ...interactionState,\n      scrollProgress,\n    };\n  }, [currentPage, interaction, scrollProgress, segments]);\n\n  const onCurrentPageChange = React.useCallback((page: number) => {\n    setCurrentPage(normalizePage(page));\n  }, []);\n\n  const onScrollProgressChange = React.useCallback((progress: number) => {\n    setScrollProgress(clamp01(progress));\n  }, []);\n\n  const setDocumentHandle = React.useCallback(\n    (handle: SegmentDocumentHandle | null) => {\n      documentHandleRef.current = handle;\n    },\n    [],\n  );\n\n  const scrollToPage = React.useCallback(\n    (page: number, options?: SegmentNavigationOptions) => {\n      const normalizedPage = normalizePage(page);\n      if (normalizedPage == null) return;\n\n      if (options?.clearPreview !== false) interaction.clearPreview();\n      const scrollOptions = segmentScrollOptions(options);\n      if (scrollOptions) {\n        documentHandleRef.current?.scrollToPage(normalizedPage, scrollOptions);\n      } else {\n        documentHandleRef.current?.scrollToPage(normalizedPage);\n      }\n    },\n    [interaction],\n  );\n\n  const scrollToSegmentStart = React.useCallback(\n    (segment: DocumentSegment, options?: SegmentNavigationOptions) => {\n      const page = firstSegmentPage(segment.pages);\n      if (page == null) return;\n\n      scrollToPage(page, options);\n    },\n    [scrollToPage],\n  );\n\n  const scrollToAnchor = React.useCallback(\n    (anchor: SegmentAnchor, options?: SegmentNavigationOptions) => {\n      const normalizedPage = normalizePage(anchor.pageNumber);\n      if (normalizedPage == null) return;\n\n      if (options?.clearPreview !== false) interaction.clearPreview();\n      const scrollOptions = segmentScrollOptions(options);\n      const handle = documentHandleRef.current;\n      if (!handle) return;\n\n      if (handle.scrollToAnchor) {\n        if (scrollOptions) {\n          handle.scrollToAnchor(anchor, scrollOptions);\n        } else {\n          handle.scrollToAnchor(anchor);\n        }\n        return;\n      }\n\n      if (anchor.bounds && handle.scrollToPageArea) {\n        const target = {\n          pageNumber: normalizedPage,\n          left: toPageAreaPercent(anchor.bounds.x),\n          top: toPageAreaPercent(anchor.bounds.y),\n          width: toPageAreaPercent(anchor.bounds.width),\n          height: toPageAreaPercent(anchor.bounds.height),\n        };\n        if (scrollOptions) {\n          handle.scrollToPageArea(target, scrollOptions);\n        } else {\n          handle.scrollToPageArea(target);\n        }\n        return;\n      }\n\n      if (scrollOptions) {\n        handle.scrollToPage(normalizedPage, scrollOptions);\n      } else {\n        handle.scrollToPage(normalizedPage);\n      }\n    },\n    [interaction],\n  );\n\n  const followCurrentPage = React.useCallback((page: number | null) => {\n    const normalizedPage = normalizePage(page);\n    if (normalizedPage == null) return;\n\n    const state = railFollowStateRef.current;\n    if (state.isPointerInsideRail || state.isUserScrollingRail) return;\n\n    const viewport = railViewportRef.current;\n    const target = pageElementByNumberRef.current.get(normalizedPage);\n    if (!viewport || !target) return;\n\n    const viewportRect = viewport.getBoundingClientRect();\n    const targetRect = target.getBoundingClientRect();\n    const minTop = viewportRect.top + RAIL_VISIBILITY_MARGIN;\n    const maxBottom = viewportRect.bottom - RAIL_VISIBILITY_MARGIN;\n\n    if (targetRect.top >= minTop && targetRect.bottom <= maxBottom) return;\n\n    const targetTop =\n      target.offsetTop - viewport.clientHeight / 2 + target.offsetHeight / 2;\n\n    state.lastProgrammaticScrollAt = performance.now();\n    viewport.scrollTo?.({\n      top: Math.max(0, targetTop),\n      behavior: \"smooth\",\n    });\n  }, []);\n\n  useKeyedLayoutEffect(joinEffectKey([currentPage, followCurrentPage]), () => {\n    followCurrentPage(currentPage);\n  });\n\n  useMountEffect(() => {\n    const state = railFollowStateRef.current;\n    return () => {\n      const timer = state.idleTimer;\n      if (timer != null) window.clearTimeout(timer);\n    };\n  });\n\n  useKeyedMountEffect(joinEffectKey([clearPreview, segments]), () => {\n    setCurrentPage(1);\n    setScrollProgress(0);\n    clearPreview();\n  });\n\n  const setPageElement = React.useCallback(\n    (page: number, element: HTMLElement | null) => {\n      const normalizedPage = normalizePage(page);\n      if (normalizedPage == null) return;\n\n      if (element) {\n        pageElementByNumberRef.current.set(normalizedPage, element);\n      } else {\n        pageElementByNumberRef.current.delete(normalizedPage);\n      }\n    },\n    [],\n  );\n\n  const handleRailScroll = React.useCallback(() => {\n    const state = railFollowStateRef.current;\n    const elapsed = performance.now() - state.lastProgrammaticScrollAt;\n    if (elapsed < PROGRAMMATIC_SCROLL_WINDOW_MS) return;\n\n    state.isUserScrollingRail = true;\n    if (state.idleTimer != null) window.clearTimeout(state.idleTimer);\n    state.idleTimer = window.setTimeout(() => {\n      state.isUserScrollingRail = false;\n      state.idleTimer = null;\n    }, USER_SCROLL_IDLE_MS);\n  }, []);\n\n  const rail = React.useMemo(\n    () => ({\n      setViewportElement: (element: HTMLElement | null) => {\n        railViewportRef.current = element;\n      },\n      setPageElement,\n      onPointerEnter: () => {\n        railFollowStateRef.current.isPointerInsideRail = true;\n      },\n      onPointerLeave: () => {\n        railFollowStateRef.current.isPointerInsideRail = false;\n      },\n      onScroll: handleRailScroll,\n    }),\n    [handleRailScroll, setPageElement],\n  );\n\n  const documentHandlers = React.useMemo(\n    () => ({\n      onCurrentPageChange,\n      onScrollProgressChange,\n      setDocumentHandle,\n    }),\n    [onCurrentPageChange, onScrollProgressChange, setDocumentHandle],\n  );\n\n  const navigation = React.useMemo(\n    () => ({\n      scrollToPage,\n      scrollToAnchor,\n      scrollToSegmentStart,\n    }),\n    [scrollToAnchor, scrollToPage, scrollToSegmentStart],\n  );\n\n  return React.useMemo(\n    () => ({\n      model,\n      interaction,\n      documentHandlers,\n      navigation,\n      rail,\n    }),\n    [documentHandlers, interaction, model, navigation, rail],\n  );\n}\n\nfunction normalizePage(page: number | null | undefined): number | null {\n  return page != null && Number.isInteger(page) && page > 0 ? page : null;\n}\n\nfunction clamp01(value: number): number {\n  if (!Number.isFinite(value)) return 0;\n  return Math.min(1, Math.max(0, value));\n}\n\nfunction toPageAreaPercent(value: number): number {\n  if (!Number.isFinite(value)) return 0;\n  return value >= 0 && value <= 1 ? value * 100 : value;\n}\n\nfunction segmentScrollOptions(\n  options: SegmentNavigationOptions | undefined,\n): ScrollToOptions | undefined {\n  if (!options) return undefined;\n  const { clearPreview: _clearPreview, ...scrollOptions } = options;\n  return Object.keys(scrollOptions).length > 0 ? scrollOptions : undefined;\n}\n\nexport type SegmentedDocumentViewportModel = SegmentViewportModel;\nexport type SegmentedDocumentHandle = SegmentDocumentHandle;\nexport type SegmentedDocumentViewport = SegmentViewportController;\nexport type SegmentedDocumentHandlers =\n  SegmentViewportController[\"documentHandlers\"];\nexport type SegmentedDocumentNavigation =\n  SegmentViewportController[\"navigation\"];\n",
      "type": "registry:ui",
      "target": "@ui/use-segment-viewport-controller.ts"
    }
  ],
  "type": "registry:ui"
}