{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-source",
  "title": "Image source adapter",
  "description": "Bridges the document-source model to the image viewer: imageAnchorToTarget, rotateImageArea, and renderImageSourceOverlay for image_bbox anchors.",
  "registryDependencies": [
    "@retab/image-viewer",
    "@retab/document-source"
  ],
  "files": [
    {
      "path": "registry/new-york-v4/lib/image-geometry.ts",
      "content": "export type QuarterTurn = 0 | 90 | 180 | 270;\n\nexport interface Size {\n  width: number;\n  height: number;\n}\n\nexport interface NormalizedBox {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n}\n\nexport interface FrameOverlayProps {\n  frameNumber: number;\n  frameRect: Size;\n  scale: number;\n  rotation: QuarterTurn;\n}\n\nexport function normalizeRotation(rotation: number): QuarterTurn {\n  const normalized = ((rotation % 360) + 360) % 360;\n  if (normalized === 90 || normalized === 180 || normalized === 270) {\n    return normalized;\n  }\n  return 0;\n}\n\nexport function isRotatedSideways(rotation: QuarterTurn): boolean {\n  return rotation === 90 || rotation === 270;\n}\n\nexport function rotatedSize(size: Size, rotation: QuarterTurn): Size {\n  return isRotatedSideways(rotation)\n    ? { width: size.height, height: size.width }\n    : size;\n}\n\nexport function frameCssSize(\n  intrinsicSize: Size,\n  scale: number,\n  rotation: QuarterTurn,\n): Size {\n  const size = rotatedSize(intrinsicSize, rotation);\n  return {\n    width: size.width * scale,\n    height: size.height * scale,\n  };\n}\n\nexport function rotateNormalizedBox(\n  box: NormalizedBox,\n  rotation: QuarterTurn,\n): NormalizedBox {\n  if (rotation === 90) {\n    return {\n      left: 1 - box.top - box.height,\n      top: box.left,\n      width: box.height,\n      height: box.width,\n    };\n  }\n  if (rotation === 180) {\n    return {\n      left: 1 - box.left - box.width,\n      top: 1 - box.top - box.height,\n      width: box.width,\n      height: box.height,\n    };\n  }\n  if (rotation === 270) {\n    return {\n      left: box.top,\n      top: 1 - box.left - box.width,\n      width: box.height,\n      height: box.width,\n    };\n  }\n  return box;\n}\n\nexport function frameNumberToIndex(frameNumber: number): number {\n  if (!Number.isFinite(frameNumber)) return 0;\n  return Math.max(0, Math.floor(frameNumber) - 1);\n}\n\nexport function frameIndexToNumber(frameIndex: number): number {\n  return frameIndex + 1;\n}\n",
      "type": "registry:lib",
      "target": "@lib/image-geometry.ts"
    },
    {
      "path": "registry/new-york-v4/ui/image-source.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { Source, SourceAnchor, SourceArea } from \"@/lib/document-source\";\nimport { normalizeRotation, rotateNormalizedBox } from \"@/lib/image-geometry\";\nimport {\n  type ImageFrameOverlayProps,\n  type ImageViewerHandle,\n} from \"@/components/ui/image-viewer-types\";\n\nconst HIGHLIGHT_CLASS =\n  \"pointer-events-none absolute z-10 rounded-[2px] border border-primary/70 bg-primary/12 shadow-[0_4px_16px_rgb(0_0_0_/_8%)]\";\n\nexport interface SourceTarget {\n  scrollTo?: (source: Source, options: { behavior: ScrollBehavior }) => void;\n}\n\nexport interface ImageSourceTarget {\n  frame: number;\n  area: SourceArea;\n}\n\n/** A normalized image/pdf bbox anchor → the frame and percentage box to target. */\nexport function imageAnchorToTarget(\n  anchor: SourceAnchor,\n): ImageSourceTarget | undefined {\n  if (anchor.kind === \"image_bbox\" || anchor.kind === \"pdf_bbox\") {\n    const frame = imageAnchorFrame(anchor);\n    if (!isValidNormalizedBox(anchor)) return undefined;\n    if (frame === undefined) return undefined;\n    return {\n      frame,\n      area: {\n        left: anchor.left * 100,\n        top: anchor.top * 100,\n        width: anchor.width * 100,\n        height: anchor.height * 100,\n      },\n    };\n  }\n  return undefined;\n}\n\n/**\n * The 1-based frame a bbox anchor lives on, or undefined if it isn't a raster\n * bbox. `image_bbox` defaults to frame 1 (single image); a multi-frame TIFF (or\n * a rasterized slide deck) sets `page` explicitly.\n */\nfunction imageAnchorFrame(anchor: SourceAnchor): number | undefined {\n  if (anchor.kind === \"image_bbox\") {\n    const frame = anchor.page ?? 1;\n    return isPositiveInteger(frame) ? frame : undefined;\n  }\n  if (anchor.kind === \"pdf_bbox\") {\n    return isPositiveInteger(anchor.page) ? anchor.page : undefined;\n  }\n  return undefined;\n}\n\nfunction isPositiveInteger(value: number): boolean {\n  return Number.isInteger(value) && value >= 1;\n}\n\nfunction isValidNormalizedBox({\n  left,\n  top,\n  width,\n  height,\n}: {\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n}): boolean {\n  return (\n    Number.isFinite(left) &&\n    Number.isFinite(top) &&\n    Number.isFinite(width) &&\n    Number.isFinite(height) &&\n    left >= 0 &&\n    top >= 0 &&\n    width > 0 &&\n    height > 0 &&\n    left + width <= 1 &&\n    top + height <= 1\n  );\n}\n\nexport function rotateImageArea(\n  area: SourceArea,\n  rotation: number,\n): SourceArea {\n  const rotated = rotateNormalizedBox(\n    {\n      left: area.left / 100,\n      top: area.top / 100,\n      width: area.width / 100,\n      height: area.height / 100,\n    },\n    normalizeRotation(rotation),\n  );\n  return {\n    left: toPercent(rotated.left),\n    top: toPercent(rotated.top),\n    width: toPercent(rotated.width),\n    height: toPercent(rotated.height),\n  };\n}\n\nfunction toPercent(value: number): number {\n  return Math.round(value * 100 * 1e10) / 1e10;\n}\n\n/** A stable source target over an `ImageViewer` ref. */\nexport function useImageSourceTarget(\n  viewerRef: React.RefObject<ImageViewerHandle | null>,\n): SourceTarget {\n  return React.useMemo<SourceTarget>(\n    () => ({\n      scrollTo: (source: Source, options) => {\n        const target = imageAnchorToTarget(source.anchor);\n        if (target) {\n          viewerRef.current?.scrollToFrameArea(\n            target.frame,\n            target.area,\n            options,\n          );\n        }\n      },\n    }),\n    [viewerRef],\n  );\n}\n\n/**\n * Build a `renderFrameOverlay` callback that draws a source highlight on the\n * image.\n */\nexport function renderImageSourceOverlay(\n  source: Source | undefined,\n): (props: ImageFrameOverlayProps) => React.ReactNode {\n  const target = source ? imageAnchorToTarget(source.anchor) : undefined;\n  return function ImageSourceOverlay({\n    frameNumber,\n    rotation,\n  }: ImageFrameOverlayProps) {\n    if (!target || frameNumber !== target.frame) return null;\n    const renderedArea = rotateImageArea(target.area, rotation);\n    return (\n      <div\n        className={HIGHLIGHT_CLASS}\n        style={{\n          left: `${renderedArea.left}%`,\n          top: `${renderedArea.top}%`,\n          width: `${renderedArea.width}%`,\n          height: `${renderedArea.height}%`,\n        }}\n      />\n    );\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/image-source.tsx"
    }
  ],
  "type": "registry:ui"
}