retab
blocksources-viewer-blocksource: ok · hit
Sources Viewer
Every source-backed format in one viewer — PDF/image bbox sources use segmented-document anchors; text, CSV, Excel, and Word use segmented source links with format-specific target adapters.
npx shadcn add "https://ui.retab.com/r/sources-viewer-block.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightformsreact-hook-form
ui-primitives@radix-ui/react-checkbox@^1.1.3@radix-ui/react-label@^2.1.1@radix-ui/react-slot@^1.1.1
Registry dependencies
@retab/file-viewer@retab/pdf-viewer@retab/image-viewer@retab/image-source@retab/text-viewer@retab/text-source@retab/csv-viewer@retab/csv-source@retab/xlsx-viewer@retab/xlsx-source@retab/docx-viewer@retab/docx-source@retab/document-source@retab/scroll-area@retab/segmented-document@retab/source-evidence@retab/source-segmented-document@retab/source-field-link@retab/source-indicator@retab/utils@retab/data-cell@retab/inputtooltip
Files42 · source cached 10/15/2026, 9:53:35 AM
registry/new-york-v4/blocks/sources-viewer-block.tsx
"use client";
import * as React from "react";
import type { JSONSchema7 } from "json-schema";
import { useForm } from "react-hook-form";
import {
extractionSourcesToSourceMap,
type Source,
type SourceMap,
} from "@/lib/document-source";
import { cn } from "@/lib/utils";
import {
sourceToCsvCell,
useCsvSourceTarget,
} from "@/components/ui/csv-source";
import {
CsvViewerGrid,
CsvViewerProvider,
type CsvViewerHandle,
} from "@/components/ui/csv-viewer";
import {
sourceToDocxHighlight,
useDocxSourceTarget,
} from "@/components/ui/docx-source";
import {
DocxViewerDocument,
DocxViewerProvider,
type DocxViewerHandle,
} from "@/components/ui/docx-viewer";
import {
FileViewerContent,
FileViewerHeader,
FileViewerTitle,
FileViewer,
FileViewerProvider,
FileViewerSidebar,
FileViewerSidebarContent,
FileViewerSidebarSection,
FileViewerSidebarSectionContent,
FileViewerSidebarSectionHeader,
FileViewerSidebarSectionTitle,
FileViewerSidebarTrigger,
FileViewerInset,
FileViewerControls,
FileViewerViewport,
type ViewerSource,
useFileViewerResource,
} from "@/components/ui/file-viewer";
import {
ImageViewerFrames,
ImageViewerProvider,
type ImageViewerHandle,
} from "@/components/ui/image-viewer";
import { PdfViewerPages, PdfViewerProvider } from "@/components/ui/pdf-viewer";
import {
SegmentedDocumentProvider,
useSegmentedDocumentViewport,
} from "@/components/ui/segmented-document-provider";
import { sourceMapToEvidenceModel } from "@/components/ui/source-evidence";
import {
useSegmentedSourceFieldLink,
type SegmentedSourceFieldLink,
type SourceFieldLink,
} from "@/components/ui/source-field-link";
import { SourceIndicator } from "@/components/ui/source-indicator";
import { createSourcesSegmentedDocumentModel } from "@/components/ui/source-segmented-document-model";
import {
useSegmentedImageSourceOverlay,
useSegmentedImageViewerHandle,
useSegmentedPdfSourceOverlay,
useSegmentedPdfViewerHandle,
} from "@/components/ui/source-segmented-document-overlays";
import {
sourceToTextHighlight,
useTextSourceTarget,
} from "@/components/ui/text-source";
import {
TextViewerDocument,
TextViewerProvider,
type TextViewerHandle,
} from "@/components/ui/text-viewer";
import {
sourceToXlsxCell,
useXlsxSourceTarget,
} from "@/components/ui/xlsx-source";
import {
XlsxViewerProvider,
XlsxViewerWorkbook,
type XlsxViewerHandle,
} from "@/components/ui/xlsx-viewer";
import { JsonForm } from "@/components/json-form/json-form";
import csvSample from "@/components/viewers/sample-data/csv-sources.json";
import docxSample from "@/components/viewers/sample-data/docx-sources.json";
import imageSample from "@/components/viewers/sample-data/image-sources.json";
import jsonFormSample from "@/components/viewers/sample-data/json-form-sources.json";
import textSample from "@/components/viewers/sample-data/text-sources.json";
import xlsxSample from "@/components/viewers/sample-data/xlsx-sources.json";
// ── Sample sources, one per file format ───────────────────────────────────────
const PDF_URL = "/samples/jane-doe-bank-statement-5-pages.pdf";
const PDF_SOURCE = {
kind: "url" as const,
url: PDF_URL,
fileName: "jane-doe-bank-statement-5-pages.pdf",
};
const IMAGE_URL = "/samples/an-image-is-worth-16x16-words-page-1.png";
const XLSX_URL = "/samples/nvidia-financials-fy2024.xlsx";
const DOCX_URL = "/samples/quarterly-business-review.docx";
const TEXT_TEXT = [
"[retab] extraction run",
"run_id=run_3xK9b2QvT7",
"tenant=acme-corp environment=prod",
"received file bank-statement.pdf (80.1 KB)",
"sha256=4f1a...c0de",
"mime=application/pdf",
"detected document_type=bank_statement",
"confidence=0.984",
"page_count=4",
"preprocessing started",
"rasterizing pages at 192 dpi",
"ocr blocks=324",
"table candidates=7",
"classification completed",
"extraction started",
"model=retab-large n_consensus=3",
"schema=bank_statement_v2 fields=18",
"run 1 completed",
"run 2 completed",
"run 3 completed",
"merged 3 runs, mean_likelihood=0.91",
"field transactions.7.amount low agreement (0.58)",
"source grounding started",
"source grounding completed",
"review hints generated",
"16/18 fields grounded",
"low confidence fields queued",
"human review required=false",
"assigned to queue=finance-ops",
"prompt_tokens=14820 completion_tokens=2110",
"total_tokens=16930",
"run status=completed",
"duration_ms=11842",
].join("\n");
const CSV_TEXT = `region,quarter,revenue,customers,nrr
North America,Q1,1240000,48,1.12
North America,Q2,1510000,61,1.21
EMEA,Q1,820000,33,1.08
EMEA,Q2,910000,39,1.15
APAC,Q1,430000,18,1.04
APAC,Q2,560000,24,1.11`;
const IMAGE_SOURCE = {
kind: "url" as const,
url: IMAGE_URL,
fileName: "an-image-is-worth-16x16-words-page-1.png",
};
const TEXT_SOURCE = {
kind: "text" as const,
text: TEXT_TEXT,
fileName: "extraction-run.log",
};
const CSV_SOURCE = {
kind: "text" as const,
text: CSV_TEXT,
fileName: "sales.csv",
};
const XLSX_SOURCE = {
kind: "url" as const,
url: XLSX_URL,
fileName: "nvidia-financials-fy2024.xlsx",
};
const DOCX_SOURCE = {
kind: "url" as const,
url: DOCX_URL,
fileName: "quarterly-business-review.docx",
};
// A flat extraction sample: each field is a scalar with the source it came from.
type FlatField = { key: string; label: string; value: string; source: Source };
type SourceExtraction = {
schema: JSONSchema7;
sources: SourceMap;
values: Record<string, unknown>;
};
type SourceLinkedViewerSource = ViewerSource;
// Build a JSON form's inputs from a flat field array. The schema property names
// match the source-map keys used for json-form hover and click interactions.
function flatSourceExtraction(fields: FlatField[]): SourceExtraction {
const sources = Object.fromEntries(
fields.map((field) => [field.key, field.source]),
);
return {
schema: {
type: "object",
properties: Object.fromEntries(
fields.map((field) => [
field.key,
{ type: "string", title: field.label },
]),
),
},
values: Object.fromEntries(fields.map((field) => [field.key, field.value])),
sources,
};
}
// The PDF tab uses the richer, nested extraction sample (a real /sources
// response); the rest derive a flat form from their per-format field arrays.
const PDF_SOURCE_MAP = extractionSourcesToSourceMap(jsonFormSample.sources);
const PDF_EVIDENCE = sourceMapToEvidenceModel({
sourceMap: PDF_SOURCE_MAP,
schema: jsonFormSample.schema as JSONSchema7,
values: jsonFormSample.extraction as Record<string, unknown>,
});
const PDF_SEGMENTED_DOCUMENT = createSourcesSegmentedDocumentModel({
labels: Object.fromEntries(
PDF_EVIDENCE.evidenceItems.map((item) => [item.id, item.payload.label]),
),
sourceMap: PDF_SOURCE_MAP,
});
const PDF_INITIAL_SOURCE_PATH =
PDF_EVIDENCE.evidenceItems.find((item) => item.anchor.status === "resolved")
?.id ?? null;
const PDF_EXTRACTION: SourceExtraction = {
schema: jsonFormSample.schema as JSONSchema7,
sources: PDF_SOURCE_MAP,
values: jsonFormSample.extraction as Record<string, unknown>,
};
const IMAGE_FIELDS = imageSample as FlatField[];
const IMAGE_EXTRACTION = flatSourceExtraction(IMAGE_FIELDS);
const IMAGE_SEGMENTED_DOCUMENT = createSourcesSegmentedDocumentModel(
fieldsToSegmentedFields(IMAGE_FIELDS),
);
const TEXT_FIELDS = textSample as FlatField[];
const TEXT_EXTRACTION = flatSourceExtraction(TEXT_FIELDS);
const TEXT_SEGMENTED_DOCUMENT = createSourcesSegmentedDocumentModel(
fieldsToSegmentedFields(TEXT_FIELDS),
);
const CSV_FIELDS = csvSample as FlatField[];
const CSV_EXTRACTION = flatSourceExtraction(CSV_FIELDS);
const CSV_SEGMENTED_DOCUMENT = createSourcesSegmentedDocumentModel(
fieldsToSegmentedFields(CSV_FIELDS),
);
const XLSX_FIELDS = xlsxSample as FlatField[];
const XLSX_EXTRACTION = flatSourceExtraction(XLSX_FIELDS);
const XLSX_SEGMENTED_DOCUMENT = createSourcesSegmentedDocumentModel(
fieldsToSegmentedFields(XLSX_FIELDS),
);
const DOCX_FIELDS = docxSample as FlatField[];
const DOCX_EXTRACTION = flatSourceExtraction(DOCX_FIELDS);
const DOCX_SEGMENTED_DOCUMENT = createSourcesSegmentedDocumentModel(
fieldsToSegmentedFields(DOCX_FIELDS),
);
function fieldsToSegmentedFields(fields: readonly FlatField[]) {
return fields.map((field) => ({
id: field.key,
label: field.label,
source: field.source,
}));
}
// ── Shared layout: viewer + json-form source panel ────────────────────────────
function SourceLinkedFileHeader() {
return (
<FileViewerHeader>
<FileViewerSidebarTrigger className="-ms-1" />
<FileViewerTitle />
<FileViewerControls />
</FileViewerHeader>
);
}
function SourceLinkedViewer({
children,
extraction,
link,
source,
}: {
children: React.ReactNode;
extraction: SourceExtraction;
link: SegmentedSourceFieldLink;
source: SourceLinkedViewerSource;
}) {
return (
<FileViewerProvider source={source} headerMode="outlets" defaultSidebarOpen>
<FileViewer className="bg-background">
<SourceLinkedFileHeader />
<FileViewerContent>
<FileViewerInset>
<FileViewerViewport>{children}</FileViewerViewport>
</FileViewerInset>
<FileViewerSidebar
aria-label="Source-linked fields"
side="right"
width="420px"
className="bg-background flex flex-shrink-0 flex-col border-l"
>
<SourcesFormHeader link={link} />
<FileViewerSidebarContent>
<SourcesForm extraction={extraction} link={link} />
</FileViewerSidebarContent>
</FileViewerSidebar>
</FileViewerContent>
</FileViewer>
</FileViewerProvider>
);
}
function SourcesFormHeader({ link }: { link: SourceFieldLink }) {
return (
<FileViewerSidebarSectionHeader className="bg-background sticky top-0 z-20 flex h-10 flex-shrink-0 items-center gap-2 border-b px-4">
<FileViewerSidebarSectionTitle className="sr-only">
Source fields
</FileViewerSidebarSectionTitle>
<SourceIndicator
path={link.activeSourcePath}
className="min-h-0 flex-1 px-0 py-0"
/>
</FileViewerSidebarSectionHeader>
);
}
function SourcesForm({
extraction,
link,
}: {
extraction: SourceExtraction;
link: SourceFieldLink;
}) {
const form = useForm<Record<string, unknown>>({
defaultValues: extraction.values,
});
// `json-form` is source-aware: pass the link and every field becomes a
// hoverable card that reports its path. No per-field wiring needed.
return (
<FileViewerSidebarSection className="p-4">
<FileViewerSidebarSectionTitle className="sr-only">
Source fields
</FileViewerSidebarSectionTitle>
<FileViewerSidebarSectionContent>
<JsonForm form={form} schema={extraction.schema} sourceLink={link} />
</FileViewerSidebarSectionContent>
</FileViewerSidebarSection>
);
}
type SourceTarget = {
scrollTo?: (source: Source, options: { behavior: ScrollBehavior }) => void;
};
function useSourceTargetedSegmentedFieldLink({
initialSourcePath,
sources,
target,
}: {
initialSourcePath?: string | null;
sources: SourceMap;
target: SourceTarget;
}): SegmentedSourceFieldLink {
const link = useSegmentedSourceFieldLink({ initialSourcePath });
const scrollToPath = React.useCallback(
(path: string, behavior: ScrollBehavior) => {
const source = sources[path];
if (source) target.scrollTo?.(source, { behavior });
},
[sources, target],
);
const onSourceHover = React.useCallback(
(path: string | null) => {
link.onSourceHover(path);
if (path) scrollToPath(path, "auto");
},
[link, scrollToPath],
);
const selectSourcePath = React.useCallback(
(path: string) => {
link.selectSourcePath?.(path);
scrollToPath(path, "smooth");
},
[link, scrollToPath],
);
return React.useMemo(
() => ({
...link,
onSourceHover,
selectSourcePath,
}),
[link, onSourceHover, selectSourcePath],
);
}
function sourceForPath(
sources: SourceMap,
path: string | null,
): Source | undefined {
return path ? sources[path] : undefined;
}
// ── Per-format tabs ───────────────────────────────────────────────────────────
function PdfTab() {
return (
<SegmentedDocumentProvider model={PDF_SEGMENTED_DOCUMENT}>
<PdfTabContent />
</SegmentedDocumentProvider>
);
}
function PdfTabContent() {
const link = useSegmentedSourceFieldLink({
initialSourcePath: PDF_INITIAL_SOURCE_PATH,
});
const { documentHandlers } = useSegmentedDocumentViewport();
const renderPageOverlay = useSegmentedPdfSourceOverlay(link);
const setPdfViewerHandle = useSegmentedPdfViewerHandle();
return (
<SourceLinkedViewer
extraction={PDF_EXTRACTION}
link={link}
source={PDF_SOURCE}
>
<PdfViewerProvider>
<PdfViewerPages
ref={setPdfViewerHandle}
bare
className="h-full"
onScrollProgressChange={documentHandlers.onScrollProgressChange}
onVisiblePageChange={documentHandlers.onCurrentPageChange}
renderPageOverlay={renderPageOverlay}
/>
</PdfViewerProvider>
</SourceLinkedViewer>
);
}
function ImageTab() {
return (
<SegmentedDocumentProvider model={IMAGE_SEGMENTED_DOCUMENT}>
<ImageTabContent />
</SegmentedDocumentProvider>
);
}
function ImageTabContent() {
const link = useSegmentedSourceFieldLink({
initialSourcePath: IMAGE_FIELDS[0]?.key,
});
const { documentHandlers } = useSegmentedDocumentViewport();
const renderFrameOverlay = useSegmentedImageSourceOverlay(link);
const setImageViewerHandle = useSegmentedImageViewerHandle();
return (
<SourceLinkedViewer
extraction={IMAGE_EXTRACTION}
link={link}
source={IMAGE_SOURCE}
>
<FileResourceImageViewer
ref={setImageViewerHandle}
bare
className="h-full"
controls={false}
fallbackFrameSize={{ width: 1224, height: 1584 }}
onScrollProgressChange={documentHandlers.onScrollProgressChange}
onVisibleFrameChange={documentHandlers.onCurrentPageChange}
renderFrameOverlay={renderFrameOverlay}
/>
</SourceLinkedViewer>
);
}
function TextTab() {
return (
<SegmentedDocumentProvider model={TEXT_SEGMENTED_DOCUMENT}>
<TextTabContent />
</SegmentedDocumentProvider>
);
}
function TextTabContent() {
const viewerRef = React.useRef<TextViewerHandle>(null);
const target = useTextSourceTarget(viewerRef);
const link = useSourceTargetedSegmentedFieldLink({
initialSourcePath: TEXT_FIELDS[0]?.key,
sources: TEXT_EXTRACTION.sources,
target,
});
const highlight = sourceToTextHighlight(
sourceForPath(TEXT_EXTRACTION.sources, link.activeSourcePath),
);
return (
<SourceLinkedViewer
extraction={TEXT_EXTRACTION}
link={link}
source={TEXT_SOURCE}
>
<FileResourceTextViewer
ref={viewerRef}
bare
className="h-full"
controls={false}
highlight={highlight}
mode="text"
/>
</SourceLinkedViewer>
);
}
function CsvTab() {
return (
<SegmentedDocumentProvider model={CSV_SEGMENTED_DOCUMENT}>
<CsvTabContent />
</SegmentedDocumentProvider>
);
}
function CsvTabContent() {
const viewerRef = React.useRef<CsvViewerHandle>(null);
const target = useCsvSourceTarget(viewerRef);
const link = useSourceTargetedSegmentedFieldLink({
initialSourcePath: CSV_FIELDS[0]?.key,
sources: CSV_EXTRACTION.sources,
target,
});
const activeCell = sourceToCsvCell(
sourceForPath(CSV_EXTRACTION.sources, link.activeSourcePath),
);
return (
<SourceLinkedViewer
extraction={CSV_EXTRACTION}
link={link}
source={CSV_SOURCE}
>
<FileResourceCsvViewer
ref={viewerRef}
fillHeight
className="h-full"
controls={false}
activeCell={activeCell}
/>
</SourceLinkedViewer>
);
}
function ExcelTab() {
return (
<SegmentedDocumentProvider model={XLSX_SEGMENTED_DOCUMENT}>
<ExcelTabContent />
</SegmentedDocumentProvider>
);
}
function ExcelTabContent() {
const viewerRef = React.useRef<XlsxViewerHandle>(null);
const target = useXlsxSourceTarget(viewerRef);
const link = useSourceTargetedSegmentedFieldLink({
initialSourcePath: XLSX_FIELDS[0]?.key,
sources: XLSX_EXTRACTION.sources,
target,
});
const activeCell = sourceToXlsxCell(
sourceForPath(XLSX_EXTRACTION.sources, link.activeSourcePath),
);
return (
<SourceLinkedViewer
extraction={XLSX_EXTRACTION}
link={link}
source={XLSX_SOURCE}
>
<FileResourceXlsxViewer
ref={viewerRef}
bare
className="h-full"
controls={false}
activeCell={activeCell}
/>
</SourceLinkedViewer>
);
}
function DocxTab() {
return (
<SegmentedDocumentProvider model={DOCX_SEGMENTED_DOCUMENT}>
<DocxTabContent />
</SegmentedDocumentProvider>
);
}
function DocxTabContent() {
const viewerRef = React.useRef<DocxViewerHandle>(null);
const target = useDocxSourceTarget(viewerRef);
const link = useSourceTargetedSegmentedFieldLink({
initialSourcePath: DOCX_FIELDS[0]?.key,
sources: DOCX_EXTRACTION.sources,
target,
});
const highlight = sourceToDocxHighlight(
sourceForPath(DOCX_EXTRACTION.sources, link.activeSourcePath),
);
return (
<SourceLinkedViewer
extraction={DOCX_EXTRACTION}
link={link}
source={DOCX_SOURCE}
>
<FileResourceDocxViewer
ref={viewerRef}
bare
className="h-full"
controls={false}
highlight={highlight}
/>
</SourceLinkedViewer>
);
}
const FileResourceImageViewer = React.forwardRef<
ImageViewerHandle,
React.ComponentProps<typeof ImageViewerFrames>
>(function FileResourceImageViewer(props, ref) {
const resource = useFileViewerResource();
return (
<ImageViewerProvider resource={resource}>
<ImageViewerFrames {...props} ref={ref} />
</ImageViewerProvider>
);
});
const FileResourceTextViewer = React.forwardRef<
TextViewerHandle,
React.ComponentProps<typeof TextViewerDocument>
>(function FileResourceTextViewer(props, ref) {
const resource = useFileViewerResource();
return (
<TextViewerProvider resource={resource}>
<TextViewerDocument {...props} ref={ref} />
</TextViewerProvider>
);
});
const FileResourceCsvViewer = React.forwardRef<
CsvViewerHandle,
React.ComponentProps<typeof CsvViewerGrid>
>(function FileResourceCsvViewer(props, ref) {
const resource = useFileViewerResource();
return (
<CsvViewerProvider resource={resource}>
<CsvViewerGrid {...props} ref={ref} />
</CsvViewerProvider>
);
});
const FileResourceXlsxViewer = React.forwardRef<
XlsxViewerHandle,
React.ComponentProps<typeof XlsxViewerWorkbook>
>(function FileResourceXlsxViewer(props, ref) {
const resource = useFileViewerResource();
return (
<XlsxViewerProvider resource={resource}>
<XlsxViewerWorkbook {...props} ref={ref} />
</XlsxViewerProvider>
);
});
const FileResourceDocxViewer = React.forwardRef<
DocxViewerHandle,
React.ComponentProps<typeof DocxViewerDocument>
>(function FileResourceDocxViewer(props, ref) {
const resource = useFileViewerResource();
return (
<DocxViewerProvider resource={resource}>
<DocxViewerDocument {...props} ref={ref} />
</DocxViewerProvider>
);
});
const TABS = [
{ id: "pdf", label: "PDF", Tab: PdfTab },
{ id: "image", label: "Image", Tab: ImageTab },
{ id: "text", label: "Text", Tab: TextTab },
{ id: "csv", label: "CSV", Tab: CsvTab },
{ id: "excel", label: "Excel", Tab: ExcelTab },
{ id: "docx", label: "DOCX", Tab: DocxTab },
] as const;
type TabId = (typeof TABS)[number]["id"];
/**
* Sources viewer — every source-backed format in one component. A tab bar switches
* the file format (PDF, image, text, CSV, Excel, Word); each tab is the same
* source shell: the source document beside a JSON form of its extracted values,
* linked by their sources. Segmented source interaction drives every tab; only
* the viewer and its source target adapter differ per format.
*
* Tabs mount lazily on first visit and stay mounted (hidden) afterwards, so each
* format's viewer keeps its scroll position and avoids re-fetching its document.
*/
export function SourcesViewerBlock() {
const [active, setActive] = React.useState<TabId>("pdf");
const [mounted, setMounted] = React.useState<Set<TabId>>(
() => new Set(["pdf"]),
);
function selectTab(id: TabId) {
setActive(id);
setMounted((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
next.add(id);
return next;
});
}
return (
<div className="bg-background flex h-full min-h-[680px] flex-col">
<div
role="tablist"
aria-label="Source format"
className="flex h-11 flex-shrink-0 items-center gap-1 border-b px-2"
>
{TABS.map((tab) => {
const isActive = tab.id === active;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => selectTab(tab.id)}
className={cn(
"focus-visible:ring-ring relative h-full px-3 text-sm font-medium transition-colors outline-none focus-visible:ring-2",
isActive
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
{isActive ? (
<span className="bg-foreground absolute inset-x-2 -bottom-px h-0.5 rounded-full" />
) : null}
</button>
);
})}
</div>
<div className="relative min-h-0 flex-1">
{TABS.map((tab) =>
mounted.has(tab.id) ? (
<div
key={tab.id}
role="tabpanel"
hidden={tab.id !== active}
className={cn(
"absolute inset-0",
tab.id === active ? "block" : "hidden",
)}
>
<tab.Tab />
</div>
) : null,
)}
</div>
</div>
);
}
components/viewers/sample-data/json-form-sources.json
{
"object": "extraction.sources",
"document_type": "pdf",
"file": {
"id": "file_jane_doe_bank_statement_jul_2003",
"filename": "jane-doe-bank-statement-5-pages.pdf",
"mime_type": "application/pdf"
},
"schema": {
"type": "object",
"title": "Bank statement",
"properties": {
"account_number": {
"type": "string",
"description": "Account number"
},
"statement_date": {
"type": "string",
"description": "Statement date",
"format": "date"
},
"transactions": {
"type": "array",
"title": "Transactions",
"items": {
"type": "object",
"properties": {
"date": {
"type": "string",
"format": "date"
},
"description": {
"type": "string"
},
"amount": {
"type": "number"
}
}
}
}
},
"required": [
"account_number",
"statement_date"
]
},
"extraction": {
"account_number": "000009752",
"statement_date": "2003-07-08",
"transactions": [
{
"date": "2003-06-06",
"description": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"amount": -85.34
},
{
"date": "2003-06-06",
"description": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"amount": -81.23
},
{
"date": "2003-06-06",
"description": "DEPOSIT - PAYROLL DIRECT DEP",
"amount": 1875.24
},
{
"date": "2003-06-06",
"description": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"amount": -69.01
},
{
"date": "2003-06-07",
"description": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"amount": -30.79
},
{
"date": "2003-06-07",
"description": "BILL PAYMENT - CITY UTILITIES",
"amount": -108.3
},
{
"date": "2003-06-07",
"description": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"amount": -41.2
},
{
"date": "2003-06-07",
"description": "ONLINE TRANSFER TO SAVINGS",
"amount": -213.49
},
{
"date": "2003-06-08",
"description": "CHECKCARD PURCHASE - GARDEN CENTER",
"amount": -35.52
},
{
"date": "2003-06-08",
"description": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"amount": -7.95
},
{
"date": "2003-06-08",
"description": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"amount": -19.24
},
{
"date": "2003-06-08",
"description": "CHECKCARD PURCHASE - HARDWARE STORE",
"amount": -41.72
},
{
"date": "2003-06-09",
"description": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"amount": -174.12
},
{
"date": "2003-06-09",
"description": "CHECKCARD PURCHASE - DRY CLEANERS",
"amount": -27.77
},
{
"date": "2003-06-09",
"description": "BILL PAYMENT - MOBILE SERVICE",
"amount": -48.75
},
{
"date": "2003-06-09",
"description": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"amount": -58.01
},
{
"date": "2003-06-10",
"description": "CHECK 1007",
"amount": -123.1
},
{
"date": "2003-06-10",
"description": "CHECKCARD PURCHASE - MOVIE THEATER",
"amount": -26.65
},
{
"date": "2003-06-10",
"description": "CHECKCARD PURCHASE - AUTO PARTS",
"amount": -74.08
},
{
"date": "2003-06-10",
"description": "AUTOMATIC PAYMENT - CABLE SERVICE",
"amount": -87.17
},
{
"date": "2003-06-11",
"description": "MONTHLY SERVICE CHARGE",
"amount": -5
},
{
"date": "2003-06-11",
"description": "CHECKCARD PURCHASE - DINER",
"amount": -38.07
},
{
"date": "2003-06-11",
"description": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"amount": -73.25
},
{
"date": "2003-06-11",
"description": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"amount": -77.9
},
{
"date": "2003-06-12",
"description": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"amount": -31.14
},
{
"date": "2003-06-12",
"description": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"amount": -58.72
},
{
"date": "2003-06-12",
"description": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"amount": -41.2
},
{
"date": "2003-06-12",
"description": "BILL PAYMENT - CITY UTILITIES",
"amount": -129.74
},
{
"date": "2003-06-13",
"description": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"amount": -34.84
},
{
"date": "2003-06-13",
"description": "DEPOSIT - MOBILE DEPOSIT",
"amount": 420
},
{
"date": "2003-06-13",
"description": "CHECKCARD PURCHASE - GARDEN CENTER",
"amount": -71.14
},
{
"date": "2003-06-13",
"description": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"amount": -8.67
},
{
"date": "2003-06-14",
"description": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"amount": -41.37
},
{
"date": "2003-06-14",
"description": "CHECKCARD PURCHASE - HARDWARE STORE",
"amount": -41.86
},
{
"date": "2003-06-14",
"description": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"amount": -132.11
},
{
"date": "2003-06-14",
"description": "CHECKCARD PURCHASE - DRY CLEANERS",
"amount": -31.5
},
{
"date": "2003-06-15",
"description": "BILL PAYMENT - MOBILE SERVICE",
"amount": -72.18
},
{
"date": "2003-06-15",
"description": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"amount": -52.41
},
{
"date": "2003-06-15",
"description": "CHECKCARD PURCHASE - POSTAL CENTER",
"amount": -15.84
},
{
"date": "2003-06-15",
"description": "CHECKCARD PURCHASE - MOVIE THEATER",
"amount": -31.59
},
{
"date": "2003-06-16",
"description": "CHECKCARD PURCHASE - AUTO PARTS",
"amount": -47.27
},
{
"date": "2003-06-16",
"description": "AUTOMATIC PAYMENT - CABLE SERVICE",
"amount": -93.98
},
{
"date": "2003-06-16",
"description": "MONTHLY SERVICE CHARGE",
"amount": -5
},
{
"date": "2003-06-16",
"description": "CHECK 1008",
"amount": -89
},
{
"date": "2003-06-17",
"description": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"amount": -71.51
},
{
"date": "2003-06-17",
"description": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"amount": -73.43
},
{
"date": "2003-06-17",
"description": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"amount": -41.87
},
{
"date": "2003-06-17",
"description": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"amount": -77.2
},
{
"date": "2003-06-18",
"description": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"amount": -46.3
},
{
"date": "2003-06-18",
"description": "BILL PAYMENT - CITY UTILITIES",
"amount": -120.45
},
{
"date": "2003-06-18",
"description": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"amount": -35
},
{
"date": "2003-06-18",
"description": "ONLINE TRANSFER TO SAVINGS",
"amount": -106.83
},
{
"date": "2003-06-19",
"description": "CHECKCARD PURCHASE - GARDEN CENTER",
"amount": -52.55
},
{
"date": "2003-06-19",
"description": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"amount": -15.42
},
{
"date": "2003-06-19",
"description": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"amount": -31.46
},
{
"date": "2003-06-19",
"description": "CHECKCARD PURCHASE - HARDWARE STORE",
"amount": -47.3
},
{
"date": "2003-06-20",
"description": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"amount": -155.05
},
{
"date": "2003-06-20",
"description": "CHECKCARD PURCHASE - DRY CLEANERS",
"amount": -35.57
},
{
"date": "2003-06-20",
"description": "ACH CREDIT - CUSTOMER REFUND",
"amount": 54.19
},
{
"date": "2003-06-20",
"description": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"amount": -55.86
},
{
"date": "2003-06-21",
"description": "CHECKCARD PURCHASE - POSTAL CENTER",
"amount": -23.97
},
{
"date": "2003-06-21",
"description": "CHECKCARD PURCHASE - MOVIE THEATER",
"amount": -35.78
},
{
"date": "2003-06-21",
"description": "CHECKCARD PURCHASE - AUTO PARTS",
"amount": -64.3
},
{
"date": "2003-06-21",
"description": "AUTOMATIC PAYMENT - CABLE SERVICE",
"amount": -73.81
},
{
"date": "2003-06-22",
"description": "MONTHLY SERVICE CHARGE",
"amount": -5
},
{
"date": "2003-06-22",
"description": "CHECKCARD PURCHASE - DINER",
"amount": -44.13
},
{
"date": "2003-06-22",
"description": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"amount": -48.6
},
{
"date": "2003-06-22",
"description": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"amount": -77.82
},
{
"date": "2003-06-23",
"description": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"amount": -58.48
},
{
"date": "2003-06-23",
"description": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"amount": -28.91
},
{
"date": "2003-06-23",
"description": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"amount": -27.12
},
{
"date": "2003-06-23",
"description": "BILL PAYMENT - CITY UTILITIES",
"amount": -125.07
},
{
"date": "2003-06-24",
"description": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"amount": -42.1
},
{
"date": "2003-06-24",
"description": "ONLINE TRANSFER TO SAVINGS",
"amount": -222.37
},
{
"date": "2003-06-24",
"description": "CHECKCARD PURCHASE - GARDEN CENTER",
"amount": -31.59
},
{
"date": "2003-06-24",
"description": "CHECK 1009",
"amount": -69.65
},
{
"date": "2003-06-25",
"description": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"amount": -54.55
},
{
"date": "2003-06-25",
"description": "CHECKCARD PURCHASE - HARDWARE STORE",
"amount": -48.03
},
{
"date": "2003-06-25",
"description": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"amount": -147.09
},
{
"date": "2003-06-25",
"description": "CHECKCARD PURCHASE - DRY CLEANERS",
"amount": -25.3
},
{
"date": "2003-06-26",
"description": "BILL PAYMENT - MOBILE SERVICE",
"amount": -62.79
},
{
"date": "2003-06-26",
"description": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"amount": -66.49
},
{
"date": "2003-06-26",
"description": "CHECKCARD PURCHASE - POSTAL CENTER",
"amount": -26.59
},
{
"date": "2003-06-26",
"description": "CHECKCARD PURCHASE - MOVIE THEATER",
"amount": -28.02
},
{
"date": "2003-06-27",
"description": "CHECKCARD PURCHASE - AUTO PARTS",
"amount": -30.23
},
{
"date": "2003-06-27",
"description": "AUTOMATIC PAYMENT - CABLE SERVICE",
"amount": -70.26
},
{
"date": "2003-06-27",
"description": "MONTHLY SERVICE CHARGE",
"amount": -5
},
{
"date": "2003-06-27",
"description": "CHECKCARD PURCHASE - DINER",
"amount": -22.66
},
{
"date": "2003-06-28",
"description": "ACH CREDIT - EXPENSE REIMBURSEMENT",
"amount": 96.5
},
{
"date": "2003-06-28",
"description": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"amount": -88.25
},
{
"date": "2003-06-28",
"description": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"amount": -38.13
},
{
"date": "2003-06-28",
"description": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"amount": -49.3
},
{
"date": "2003-06-29",
"description": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"amount": -32.73
},
{
"date": "2003-06-29",
"description": "BILL PAYMENT - CITY UTILITIES",
"amount": -87.98
},
{
"date": "2003-06-29",
"description": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"amount": -27.12
},
{
"date": "2003-06-29",
"description": "ONLINE TRANSFER TO SAVINGS",
"amount": -173.65
},
{
"date": "2003-06-30",
"description": "CHECKCARD PURCHASE - GARDEN CENTER",
"amount": -51.97
},
{
"date": "2003-06-30",
"description": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"amount": -17.91
},
{
"date": "2003-06-30",
"description": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"amount": -74.91
},
{
"date": "2003-06-30",
"description": "CHECKCARD PURCHASE - HARDWARE STORE",
"amount": -33.34
},
{
"date": "2003-07-01",
"description": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"amount": -167.63
},
{
"date": "2003-07-01",
"description": "CHECKCARD PURCHASE - DRY CLEANERS",
"amount": -18.94
},
{
"date": "2003-07-01",
"description": "BILL PAYMENT - MOBILE SERVICE",
"amount": -59.37
},
{
"date": "2003-07-01",
"description": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"amount": -43.76
},
{
"date": "2003-07-02",
"description": "CHECK 1010",
"amount": -115.17
},
{
"date": "2003-07-02",
"description": "CHECKCARD PURCHASE - MOVIE THEATER",
"amount": -20.21
},
{
"date": "2003-07-02",
"description": "CHECKCARD PURCHASE - AUTO PARTS",
"amount": -75.17
},
{
"date": "2003-07-02",
"description": "AUTOMATIC PAYMENT - CABLE SERVICE",
"amount": -93.9
},
{
"date": "2003-07-03",
"description": "MONTHLY SERVICE CHARGE",
"amount": -5
},
{
"date": "2003-07-03",
"description": "CHECKCARD PURCHASE - DINER",
"amount": -21.27
},
{
"date": "2003-07-03",
"description": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"amount": -52.88
},
{
"date": "2003-07-03",
"description": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"amount": -88.93
},
{
"date": "2003-07-04",
"description": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"amount": -45.92
},
{
"date": "2003-07-04",
"description": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"amount": -42.9
},
{
"date": "2003-07-04",
"description": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"amount": -35.54
},
{
"date": "2003-07-04",
"description": "BILL PAYMENT - CITY UTILITIES",
"amount": -81.24
},
{
"date": "2003-07-05",
"description": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"amount": -39.69
},
{
"date": "2003-07-05",
"description": "ONLINE TRANSFER TO SAVINGS",
"amount": -219.21
},
{
"date": "2003-07-05",
"description": "ATM DEPOSIT - SMALL TOWN BRANCH",
"amount": 225
},
{
"date": "2003-07-05",
"description": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"amount": -6.58
},
{
"date": "2003-07-06",
"description": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"amount": -36.07
},
{
"date": "2003-07-06",
"description": "CHECKCARD PURCHASE - HARDWARE STORE",
"amount": -51.25
},
{
"date": "2003-07-06",
"description": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"amount": -127.13
},
{
"date": "2003-07-06",
"description": "CHECKCARD PURCHASE - DRY CLEANERS",
"amount": -25.2
},
{
"date": "2003-07-07",
"description": "BILL PAYMENT - MOBILE SERVICE",
"amount": -76.42
},
{
"date": "2003-07-07",
"description": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"amount": -33.72
},
{
"date": "2003-07-07",
"description": "CHECKCARD PURCHASE - POSTAL CENTER",
"amount": -24.21
},
{
"date": "2003-07-07",
"description": "CHECKCARD PURCHASE - MOVIE THEATER",
"amount": -45.25
},
{
"date": "2003-07-08",
"description": "CHECKCARD PURCHASE - AUTO PARTS",
"amount": -70.6
},
{
"date": "2003-07-08",
"description": "AUTOMATIC PAYMENT - CABLE SERVICE",
"amount": -76.67
}
]
},
"sources": {
"account_number": {
"value": "000009752",
"source": {
"content": "000009752",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.8111,
"top": 0.1772,
"width": 0.0736,
"height": 0.0127
}
}
},
"statement_date": {
"value": "2003-07-08",
"source": {
"content": "July 8, 2003",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.8021,
"top": 0.1933,
"width": 0.0826,
"height": 0.0127
}
}
},
"transactions": [
{
"date": {
"value": "2003-06-06",
"source": {
"content": "06-06",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.4195,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"source": {
"content": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.4195,
"width": 0.3067,
"height": 0.023
}
}
},
"amount": {
"value": -85.34,
"source": {
"content": "$85.34",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.4195,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-06",
"source": {
"content": "06-06",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.4489,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"source": {
"content": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.4489,
"width": 0.3009,
"height": 0.0116
}
}
},
"amount": {
"value": -81.23,
"source": {
"content": "$81.23",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.4489,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-06",
"source": {
"content": "06-06",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.4669,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "DEPOSIT - PAYROLL DIRECT DEP",
"source": {
"content": "DEPOSIT - PAYROLL DIRECT DEP",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.4669,
"width": 0.2157,
"height": 0.0116
}
}
},
"amount": {
"value": 1875.24,
"source": {
"content": "$1,875.24",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.8024,
"top": 0.4669,
"width": 0.06,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-06",
"source": {
"content": "06-06",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.4839,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"source": {
"content": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.4839,
"width": 0.2809,
"height": 0.0116
}
}
},
"amount": {
"value": -69.01,
"source": {
"content": "$69.01",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.4839,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-07",
"source": {
"content": "06-07",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.5019,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"source": {
"content": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.5019,
"width": 0.2666,
"height": 0.023
}
}
},
"amount": {
"value": -30.79,
"source": {
"content": "$30.79",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.5019,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-07",
"source": {
"content": "06-07",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.5313,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "BILL PAYMENT - CITY UTILITIES",
"source": {
"content": "BILL PAYMENT - CITY UTILITIES",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.5313,
"width": 0.203,
"height": 0.0116
}
}
},
"amount": {
"value": -108.3,
"source": {
"content": "$108.30",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.7075,
"top": 0.5313,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-07",
"source": {
"content": "06-07",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.5493,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"source": {
"content": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.5493,
"width": 0.2996,
"height": 0.0116
}
}
},
"amount": {
"value": -41.2,
"source": {
"content": "$41.20",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.5493,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-07",
"source": {
"content": "06-07",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.5673,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "ONLINE TRANSFER TO SAVINGS",
"source": {
"content": "ONLINE TRANSFER TO SAVINGS",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.5673,
"width": 0.211,
"height": 0.0116
}
}
},
"amount": {
"value": -213.49,
"source": {
"content": "$213.49",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.7075,
"top": 0.5673,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-08",
"source": {
"content": "06-08",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.5853,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - GARDEN CENTER",
"source": {
"content": "CHECKCARD PURCHASE - GARDEN CENTER",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.5853,
"width": 0.2929,
"height": 0.0116
}
}
},
"amount": {
"value": -35.52,
"source": {
"content": "$35.52",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.5853,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-08",
"source": {
"content": "06-08",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.6033,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"source": {
"content": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.6033,
"width": 0.3086,
"height": 0.0116
}
}
},
"amount": {
"value": -7.95,
"source": {
"content": "$7.95",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.7225,
"top": 0.6033,
"width": 0.0337,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-08",
"source": {
"content": "06-08",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.6203,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"source": {
"content": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.6203,
"width": 0.3149,
"height": 0.0116
}
}
},
"amount": {
"value": -19.24,
"source": {
"content": "$19.24",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.6203,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-08",
"source": {
"content": "06-08",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.6383,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - HARDWARE STORE",
"source": {
"content": "CHECKCARD PURCHASE - HARDWARE STORE",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.6383,
"width": 0.3041,
"height": 0.0116
}
}
},
"amount": {
"value": -41.72,
"source": {
"content": "$41.72",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.6383,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-09",
"source": {
"content": "06-09",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.6563,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"source": {
"content": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.6563,
"width": 0.3036,
"height": 0.0116
}
}
},
"amount": {
"value": -174.12,
"source": {
"content": "$174.12",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.7075,
"top": 0.6563,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-09",
"source": {
"content": "06-09",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.6743,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - DRY CLEANERS",
"source": {
"content": "CHECKCARD PURCHASE - DRY CLEANERS",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.6743,
"width": 0.2804,
"height": 0.0116
}
}
},
"amount": {
"value": -27.77,
"source": {
"content": "$27.77",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.6743,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-09",
"source": {
"content": "06-09",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.6923,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "BILL PAYMENT - MOBILE SERVICE",
"source": {
"content": "BILL PAYMENT - MOBILE SERVICE",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.6923,
"width": 0.2195,
"height": 0.0116
}
}
},
"amount": {
"value": -48.75,
"source": {
"content": "$48.75",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.6923,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-09",
"source": {
"content": "06-09",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.7103,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"source": {
"content": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.7103,
"width": 0.3168,
"height": 0.0116
}
}
},
"amount": {
"value": -58.01,
"source": {
"content": "$58.01",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.7103,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-10",
"source": {
"content": "06-10",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.7283,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECK 1007",
"source": {
"content": "CHECK 1007",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.7283,
"width": 0.0809,
"height": 0.0116
}
}
},
"amount": {
"value": -123.1,
"source": {
"content": "$123.10",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.7075,
"top": 0.7283,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-10",
"source": {
"content": "06-10",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1307,
"top": 0.7463,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - MOVIE THEATER",
"source": {
"content": "CHECKCARD PURCHASE - MOVIE THEATER",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.1992,
"top": 0.7463,
"width": 0.2849,
"height": 0.0116
}
}
},
"amount": {
"value": -26.65,
"source": {
"content": "$26.65",
"anchor": {
"kind": "pdf_bbox",
"page": 1,
"left": 0.715,
"top": 0.7463,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-10",
"source": {
"content": "06-10",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.3267,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - AUTO PARTS",
"source": {
"content": "CHECKCARD PURCHASE - AUTO PARTS",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.3267,
"width": 0.2599,
"height": 0.0116
}
}
},
"amount": {
"value": -74.08,
"source": {
"content": "$74.08",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.3267,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-10",
"source": {
"content": "06-10",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.3447,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - CABLE SERVICE",
"source": {
"content": "AUTOMATIC PAYMENT - CABLE SERVICE",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.3447,
"width": 0.2637,
"height": 0.0116
}
}
},
"amount": {
"value": -87.17,
"source": {
"content": "$87.17",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.3447,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-11",
"source": {
"content": "06-11",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.3627,
"width": 0.0335,
"height": 0.0116
}
}
},
"description": {
"value": "MONTHLY SERVICE CHARGE",
"source": {
"content": "MONTHLY SERVICE CHARGE",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.3627,
"width": 0.1888,
"height": 0.0116
}
}
},
"amount": {
"value": -5,
"source": {
"content": "$5.00",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.7225,
"top": 0.3627,
"width": 0.0337,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-11",
"source": {
"content": "06-11",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.3807,
"width": 0.0335,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - DINER",
"source": {
"content": "CHECKCARD PURCHASE - DINER",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.3807,
"width": 0.218,
"height": 0.0116
}
}
},
"amount": {
"value": -38.07,
"source": {
"content": "$38.07",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.3807,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-11",
"source": {
"content": "06-11",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.3987,
"width": 0.0335,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"source": {
"content": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.3987,
"width": 0.3067,
"height": 0.023
}
}
},
"amount": {
"value": -73.25,
"source": {
"content": "$73.25",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.3987,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-11",
"source": {
"content": "06-11",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.4271,
"width": 0.0335,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"source": {
"content": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.4271,
"width": 0.3009,
"height": 0.0116
}
}
},
"amount": {
"value": -77.9,
"source": {
"content": "$77.90",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.4271,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-12",
"source": {
"content": "06-12",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.4451,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"source": {
"content": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.4451,
"width": 0.2864,
"height": 0.023
}
}
},
"amount": {
"value": -31.14,
"source": {
"content": "$31.14",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.4451,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-12",
"source": {
"content": "06-12",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.4745,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"source": {
"content": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.4745,
"width": 0.2809,
"height": 0.0116
}
}
},
"amount": {
"value": -58.72,
"source": {
"content": "$58.72",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.4745,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-12",
"source": {
"content": "06-12",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.4925,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"source": {
"content": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.4925,
"width": 0.2666,
"height": 0.023
}
}
},
"amount": {
"value": -41.2,
"source": {
"content": "$41.20",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.4925,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-12",
"source": {
"content": "06-12",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.5218,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "BILL PAYMENT - CITY UTILITIES",
"source": {
"content": "BILL PAYMENT - CITY UTILITIES",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.5218,
"width": 0.203,
"height": 0.0116
}
}
},
"amount": {
"value": -129.74,
"source": {
"content": "$129.74",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.7075,
"top": 0.5218,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-13",
"source": {
"content": "06-13",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.5398,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"source": {
"content": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.5398,
"width": 0.2996,
"height": 0.0116
}
}
},
"amount": {
"value": -34.84,
"source": {
"content": "$34.84",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.5398,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-13",
"source": {
"content": "06-13",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.5569,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "DEPOSIT - MOBILE DEPOSIT",
"source": {
"content": "DEPOSIT - MOBILE DEPOSIT",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.5569,
"width": 0.1848,
"height": 0.0116
}
}
},
"amount": {
"value": 420,
"source": {
"content": "$420.00",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.8137,
"top": 0.5569,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-13",
"source": {
"content": "06-13",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.5749,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - GARDEN CENTER",
"source": {
"content": "CHECKCARD PURCHASE - GARDEN CENTER",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.5749,
"width": 0.2929,
"height": 0.0116
}
}
},
"amount": {
"value": -71.14,
"source": {
"content": "$71.14",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.5749,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-13",
"source": {
"content": "06-13",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.5928,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"source": {
"content": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.5928,
"width": 0.3086,
"height": 0.0116
}
}
},
"amount": {
"value": -8.67,
"source": {
"content": "$8.67",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.7225,
"top": 0.5928,
"width": 0.0337,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-14",
"source": {
"content": "06-14",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.6108,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"source": {
"content": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.6108,
"width": 0.3149,
"height": 0.0116
}
}
},
"amount": {
"value": -41.37,
"source": {
"content": "$41.37",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.6108,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-14",
"source": {
"content": "06-14",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.6288,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - HARDWARE STORE",
"source": {
"content": "CHECKCARD PURCHASE - HARDWARE STORE",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.6288,
"width": 0.3041,
"height": 0.0116
}
}
},
"amount": {
"value": -41.86,
"source": {
"content": "$41.86",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.6288,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-14",
"source": {
"content": "06-14",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.6468,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"source": {
"content": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.6468,
"width": 0.3036,
"height": 0.0116
}
}
},
"amount": {
"value": -132.11,
"source": {
"content": "$132.11",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.7085,
"top": 0.6468,
"width": 0.0477,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-14",
"source": {
"content": "06-14",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.6648,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - DRY CLEANERS",
"source": {
"content": "CHECKCARD PURCHASE - DRY CLEANERS",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.6648,
"width": 0.2804,
"height": 0.0116
}
}
},
"amount": {
"value": -31.5,
"source": {
"content": "$31.50",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.6648,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-15",
"source": {
"content": "06-15",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.6828,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "BILL PAYMENT - MOBILE SERVICE",
"source": {
"content": "BILL PAYMENT - MOBILE SERVICE",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.6828,
"width": 0.2195,
"height": 0.0116
}
}
},
"amount": {
"value": -72.18,
"source": {
"content": "$72.18",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.6828,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-15",
"source": {
"content": "06-15",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.6999,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"source": {
"content": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.6999,
"width": 0.3168,
"height": 0.0116
}
}
},
"amount": {
"value": -52.41,
"source": {
"content": "$52.41",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.6999,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-15",
"source": {
"content": "06-15",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.7178,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - POSTAL CENTER",
"source": {
"content": "CHECKCARD PURCHASE - POSTAL CENTER",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.7178,
"width": 0.2869,
"height": 0.0116
}
}
},
"amount": {
"value": -15.84,
"source": {
"content": "$15.84",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.7178,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-15",
"source": {
"content": "06-15",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.7358,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - MOVIE THEATER",
"source": {
"content": "CHECKCARD PURCHASE - MOVIE THEATER",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.7358,
"width": 0.2849,
"height": 0.0116
}
}
},
"amount": {
"value": -31.59,
"source": {
"content": "$31.59",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.7358,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-16",
"source": {
"content": "06-16",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.7538,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - AUTO PARTS",
"source": {
"content": "CHECKCARD PURCHASE - AUTO PARTS",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.7538,
"width": 0.2599,
"height": 0.0116
}
}
},
"amount": {
"value": -47.27,
"source": {
"content": "$47.27",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.7538,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-16",
"source": {
"content": "06-16",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.7718,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - CABLE SERVICE",
"source": {
"content": "AUTOMATIC PAYMENT - CABLE SERVICE",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.7718,
"width": 0.2637,
"height": 0.0116
}
}
},
"amount": {
"value": -93.98,
"source": {
"content": "$93.98",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.7718,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-16",
"source": {
"content": "06-16",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.7898,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "MONTHLY SERVICE CHARGE",
"source": {
"content": "MONTHLY SERVICE CHARGE",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.7898,
"width": 0.1888,
"height": 0.0116
}
}
},
"amount": {
"value": -5,
"source": {
"content": "$5.00",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.7225,
"top": 0.7898,
"width": 0.0337,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-16",
"source": {
"content": "06-16",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.8078,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECK 1008",
"source": {
"content": "CHECK 1008",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.8078,
"width": 0.0809,
"height": 0.0116
}
}
},
"amount": {
"value": -89,
"source": {
"content": "$89.00",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.8078,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-17",
"source": {
"content": "06-17",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.8258,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"source": {
"content": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.8258,
"width": 0.3067,
"height": 0.0221
}
}
},
"amount": {
"value": -71.51,
"source": {
"content": "$71.51",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.8258,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-17",
"source": {
"content": "06-17",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1307,
"top": 0.8542,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"source": {
"content": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.1992,
"top": 0.8542,
"width": 0.3009,
"height": 0.0116
}
}
},
"amount": {
"value": -73.43,
"source": {
"content": "$73.43",
"anchor": {
"kind": "pdf_bbox",
"page": 2,
"left": 0.715,
"top": 0.8542,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-17",
"source": {
"content": "06-17",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.3267,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"source": {
"content": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.3267,
"width": 0.2864,
"height": 0.023
}
}
},
"amount": {
"value": -41.87,
"source": {
"content": "$41.87",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.3267,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-17",
"source": {
"content": "06-17",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.3561,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"source": {
"content": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.3561,
"width": 0.2809,
"height": 0.0116
}
}
},
"amount": {
"value": -77.2,
"source": {
"content": "$77.20",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.3561,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-18",
"source": {
"content": "06-18",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.3741,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"source": {
"content": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.3741,
"width": 0.2666,
"height": 0.023
}
}
},
"amount": {
"value": -46.3,
"source": {
"content": "$46.30",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.3741,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-18",
"source": {
"content": "06-18",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.4034,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "BILL PAYMENT - CITY UTILITIES",
"source": {
"content": "BILL PAYMENT - CITY UTILITIES",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.4034,
"width": 0.203,
"height": 0.0116
}
}
},
"amount": {
"value": -120.45,
"source": {
"content": "$120.45",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.7075,
"top": 0.4034,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-18",
"source": {
"content": "06-18",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.4205,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"source": {
"content": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.4205,
"width": 0.2996,
"height": 0.0116
}
}
},
"amount": {
"value": -35,
"source": {
"content": "$35.00",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.4205,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-18",
"source": {
"content": "06-18",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.4385,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "ONLINE TRANSFER TO SAVINGS",
"source": {
"content": "ONLINE TRANSFER TO SAVINGS",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.4385,
"width": 0.211,
"height": 0.0116
}
}
},
"amount": {
"value": -106.83,
"source": {
"content": "$106.83",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.7075,
"top": 0.4385,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-19",
"source": {
"content": "06-19",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.4565,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - GARDEN CENTER",
"source": {
"content": "CHECKCARD PURCHASE - GARDEN CENTER",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.4565,
"width": 0.2929,
"height": 0.0116
}
}
},
"amount": {
"value": -52.55,
"source": {
"content": "$52.55",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.4565,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-19",
"source": {
"content": "06-19",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.4745,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"source": {
"content": "CHECKCARD PURCHASE - COFFEE HOUSE 013",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.4745,
"width": 0.3086,
"height": 0.0116
}
}
},
"amount": {
"value": -15.42,
"source": {
"content": "$15.42",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.4745,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-19",
"source": {
"content": "06-19",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.4925,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"source": {
"content": "CHECKCARD PURCHASE - OFFICE SUPPLY 3301",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.4925,
"width": 0.3149,
"height": 0.0116
}
}
},
"amount": {
"value": -31.46,
"source": {
"content": "$31.46",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.4925,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-19",
"source": {
"content": "06-19",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.5105,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - HARDWARE STORE",
"source": {
"content": "CHECKCARD PURCHASE - HARDWARE STORE",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.5105,
"width": 0.3041,
"height": 0.0116
}
}
},
"amount": {
"value": -47.3,
"source": {
"content": "$47.30",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.5105,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-20",
"source": {
"content": "06-20",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.5284,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"source": {
"content": "AUTOMATIC PAYMENT - INSURANCE PREMIUM",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.5284,
"width": 0.3036,
"height": 0.0116
}
}
},
"amount": {
"value": -155.05,
"source": {
"content": "$155.05",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.7075,
"top": 0.5284,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-20",
"source": {
"content": "06-20",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.5464,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - DRY CLEANERS",
"source": {
"content": "CHECKCARD PURCHASE - DRY CLEANERS",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.5464,
"width": 0.2804,
"height": 0.0116
}
}
},
"amount": {
"value": -35.57,
"source": {
"content": "$35.57",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.5464,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-20",
"source": {
"content": "06-20",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.5635,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "ACH CREDIT - CUSTOMER REFUND",
"source": {
"content": "ACH CREDIT - CUSTOMER REFUND",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.5635,
"width": 0.2309,
"height": 0.0116
}
}
},
"amount": {
"value": 54.19,
"source": {
"content": "$54.19",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.8212,
"top": 0.5635,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-20",
"source": {
"content": "06-20",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.5815,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"source": {
"content": "CHECKCARD PURCHASE - FAMILY RESTAURANT",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.5815,
"width": 0.3168,
"height": 0.0116
}
}
},
"amount": {
"value": -55.86,
"source": {
"content": "$55.86",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.5815,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-21",
"source": {
"content": "06-21",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.5995,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - POSTAL CENTER",
"source": {
"content": "CHECKCARD PURCHASE - POSTAL CENTER",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.5995,
"width": 0.2869,
"height": 0.0116
}
}
},
"amount": {
"value": -23.97,
"source": {
"content": "$23.97",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.5995,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-21",
"source": {
"content": "06-21",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.6175,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - MOVIE THEATER",
"source": {
"content": "CHECKCARD PURCHASE - MOVIE THEATER",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.6175,
"width": 0.2849,
"height": 0.0116
}
}
},
"amount": {
"value": -35.78,
"source": {
"content": "$35.78",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.6175,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-21",
"source": {
"content": "06-21",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.6355,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - AUTO PARTS",
"source": {
"content": "CHECKCARD PURCHASE - AUTO PARTS",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.6355,
"width": 0.2599,
"height": 0.0116
}
}
},
"amount": {
"value": -64.3,
"source": {
"content": "$64.30",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.6355,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-21",
"source": {
"content": "06-21",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.6534,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - CABLE SERVICE",
"source": {
"content": "AUTOMATIC PAYMENT - CABLE SERVICE",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.6534,
"width": 0.2637,
"height": 0.0116
}
}
},
"amount": {
"value": -73.81,
"source": {
"content": "$73.81",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.6534,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-22",
"source": {
"content": "06-22",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.6714,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "MONTHLY SERVICE CHARGE",
"source": {
"content": "MONTHLY SERVICE CHARGE",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.6714,
"width": 0.1888,
"height": 0.0116
}
}
},
"amount": {
"value": -5,
"source": {
"content": "$5.00",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.7225,
"top": 0.6714,
"width": 0.0337,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-22",
"source": {
"content": "06-22",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.6894,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - DINER",
"source": {
"content": "CHECKCARD PURCHASE - DINER",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.6894,
"width": 0.218,
"height": 0.0116
}
}
},
"amount": {
"value": -44.13,
"source": {
"content": "$44.13",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.6894,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-22",
"source": {
"content": "06-22",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.7065,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"source": {
"content": "CHECKCARD PURCHASE - GROCER'S MARKET 0042, SMALL TOWN MO",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.7065,
"width": 0.3067,
"height": 0.023
}
}
},
"amount": {
"value": -48.6,
"source": {
"content": "$48.60",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.7065,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-22",
"source": {
"content": "06-22",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.7358,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"source": {
"content": "AUTOMATIC PAYMENT - TELEPHONE SERVICE",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.7358,
"width": 0.3009,
"height": 0.0116
}
}
},
"amount": {
"value": -77.82,
"source": {
"content": "$77.82",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.7358,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-23",
"source": {
"content": "06-23",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.7538,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"source": {
"content": "CHECKCARD PURCHASE - FUEL STOP 1187, KANSAS CITY MO",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.7538,
"width": 0.2864,
"height": 0.023
}
}
},
"amount": {
"value": -58.48,
"source": {
"content": "$58.48",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.7538,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-23",
"source": {
"content": "06-23",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.7832,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"source": {
"content": "ATM WITHDRAWAL - 1000 WALNUT ST M119",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.7832,
"width": 0.2809,
"height": 0.0116
}
}
},
"amount": {
"value": -28.91,
"source": {
"content": "$28.91",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.7832,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-23",
"source": {
"content": "06-23",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.8012,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"source": {
"content": "CHECKCARD PURCHASE - MAIN STREET PHARMACY",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.8012,
"width": 0.2666,
"height": 0.023
}
}
},
"amount": {
"value": -27.12,
"source": {
"content": "$27.12",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.715,
"top": 0.8012,
"width": 0.0412,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-23",
"source": {
"content": "06-23",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.8296,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "BILL PAYMENT - CITY UTILITIES",
"source": {
"content": "BILL PAYMENT - CITY UTILITIES",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.8296,
"width": 0.203,
"height": 0.0116
}
}
},
"amount": {
"value": -125.07,
"source": {
"content": "$125.07",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.7075,
"top": 0.8296,
"width": 0.0487,
"height": 0.0116
}
}
}
},
{
"date": {
"value": "2003-06-24",
"source": {
"content": "06-24",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1307,
"top": 0.8476,
"width": 0.0345,
"height": 0.0116
}
}
},
"description": {
"value": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"source": {
"content": "CHECKCARD PURCHASE - BOOKS AND NEWS",
"anchor": {
"kind": "pdf_bbox",
"page": 3,
"left": 0.1992,
"top": 0.8476,
"width": 0.2996,
"height": 0.0116
}
}
},
"amount": {
"value": -42.1,
"source": {
"content": "$42.10",
"anchor": {
[truncated — showing 100000 of 163436 characters]components/viewers/sample-data/image-sources.json
[
{
"key": "title",
"label": "Title",
"value": "An Image is Worth 16x16 Words",
"source": {
"content": "An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale",
"anchor": {
"kind": "image_bbox",
"left": 0.1772,
"top": 0.1034,
"width": 0.6457,
"height": 0.0446
}
}
},
{
"key": "firstAuthor",
"label": "First author",
"value": "Alexey Dosovitskiy",
"source": {
"content": "Alexey Dosovitskiy",
"anchor": {
"kind": "image_bbox",
"left": 0.2313,
"top": 0.1739,
"width": 0.1314,
"height": 0.0113
}
}
},
{
"key": "affiliation",
"label": "Affiliation",
"value": "Google Research, Brain Team",
"source": {
"content": "Google Research, Brain Team",
"anchor": {
"kind": "image_bbox",
"left": 0.4118,
"top": 0.2417,
"width": 0.196,
"height": 0.0112
}
}
},
{
"key": "firstAuthorEmail",
"label": "First author email",
"value": "adosovitskiy@google.com",
"source": {
"content": "{adosovitskiy, neilhoulsby}@google.com",
"anchor": {
"kind": "image_bbox",
"left": 0.3258,
"top": 0.2575,
"width": 0.3679,
"height": 0.0119
}
}
},
{
"key": "secondAuthor",
"label": "Second author",
"value": "Lucas Beyer",
"source": {
"content": "Lucas Beyer",
"anchor": {
"kind": "image_bbox",
"left": 0.3882,
"top": 0.1739,
"width": 0.0862,
"height": 0.0113
}
}
},
{
"key": "model",
"label": "Proposed model",
"value": "Vision Transformer (ViT)",
"source": {
"content": "Vision Transformer (ViT)",
"anchor": {
"kind": "image_bbox",
"left": 0.4868,
"top": 0.4533,
"width": 0.1682,
"height": 0.0112
}
}
},
{
"key": "benchmark",
"label": "Benchmarks",
"value": "ImageNet, CIFAR-100, VTAB",
"source": {
"content": "ImageNet, CIFAR-100, VTAB",
"anchor": {
"kind": "image_bbox",
"left": 0.2349,
"top": 0.4533,
"width": 0.2481,
"height": 0.0112
}
}
},
{
"key": "venue",
"label": "Venue",
"value": "ICLR 2021",
"source": {
"content": "Published as a conference paper at ICLR 2021",
"anchor": {
"kind": "image_bbox",
"left": 0.1765,
"top": 0.0361,
"width": 0.3024,
"height": 0.0112
}
}
}
]
components/viewers/sample-data/text-sources.json
[
{
"key": "runId",
"label": "Run ID",
"value": "run_3xK9b2QvT7",
"source": {
"content": "run_id=run_3xK9b2QvT7",
"anchor": { "kind": "text_span", "line_start": 2, "line_end": 2 }
}
},
{
"key": "tenant",
"label": "Tenant",
"value": "acme-corp",
"source": {
"content": "tenant=acme-corp environment=prod",
"anchor": { "kind": "text_span", "line_start": 3, "line_end": 3 }
}
},
{
"key": "environment",
"label": "Environment",
"value": "prod",
"source": {
"content": "tenant=acme-corp environment=prod",
"anchor": { "kind": "text_span", "line_start": 3, "line_end": 3 }
}
},
{
"key": "fileName",
"label": "File name",
"value": "bank-statement.pdf",
"source": {
"content": "received file bank-statement.pdf (80.1 KB)",
"anchor": { "kind": "text_span", "line_start": 4, "line_end": 4 }
}
},
{
"key": "fileSize",
"label": "File size",
"value": "80.1 KB",
"source": {
"content": "received file bank-statement.pdf (80.1 KB)",
"anchor": { "kind": "text_span", "line_start": 4, "line_end": 4 }
}
},
{
"key": "sha256",
"label": "SHA-256",
"value": "4f1a…c0de",
"source": {
"content": "sha256=4f1a...c0de",
"anchor": { "kind": "text_span", "line_start": 5, "line_end": 5 }
}
},
{
"key": "mimeType",
"label": "MIME type",
"value": "application/pdf",
"source": {
"content": "mime=application/pdf",
"anchor": { "kind": "text_span", "line_start": 6, "line_end": 6 }
}
},
{
"key": "documentType",
"label": "Document type",
"value": "bank_statement",
"source": {
"content": "detected document_type=bank_statement",
"anchor": { "kind": "text_span", "line_start": 7, "line_end": 7 }
}
},
{
"key": "classifierConfidence",
"label": "Classifier confidence",
"value": "0.984",
"source": {
"content": "confidence=0.984",
"anchor": { "kind": "text_span", "line_start": 8, "line_end": 8 }
}
},
{
"key": "pageCount",
"label": "Page count",
"value": "4",
"source": {
"content": "page_count=4",
"anchor": { "kind": "text_span", "line_start": 9, "line_end": 9 }
}
},
{
"key": "ocrDpi",
"label": "OCR resolution",
"value": "192 dpi",
"source": {
"content": "rasterizing pages at 192 dpi",
"anchor": { "kind": "text_span", "line_start": 11, "line_end": 11 }
}
},
{
"key": "model",
"label": "Extraction model",
"value": "retab-large",
"source": {
"content": "model=retab-large n_consensus=3",
"anchor": { "kind": "text_span", "line_start": 16, "line_end": 16 }
}
},
{
"key": "consensusRuns",
"label": "Consensus runs",
"value": "3",
"source": {
"content": "model=retab-large n_consensus=3",
"anchor": { "kind": "text_span", "line_start": 16, "line_end": 16 }
}
},
{
"key": "schema",
"label": "Schema",
"value": "bank_statement_v2",
"source": {
"content": "schema=bank_statement_v2 fields=18",
"anchor": { "kind": "text_span", "line_start": 17, "line_end": 17 }
}
},
{
"key": "schemaFields",
"label": "Schema fields",
"value": "18",
"source": {
"content": "schema=bank_statement_v2 fields=18",
"anchor": { "kind": "text_span", "line_start": 17, "line_end": 17 }
}
},
{
"key": "meanLikelihood",
"label": "Mean likelihood",
"value": "0.91",
"source": {
"content": "merged 3 runs, mean_likelihood=0.91",
"anchor": { "kind": "text_span", "line_start": 21, "line_end": 21 }
}
},
{
"key": "lowAgreementField",
"label": "Low-agreement field",
"value": "transactions.7.amount",
"source": {
"content": "field transactions.7.amount low agreement (0.58)",
"anchor": { "kind": "text_span", "line_start": 22, "line_end": 22 }
}
},
{
"key": "fieldsGrounded",
"label": "Fields grounded",
"value": "16 / 18",
"source": {
"content": "16/18 fields grounded",
"anchor": { "kind": "text_span", "line_start": 26, "line_end": 26 }
}
},
{
"key": "reviewQueue",
"label": "Review queue",
"value": "finance-ops",
"source": {
"content": "assigned to queue=finance-ops",
"anchor": { "kind": "text_span", "line_start": 29, "line_end": 29 }
}
},
{
"key": "promptTokens",
"label": "Prompt tokens",
"value": "14,820",
"source": {
"content": "prompt_tokens=14820 completion_tokens=2110",
"anchor": { "kind": "text_span", "line_start": 30, "line_end": 30 }
}
},
{
"key": "completionTokens",
"label": "Completion tokens",
"value": "2,110",
"source": {
"content": "prompt_tokens=14820 completion_tokens=2110",
"anchor": { "kind": "text_span", "line_start": 30, "line_end": 30 }
}
},
{
"key": "totalTokens",
"label": "Total tokens",
"value": "16,930",
"source": {
"content": "total_tokens=16930",
"anchor": { "kind": "text_span", "line_start": 31, "line_end": 31 }
}
},
{
"key": "status",
"label": "Run status",
"value": "completed",
"source": {
"content": "run status=completed",
"anchor": { "kind": "text_span", "line_start": 32, "line_end": 32 }
}
},
{
"key": "durationMs",
"label": "Duration",
"value": "11,842 ms",
"source": {
"content": "duration_ms=11842",
"anchor": { "kind": "text_span", "line_start": 33, "line_end": 33 }
}
}
]
components/viewers/sample-data/csv-sources.json
[
{
"key": "naQ1Revenue",
"label": "North America Q1 revenue",
"value": "$1,240,000",
"source": {
"content": "1240000",
"anchor": { "kind": "csv_cell", "row": 1, "column": "C", "coordinate": "C2" }
}
},
{
"key": "naQ1Customers",
"label": "North America Q1 customers",
"value": "48",
"source": {
"content": "48",
"anchor": { "kind": "csv_cell", "row": 1, "column": "D", "coordinate": "D2" }
}
},
{
"key": "naQ1Nrr",
"label": "North America Q1 net revenue retention",
"value": "1.12",
"source": {
"content": "1.12",
"anchor": { "kind": "csv_cell", "row": 1, "column": "E", "coordinate": "E2" }
}
},
{
"key": "naQ2Revenue",
"label": "North America Q2 revenue",
"value": "$1,510,000",
"source": {
"content": "1510000",
"anchor": { "kind": "csv_cell", "row": 2, "column": "C", "coordinate": "C3" }
}
},
{
"key": "naQ2Customers",
"label": "North America Q2 customers",
"value": "61",
"source": {
"content": "61",
"anchor": { "kind": "csv_cell", "row": 2, "column": "D", "coordinate": "D3" }
}
},
{
"key": "naQ2Nrr",
"label": "North America Q2 net revenue retention",
"value": "1.21",
"source": {
"content": "1.21",
"anchor": { "kind": "csv_cell", "row": 2, "column": "E", "coordinate": "E3" }
}
},
{
"key": "emeaQ1Revenue",
"label": "EMEA Q1 revenue",
"value": "$820,000",
"source": {
"content": "820000",
"anchor": { "kind": "csv_cell", "row": 3, "column": "C", "coordinate": "C4" }
}
},
{
"key": "emeaQ1Customers",
"label": "EMEA Q1 customers",
"value": "33",
"source": {
"content": "33",
"anchor": { "kind": "csv_cell", "row": 3, "column": "D", "coordinate": "D4" }
}
},
{
"key": "emeaQ2Revenue",
"label": "EMEA Q2 revenue",
"value": "$910,000",
"source": {
"content": "910000",
"anchor": { "kind": "csv_cell", "row": 4, "column": "C", "coordinate": "C5" }
}
},
{
"key": "emeaQ2Customers",
"label": "EMEA Q2 customers",
"value": "39",
"source": {
"content": "39",
"anchor": { "kind": "csv_cell", "row": 4, "column": "D", "coordinate": "D5" }
}
},
{
"key": "emeaQ2Nrr",
"label": "EMEA Q2 net revenue retention",
"value": "1.15",
"source": {
"content": "1.15",
"anchor": { "kind": "csv_cell", "row": 4, "column": "E", "coordinate": "E5" }
}
},
{
"key": "apacQ1Revenue",
"label": "APAC Q1 revenue",
"value": "$430,000",
"source": {
"content": "430000",
"anchor": { "kind": "csv_cell", "row": 5, "column": "C", "coordinate": "C6" }
}
},
{
"key": "apacQ1Nrr",
"label": "APAC Q1 net revenue retention",
"value": "1.04",
"source": {
"content": "1.04",
"anchor": { "kind": "csv_cell", "row": 5, "column": "E", "coordinate": "E6" }
}
},
{
"key": "apacQ2Revenue",
"label": "APAC Q2 revenue",
"value": "$560,000",
"source": {
"content": "560000",
"anchor": { "kind": "csv_cell", "row": 6, "column": "C", "coordinate": "C7" }
}
},
{
"key": "apacQ2Customers",
"label": "APAC Q2 customers",
"value": "24",
"source": {
"content": "24",
"anchor": { "kind": "csv_cell", "row": 6, "column": "D", "coordinate": "D7" }
}
},
{
"key": "apacQ2Nrr",
"label": "APAC Q2 net revenue retention",
"value": "1.11",
"source": {
"content": "1.11",
"anchor": { "kind": "csv_cell", "row": 6, "column": "E", "coordinate": "E7" }
}
}
]
components/viewers/sample-data/xlsx-sources.json
[
{
"key": "registrantName",
"label": "Registrant name",
"value": "NVIDIA CORP",
"source": {
"content": "NVIDIA CORP",
"anchor": { "kind": "spreadsheet_cell", "row": 10, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B10" }
}
},
{
"key": "documentType",
"label": "Document type",
"value": "10-K",
"source": {
"content": "10-K",
"anchor": { "kind": "spreadsheet_cell", "row": 4, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B4" }
}
},
{
"key": "periodEnd",
"label": "Period end date",
"value": "Jan. 28, 2024",
"source": {
"content": "Jan. 28, 2024",
"anchor": { "kind": "spreadsheet_cell", "row": 6, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B6" }
}
},
{
"key": "fiscalYearEnd",
"label": "Fiscal year end",
"value": "--01-28",
"source": {
"content": "--01-28",
"anchor": { "kind": "spreadsheet_cell", "row": 7, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B7" }
}
},
{
"key": "fileNumber",
"label": "Entity file number",
"value": "0-23985",
"source": {
"content": "0-23985",
"anchor": { "kind": "spreadsheet_cell", "row": 9, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B9" }
}
},
{
"key": "incorporationState",
"label": "Incorporation state",
"value": "DE",
"source": {
"content": "DE",
"anchor": { "kind": "spreadsheet_cell", "row": 11, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B11" }
}
},
{
"key": "taxId",
"label": "Tax ID",
"value": "94-3177549",
"source": {
"content": "94-3177549",
"anchor": { "kind": "spreadsheet_cell", "row": 12, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B12" }
}
},
{
"key": "address",
"label": "Address",
"value": "2788 San Tomas Expressway",
"source": {
"content": "2788 San Tomas Expressway",
"anchor": { "kind": "spreadsheet_cell", "row": 13, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B13" }
}
},
{
"key": "city",
"label": "City",
"value": "Santa Clara",
"source": {
"content": "Santa Clara",
"anchor": { "kind": "spreadsheet_cell", "row": 14, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B14" }
}
},
{
"key": "state",
"label": "State",
"value": "CA",
"source": {
"content": "CA",
"anchor": { "kind": "spreadsheet_cell", "row": 15, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B15" }
}
},
{
"key": "zip",
"label": "ZIP code",
"value": "95051",
"source": {
"content": "95051",
"anchor": { "kind": "spreadsheet_cell", "row": 16, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B16" }
}
},
{
"key": "phone",
"label": "Phone",
"value": "(408) 486-2000",
"source": {
"content": "486-2000",
"anchor": { "kind": "spreadsheet_cell", "row": 18, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B18" }
}
},
{
"key": "securityTitle",
"label": "Security title",
"value": "Common Stock, $0.001 par value",
"source": {
"content": "Common Stock, $0.001 par value per share",
"anchor": { "kind": "spreadsheet_cell", "row": 19, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B19" }
}
},
{
"key": "tradingSymbol",
"label": "Trading symbol",
"value": "NVDA",
"source": {
"content": "NVDA",
"anchor": { "kind": "spreadsheet_cell", "row": 20, "column": "B", "sheet_index": 0, "sheet_name": "Cover Page", "coordinate": "B20" }
}
},
{
"key": "auditorName",
"label": "Auditor",
"value": "PricewaterhouseCoopers LLP",
"source": {
"content": "PricewaterhouseCoopers LLP",
"anchor": { "kind": "spreadsheet_cell", "row": 5, "column": "B", "sheet_index": 1, "sheet_name": "Audit Information", "coordinate": "B5" }
}
},
{
"key": "auditorLocation",
"label": "Auditor location",
"value": "San Jose, California",
"source": {
"content": "San Jose, California",
"anchor": { "kind": "spreadsheet_cell", "row": 6, "column": "B", "sheet_index": 1, "sheet_name": "Audit Information", "coordinate": "B6" }
}
},
{
"key": "auditorFirmId",
"label": "Auditor firm ID",
"value": "238",
"source": {
"content": "238",
"anchor": { "kind": "spreadsheet_cell", "row": 4, "column": "B", "sheet_index": 1, "sheet_name": "Audit Information", "coordinate": "B4" }
}
}
]
components/viewers/sample-data/docx-sources.json
[
{
"key": "title",
"label": "Report title",
"value": "Quarterly Business Review",
"source": {
"content": "Quarterly Business Review",
"anchor": { "kind": "docx_text_span", "paragraph": 0 }
}
},
{
"key": "fiscalYear",
"label": "Fiscal year",
"value": "FY2026",
"source": {
"content": "Fiscal Year 2026 — Consolidated Report",
"anchor": { "kind": "docx_text_span", "paragraph": 1 }
}
},
{
"key": "totalPages",
"label": "Total pages",
"value": "25",
"source": {
"content": "Total pages: 25",
"anchor": { "kind": "docx_text_span", "paragraph": 3 }
}
},
{
"key": "execSummarySection",
"label": "Executive summary section",
"value": "Executive Summary",
"source": {
"content": "1. Executive Summary",
"anchor": { "kind": "docx_text_span", "paragraph": 5 }
}
},
{
"key": "marketOverviewSection",
"label": "Market overview section",
"value": "Market Overview",
"source": {
"content": "2. Market Overview",
"anchor": { "kind": "docx_text_span", "paragraph": 12 }
}
},
{
"key": "revenueSection",
"label": "Revenue section",
"value": "Revenue Analysis",
"source": {
"content": "3. Revenue Analysis",
"anchor": { "kind": "docx_text_span", "paragraph": 19 }
}
},
{
"key": "costStructureSection",
"label": "Cost structure section",
"value": "Cost Structure",
"source": {
"content": "4. Cost Structure",
"anchor": { "kind": "docx_text_span", "paragraph": 26 }
}
},
{
"key": "operatingMarginsSection",
"label": "Operating margins section",
"value": "Operating Margins",
"source": {
"content": "5. Operating Margins",
"anchor": { "kind": "docx_text_span", "paragraph": 33 }
}
},
{
"key": "segmentPerformanceSection",
"label": "Segment performance section",
"value": "Segment Performance",
"source": {
"content": "6. Segment Performance",
"anchor": { "kind": "docx_text_span", "paragraph": 40 }
}
},
{
"key": "cashFlowSection",
"label": "Cash flow section",
"value": "Cash Flow",
"source": {
"content": "11. Cash Flow",
"anchor": { "kind": "docx_text_span", "paragraph": 75 }
}
},
{
"key": "balanceSheetSection",
"label": "Balance sheet section",
"value": "Balance Sheet Review",
"source": {
"content": "12. Balance Sheet Review",
"anchor": { "kind": "docx_text_span", "paragraph": 82 }
}
},
{
"key": "riskSection",
"label": "Risk section",
"value": "Risk Factors",
"source": {
"content": "14. Risk Factors",
"anchor": { "kind": "docx_text_span", "paragraph": 96 }
}
},
{
"key": "governanceSection",
"label": "Governance section",
"value": "Governance",
"source": {
"content": "19. Governance",
"anchor": { "kind": "docx_text_span", "paragraph": 131 }
}
},
{
"key": "outlookSection",
"label": "Outlook section",
"value": "Outlook & Guidance",
"source": {
"content": "20. Outlook & Guidance",
"anchor": { "kind": "docx_text_span", "paragraph": 138 }
}
},
{
"key": "methodologyAppendix",
"label": "Methodology appendix",
"value": "Appendix A: Methodology",
"source": {
"content": "23. Appendix A: Methodology",
"anchor": { "kind": "docx_text_span", "paragraph": 159 }
}
}
]
components/json-form/array-fields.tsx
"use client";
import * as React from "react";
import { Plus, X } from "lucide-react";
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { DisclosureHeader } from "@/components/json-form/disclosure";
import type { RenderJsonFormField } from "@/components/json-form/field-renderer";
import {
AUTO_COLLAPSE_DEPTH,
CARD_VIRTUALIZE_THRESHOLD,
LONG_ARRAY_THRESHOLD,
} from "@/components/json-form/json-form-constants";
import { useJsonFormStartsOpen } from "@/components/json-form/open-paths";
import { useJsonFormReadOnly } from "@/components/json-form/read-only";
import {
emptyArrayItemFormValue,
joinJsonFormPath,
joinJsonSourcePath,
} from "@/components/json-form/path-codec";
import type { JsonFormTextInput } from "@/components/json-form/scalar-control";
import {
arrayItemSchemaAt,
canAppendArrayItem,
canRemoveArrayItem,
hasDynamicObjectProperties,
scalarObjectColumns,
type Schema,
} from "@/components/json-form/schema-model";
import { ArrayTable } from "@/components/json-form/table/array-table";
import { VirtualList } from "@/components/json-form/virtual-list";
export function JsonFormArray({
name,
sourcePath,
schema,
label,
textInput,
className,
depth,
renderField,
}: {
name: string;
sourcePath: string;
schema: Schema;
label: string;
textInput?: JsonFormTextInput;
className?: string;
depth: number;
renderField: RenderJsonFormField;
}) {
const readOnly = useJsonFormReadOnly();
const { control, getValues, setValue, unregister } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name });
const arrayValue = useWatch({ control, name });
const renderedFields = React.useMemo(() => {
// react-hook-form's useFieldArray compacts falsy primitive items (false,
// 0, "") out of `fields`, so derive the rendered item count from the
// watched array value instead of the compacted field list.
if (!Array.isArray(arrayValue)) {
return fields.map((field, index) => ({
id: field.id ?? `${name}.${index}`,
}));
}
return arrayValue.map((_, index) => ({
id: fields[index]?.id ?? `${name}.${index}`,
}));
}, [arrayValue, fields, name]);
const itemSchema = React.useMemo(
() => arrayItemSchemaAt(schema, 0),
[schema],
);
const isTupleArray = Array.isArray(schema.items);
const hasDynamicItemProperties = React.useMemo(
() => hasDynamicObjectProperties(itemSchema),
[itemSchema],
);
const itemSchemaForIndex = React.useCallback(
(index: number) => arrayItemSchemaAt(schema, index),
[schema],
);
const columns = React.useMemo(
() =>
isTupleArray || hasDynamicItemProperties
? null
: scalarObjectColumns(itemSchema),
[hasDynamicItemProperties, isTupleArray, itemSchema],
);
const startsOpen = useJsonFormStartsOpen(
sourcePath,
depth < AUTO_COLLAPSE_DEPTH &&
renderedFields.length <= LONG_ARRAY_THRESHOLD,
);
const [open, setOpen] = React.useState(startsOpen);
const canAddItem =
!readOnly && canAppendArrayItem(schema, renderedFields.length);
const canRemoveItem =
!readOnly && canRemoveArrayItem(schema, renderedFields.length);
const add = React.useCallback(() => {
const current = getValues(name);
const nextIndex = Array.isArray(current)
? current.length
: renderedFields.length;
if (!canAppendArrayItem(schema, nextIndex)) return;
const nextSchema = arrayItemSchemaAt(schema, nextIndex);
const nextItem = emptyArrayItemFormValue(nextSchema);
append(nextItem as never);
if (Array.isArray(current)) {
setValue(name, [...current, nextItem], { shouldDirty: true });
}
setOpen(true);
}, [append, getValues, name, renderedFields.length, schema, setValue]);
const removeAt = React.useCallback(
(index: number) => {
const current = getValues(name);
if (Array.isArray(current)) {
if (!canRemoveArrayItem(schema, current.length)) return;
const next = current.slice();
next.splice(index, 1);
remove(index);
setValue(name, next, { shouldDirty: true });
unregister(`${name}.${next.length}`);
return;
}
if (!canRemoveArrayItem(schema, renderedFields.length)) return;
remove(index);
},
[
getValues,
name,
remove,
renderedFields.length,
schema,
setValue,
unregister,
],
);
return (
<div
className={cn(
"bg-background overflow-hidden rounded-lg border shadow-sm",
className,
)}
>
<DisclosureHeader
open={open}
onToggle={() => setOpen((o) => !o)}
title={label}
summary={`${renderedFields.length} item${renderedFields.length === 1 ? "" : "s"}`}
description={schema.description}
actions={
readOnly ? null : (
<Button
type="button"
size="sm"
variant="outline"
onClick={add}
disabled={!canAddItem}
>
<Plus className="size-4" />
Add
</Button>
)
}
/>
{open ? (
<div className={cn("border-t", columns ? "" : "p-3")}>
{renderedFields.length === 0 ? (
<p className="text-muted-foreground text-sm">No items.</p>
) : columns ? (
<ArrayTable
name={name}
sourcePath={sourcePath}
fields={renderedFields}
remove={removeAt}
canRemove={canRemoveItem}
columns={columns}
/>
) : (
<ArrayCards
name={name}
sourcePath={sourcePath}
fields={renderedFields}
remove={removeAt}
canRemove={canRemoveItem}
itemSchemaForIndex={itemSchemaForIndex}
label={label}
textInput={textInput}
depth={depth}
renderField={renderField}
/>
)}
</div>
) : null}
</div>
);
}
interface ArrayCardsProps {
name: string;
sourcePath: string;
fields: { id: string }[];
remove: (index: number) => void;
canRemove: boolean;
itemSchemaForIndex: (index: number) => Schema;
label: string;
textInput?: JsonFormTextInput;
depth: number;
renderField: RenderJsonFormField;
}
function ArrayCards({
name,
sourcePath,
fields,
remove,
canRemove,
itemSchemaForIndex,
label,
textInput,
depth,
renderField,
}: ArrayCardsProps) {
const renderCard = React.useCallback(
(index: number) => (
<ArrayCard
name={name}
sourcePath={sourcePath}
index={index}
remove={remove}
canRemove={canRemove}
itemSchema={itemSchemaForIndex(index)}
label={label}
textInput={textInput}
depth={depth}
renderField={renderField}
/>
),
[
name,
sourcePath,
remove,
canRemove,
itemSchemaForIndex,
label,
textInput,
depth,
renderField,
],
);
if (fields.length > CARD_VIRTUALIZE_THRESHOLD) {
return (
<VirtualList
fields={fields}
estimateSize={64}
renderItem={renderCard}
gap={8}
/>
);
}
return (
<div className="space-y-2">
{fields.map((entry, index) => (
<React.Fragment key={entry.id}>{renderCard(index)}</React.Fragment>
))}
</div>
);
}
const ArrayCard = React.memo(function ArrayCard({
name,
sourcePath,
index,
remove,
canRemove,
itemSchema,
label,
textInput,
depth,
renderField,
}: {
name: string;
sourcePath: string;
index: number;
remove: (index: number) => void;
canRemove: boolean;
itemSchema: Schema;
label: string;
textInput?: JsonFormTextInput;
depth: number;
renderField: RenderJsonFormField;
}) {
const readOnly = useJsonFormReadOnly();
return (
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
{renderField({
name: joinJsonFormPath(name, index),
sourcePath: joinJsonSourcePath(sourcePath, index),
schema: itemSchema,
label: `${label} ${index + 1}`,
textInput,
depth: depth + 1,
})}
</div>
{readOnly ? null : (
<Button
type="button"
size="icon"
variant="ghost"
className="text-muted-foreground hover:border-border hover:text-destructive mt-1 border-transparent hover:bg-transparent"
onClick={() => remove(index)}
aria-label="Remove item"
disabled={!canRemove}
>
<X className="size-4" />
</Button>
)}
</div>
);
});
components/json-form/disclosure.tsx
"use client";
import * as React from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
/**
* Wraps a label element so a field description shows as a hover tooltip on the
* label itself rather than a body-text block that changes row height.
*/
export function WithDescription({
text,
children,
}: {
text?: string;
children: React.ReactElement;
}) {
if (!text) return children;
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="max-w-xs text-left whitespace-pre-line">
{text}
</TooltipContent>
</Tooltip>
);
}
export function DisclosureHeader({
open,
onToggle,
title,
summary,
description,
actions,
}: {
open: boolean;
onToggle: () => void;
title: string;
summary?: string;
description?: string;
actions?: React.ReactNode;
}) {
return (
<div className="flex items-center gap-1 px-2 py-1.5">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-label={summary ? `${title} ${summary}` : title}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
>
<ChevronRight
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform",
open && "rotate-90",
)}
/>
<WithDescription text={description}>
<span className="truncate text-sm font-medium">{title}</span>
</WithDescription>
{summary ? (
<span className="text-muted-foreground shrink-0 text-xs">
{summary}
</span>
) : null}
</button>
{actions}
</div>
);
}
components/json-form/field-renderer.ts
import type * as React from "react";
import type { JsonFormTextInput } from "@/components/json-form/scalar-control";
import type { Schema } from "@/components/json-form/schema-model";
export interface JsonFormFieldRenderProps {
name: string;
sourcePath?: string;
schema: Schema;
required?: boolean;
label?: string;
textInput?: JsonFormTextInput;
className?: string;
depth?: number;
}
export type RenderJsonFormField = (
props: JsonFormFieldRenderProps,
) => React.ReactNode;
components/json-form/form-primitives.tsx
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { CheckIcon } from "lucide-react";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
export const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue | null>(
null,
);
export function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>(props: ControllerProps<TFieldValues, TName>) {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
}
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue | null>(null);
function useFormField() {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>");
}
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
}
export function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
export function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<LabelPrimitive.Root
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
export function FormControl(props: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
const { children, ...controlProps } = props;
const fieldProps = {
"data-slot": "form-control",
id: formItemId,
"aria-describedby": error
? `${formDescriptionId} ${formMessageId}`
: `${formDescriptionId}`,
"aria-invalid": !!error,
...controlProps,
};
if (React.isValidElement(children)) {
return React.cloneElement(
children as React.ReactElement<Record<string, unknown>>,
fieldProps,
);
}
return <Slot {...fieldProps} />;
}
export function FormMessage({
className,
...props
}: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error.message ?? "") : props.children;
if (!body) return null;
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export function Textarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-none transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
components/json-form/json-form-constants.ts
export const AUTO_COLLAPSE_DEPTH = 1;
export const CARD_VIRTUALIZE_THRESHOLD = 30;
export const LONG_ARRAY_THRESHOLD = 8;
components/json-form/json-form.tsx
"use client";
import * as React from "react";
import { type SubmitHandler, type UseFormReturn } from "react-hook-form";
import { cn } from "@/lib/utils";
import { JsonFormArray } from "@/components/json-form/array-fields";
import { WithDescription } from "@/components/json-form/disclosure";
import type { JsonFormFieldRenderProps } from "@/components/json-form/field-renderer";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/json-form/form-primitives";
import {
JsonFormObject,
JsonFormRootFields,
} from "@/components/json-form/object-fields";
import { JsonFormOpenPathsContext } from "@/components/json-form/open-paths";
import {
decodeJsonFormValue,
encodeJsonFormValue,
schemaNeedsJsonFormPathEncoding,
} from "@/components/json-form/path-codec";
import { JsonFormReadOnlyProvider } from "@/components/json-form/read-only";
import {
BooleanControl,
NullableBooleanControl,
ScalarControl,
type JsonFormTextInput,
} from "@/components/json-form/scalar-control";
import {
expandRefs,
fieldKind,
labelFor,
unwrapNullable,
type Schema,
} from "@/components/json-form/schema-model";
import {
JsonFormSourceLinkProvider,
SourceLinkShell,
type JsonFormSourceLink,
} from "@/components/json-form/source-link";
import { useKeyedMountEffect } from "@/hooks/use-keyed-mount-effect";
import { joinEffectKey } from "@/lib/effect-key";
export type { JsonFormTextInput } from "@/components/json-form/scalar-control";
/**
* A JSON-Schema-driven form built entirely on shadcn's `FormField` abstraction.
*
* Each schema property is rendered through `<FormField>` →
* `<FormItem>/<FormLabel>/<FormControl>/<FormDescription>/<FormMessage>`, so it
* inherits shadcn's react-hook-form wiring, accessibility, and error display
* with zero bespoke styling. Objects nest, arrays repeat, and scalars map to the
* matching control. Drop it inside your own `useForm()` instance.
*
* Built to scale to deep, repetitive documents (e.g. an extraction with
* `properties[] → production[] → line_items[]`):
*
* - **Lazy mount.** Nested objects and arrays are collapsible; their children
* are only mounted in the DOM while expanded, so a 5,000-field tree boots as
* a handful of summary rows.
* - **Table mode.** An array whose items are flat objects of scalars renders as
* a dense editable table (one row per item, one column per field) instead of
* a stack of bordered cards.
* - **Virtualization.** Long arrays (card *or* table mode) window their rows
* through local row virtualizers, so only the visible items are in the DOM.
* - **Isolated re-renders.** Each row subscribes to its own field state, so a
* keystroke in one item never re-renders its siblings.
*/
// ---------------------------------------------------------------------------
// JsonFormField — the unit of composition
// ---------------------------------------------------------------------------
export type JsonFormFieldProps = JsonFormFieldRenderProps;
export function JsonFormField({
name,
sourcePath,
schema: rawSchema,
required = false,
label,
textInput,
className,
depth = 0,
}: JsonFormFieldProps) {
const expandedSchema = React.useMemo(
() => expandRefs(rawSchema),
[rawSchema],
);
const { schema, nullable } = unwrapNullable(expandedSchema);
const kind = fieldKind(schema);
const heading = labelFor(name, schema, label);
const resolvedSourcePath = sourcePath ?? name;
if (kind === "object") {
return (
<JsonFormObject
name={name}
sourcePath={resolvedSourcePath}
schema={schema}
label={heading}
textInput={textInput}
className={className}
depth={depth}
renderField={renderJsonFormField}
/>
);
}
if (kind === "array") {
return (
<JsonFormArray
name={name}
sourcePath={resolvedSourcePath}
schema={schema}
label={heading}
textInput={textInput}
className={className}
depth={depth}
renderField={renderJsonFormField}
/>
);
}
if (kind === "boolean") {
if (nullable) {
return (
<SourceLinkShell sourcePath={resolvedSourcePath}>
<FormField
name={name}
render={({ field }) => (
<FormItem className={className}>
<WithDescription text={schema.description}>
<FormLabel>
{heading}
{required ? (
<span className="text-destructive"> *</span>
) : null}
</FormLabel>
</WithDescription>
<FormControl>
<NullableBooleanControl
field={field}
label={`${heading}${required ? " *" : ""}`}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SourceLinkShell>
);
}
return (
<SourceLinkShell sourcePath={resolvedSourcePath}>
<FormField
name={name}
render={({ field }) => (
<FormItem className={className}>
<WithDescription text={schema.description}>
<FormLabel>
{heading}
{required ? (
<span className="text-destructive"> *</span>
) : null}
</FormLabel>
</WithDescription>
<FormControl>
<BooleanControl
field={field}
label={`${heading}${required ? " *" : ""}`}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SourceLinkShell>
);
}
return (
<SourceLinkShell sourcePath={resolvedSourcePath}>
<FormField
name={name}
render={({ field }) => (
<FormItem className={className}>
<WithDescription text={schema.description}>
<FormLabel>
{heading}
{required ? <span className="text-destructive"> *</span> : null}
</FormLabel>
</WithDescription>
<FormControl>
<ScalarControl
kind={kind}
schema={schema}
field={field}
textInput={textInput}
nullable={nullable}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SourceLinkShell>
);
}
function renderJsonFormField(props: JsonFormFieldRenderProps) {
return <JsonFormField {...props} />;
}
// ---------------------------------------------------------------------------
// JsonForm — convenience wrapper over a whole schema
// ---------------------------------------------------------------------------
export interface JsonFormProps {
form: UseFormReturn<Record<string, unknown>>;
schema: Schema;
onSubmit?: SubmitHandler<Record<string, unknown>>;
className?: string;
/** Force plain string fields to render as single-line inputs or textareas. */
textInput?: JsonFormTextInput;
/**
* Opt into field-level source linking. When set, every scalar field becomes a
* hoverable card that reports its path and highlights when active — wire it
* straight from a source field link.
*/
sourceLink?: JsonFormSourceLink;
/**
* Source/logical paths that should start expanded. Intended for controlled
* demos and benchmarks that need a deep virtualized body mounted immediately.
*/
defaultOpenPaths?: readonly string[];
/**
* Render every control inert. Values stay visible (and selectable) but
* cannot be changed, and array add/remove affordances are dropped. Use it on
* viewer surfaces that never submit the form.
*/
readOnly?: boolean;
/** Rendered after the fields, e.g. a submit button. */
children?: React.ReactNode;
}
export function JsonForm({
form,
schema,
onSubmit,
className,
textInput,
sourceLink,
defaultOpenPaths,
readOnly = false,
children,
}: JsonFormProps) {
const expandedSchema = React.useMemo(() => expandRefs(schema), [schema]);
const usesEncodedPaths = React.useMemo(
() => schemaNeedsJsonFormPathEncoding(expandedSchema),
[expandedSchema],
);
const defaultOpenPathSet = React.useMemo(
() =>
defaultOpenPaths && defaultOpenPaths.length > 0
? new Set(defaultOpenPaths)
: null,
[defaultOpenPaths],
);
const hasEncodedInitialValuesRef = React.useRef(false);
useKeyedMountEffect(
joinEffectKey([expandedSchema, form, usesEncodedPaths]),
() => {
if (!usesEncodedPaths || hasEncodedInitialValuesRef.current) {
return;
}
hasEncodedInitialValuesRef.current = true;
form.reset(
encodeJsonFormValue(expandedSchema, form.getValues()) as Record<
string,
unknown
>,
);
},
);
const handleSubmit = React.useCallback(
(event: React.FormEvent) => {
if (!onSubmit) {
event.preventDefault();
return;
}
const activeElement = event.currentTarget.ownerDocument.activeElement;
if (
activeElement instanceof HTMLElement &&
event.currentTarget.contains(activeElement)
) {
activeElement.blur();
}
return form.handleSubmit((data, submitEvent) => {
const decoded = usesEncodedPaths
? (decodeJsonFormValue(expandedSchema, data) as Record<
string,
unknown
>)
: data;
return onSubmit(decoded, submitEvent);
})(event);
},
[expandedSchema, form, onSubmit, usesEncodedPaths],
);
return (
<JsonFormSourceLinkProvider sourceLink={sourceLink}>
<JsonFormReadOnlyProvider readOnly={readOnly}>
<JsonFormOpenPathsContext.Provider value={defaultOpenPathSet}>
<Form {...form}>
<form
onSubmit={handleSubmit}
className={cn("space-y-4", className)}
>
<JsonFormRootFields
schema={expandedSchema}
textInput={textInput}
renderField={renderJsonFormField}
/>
{children}
</form>
</Form>
</JsonFormOpenPathsContext.Provider>
</JsonFormReadOnlyProvider>
</JsonFormSourceLinkProvider>
);
}
components/json-form/object-fields.tsx
"use client";
import * as React from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { cn } from "@/lib/utils";
import { DisclosureHeader } from "@/components/json-form/disclosure";
import type { RenderJsonFormField } from "@/components/json-form/field-renderer";
import { AUTO_COLLAPSE_DEPTH } from "@/components/json-form/json-form-constants";
import { useJsonFormStartsOpen } from "@/components/json-form/open-paths";
import {
dynamicPropertyEntries,
joinJsonFormPath,
joinJsonSourcePath,
staticPropertyKeys,
} from "@/components/json-form/path-codec";
import type { JsonFormTextInput } from "@/components/json-form/scalar-control";
import {
labelFor,
schemaProperties,
type Schema,
} from "@/components/json-form/schema-model";
export function JsonFormObject({
name,
sourcePath,
schema,
label,
textInput,
className,
depth,
renderField,
}: {
name: string;
sourcePath: string;
schema: Schema;
label: string;
textInput?: JsonFormTextInput;
className?: string;
depth: number;
renderField: RenderJsonFormField;
}) {
const { control, getValues } = useFormContext();
const properties = React.useMemo(() => schemaProperties(schema), [schema]);
const required = React.useMemo(
() => new Set(schema.required ?? []),
[schema],
);
const entries = React.useMemo(() => Object.entries(properties), [properties]);
const currentValue = useWatch({
control,
name,
defaultValue: getValues(name),
}) as unknown;
const staticKeys = React.useMemo(() => staticPropertyKeys(schema), [schema]);
const dynamicEntries = React.useMemo(
() => dynamicPropertyEntries(schema, currentValue, staticKeys),
[currentValue, schema, staticKeys],
);
const fieldCount = entries.length + dynamicEntries.length;
const startsOpen = useJsonFormStartsOpen(
sourcePath,
depth < AUTO_COLLAPSE_DEPTH,
);
const [open, setOpen] = React.useState(startsOpen);
return (
<div className={cn("rounded-lg border", className)}>
<DisclosureHeader
open={open}
onToggle={() => setOpen((o) => !o)}
title={label}
summary={`${fieldCount} field${fieldCount === 1 ? "" : "s"}`}
description={schema.description}
/>
{open ? (
<div className="space-y-3 border-t p-3">
{entries.map(([key, child]) =>
typeof child === "object" ? (
<React.Fragment key={key}>
{renderField({
name: joinJsonFormPath(name, key),
sourcePath: joinJsonSourcePath(sourcePath, key),
schema: child,
required: required.has(key),
label: labelFor(key, child),
textInput,
depth: depth + 1,
})}
</React.Fragment>
) : null,
)}
{dynamicEntries.map(({ key, schema: child }) => (
<React.Fragment key={key}>
{renderField({
name: joinJsonFormPath(name, key),
sourcePath: joinJsonSourcePath(sourcePath, key),
schema: child,
label: key,
textInput,
depth: depth + 1,
})}
</React.Fragment>
))}
</div>
) : null}
</div>
);
}
export function JsonFormRootFields({
schema,
textInput,
renderField,
}: {
schema: Schema;
textInput?: JsonFormTextInput;
renderField: RenderJsonFormField;
}) {
const { control, getValues } = useFormContext();
const properties = schemaProperties(schema);
const required = new Set(schema.required ?? []);
const entries = Object.entries(properties);
const currentValue = useWatch({
control,
defaultValue: getValues(),
}) as unknown;
const staticKeys = React.useMemo(() => staticPropertyKeys(schema), [schema]);
const dynamicEntries = React.useMemo(
() => dynamicPropertyEntries(schema, currentValue, staticKeys),
[currentValue, schema, staticKeys],
);
return (
<>
{entries.map(([key, child]) =>
typeof child === "object" ? (
<React.Fragment key={key}>
{renderField({
name: joinJsonFormPath("", key),
sourcePath: key,
schema: child,
required: required.has(key),
label: labelFor(key, child),
textInput,
depth: 0,
})}
</React.Fragment>
) : null,
)}
{dynamicEntries.map(({ key, schema: child }) => (
<React.Fragment key={key}>
{renderField({
name: joinJsonFormPath("", key),
sourcePath: key,
schema: child,
label: key,
textInput,
depth: 0,
})}
</React.Fragment>
))}
</>
);
}
components/json-form/open-paths.tsx
"use client";
import * as React from "react";
export const JsonFormOpenPathsContext =
React.createContext<ReadonlySet<string> | null>(null);
export function useJsonFormStartsOpen(sourcePath: string, fallback: boolean) {
return (
React.useContext(JsonFormOpenPathsContext)?.has(sourcePath) ?? fallback
);
}
components/json-form/path-codec.ts
import {
arrayItemSchemaAt,
dynamicPropertySchemaFor,
emptyValueFor,
fieldKind,
isRecordValue,
schemaPatternProperties,
schemaProperties,
unwrapNullable,
type Schema,
} from "@/components/json-form/schema-model";
export function encodeJsonFormKey(segment: string): string {
return encodeURIComponent(segment)
.replace(
/[!'()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
)
.replace(
/[.[\]'"]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
export function decodeJsonFormKey(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
export function joinJsonFormPath(parent: string, key: string | number): string {
const segment =
typeof key === "number" ? String(key) : encodeJsonFormKey(key);
return parent ? `${parent}.${segment}` : segment;
}
export function joinJsonSourcePath(
parent: string,
key: string | number,
): string {
const segment = String(key);
return parent ? `${parent}.${segment}` : segment;
}
export function staticPropertyKeys(schema: Schema): Set<string> {
return new Set(
Object.keys(schemaProperties(schema)).flatMap((key) => [
key,
encodeJsonFormKey(key),
]),
);
}
export function dynamicPropertyEntries(
schema: Schema,
currentValue: unknown,
staticKeys: Set<string>,
): Array<{ key: string; schema: Schema }> {
if (!isRecordValue(currentValue)) return [];
return Object.keys(currentValue).flatMap((key) => {
if (staticKeys.has(key)) return [];
const decodedKey = decodeJsonFormKey(key);
if (staticKeys.has(decodedKey)) return [];
const childSchema = dynamicPropertySchemaFor(schema, decodedKey);
return childSchema ? [{ key: decodedKey, schema: childSchema }] : [];
});
}
export function schemaNeedsJsonFormPathEncoding(schema: Schema): boolean {
const { schema: inner } = unwrapNullable(schema);
const kind = fieldKind(inner);
if (kind === "object") {
const properties = schemaProperties(inner);
const patternProperties = schemaPatternProperties(inner);
return (
isRecordValue(inner.additionalProperties) ||
Object.values(patternProperties).some(isRecordValue) ||
Object.entries(properties).some(([key, child]) => {
return (
encodeJsonFormKey(key) !== key ||
(typeof child === "object" &&
child !== null &&
schemaNeedsJsonFormPathEncoding(child))
);
})
);
}
if (kind === "array" && typeof inner.items === "object" && inner.items) {
if (Array.isArray(inner.items)) {
return inner.items.some((item) =>
isRecordValue(item)
? schemaNeedsJsonFormPathEncoding(item as Schema)
: false,
);
}
return schemaNeedsJsonFormPathEncoding(inner.items as Schema);
}
return false;
}
export function encodeJsonFormValue(schema: Schema, value: unknown): unknown {
const { schema: inner } = unwrapNullable(schema);
const kind = fieldKind(inner);
if (kind === "array") {
if (!Array.isArray(value)) return value;
return value.map((item, index) =>
encodeJsonFormValue(arrayItemSchemaAt(inner, index), item),
);
}
if (kind !== "object" || !isRecordValue(value)) return value;
const properties = schemaProperties(inner);
const encoded: Record<string, unknown> = {};
for (const [key, child] of Object.entries(properties)) {
if (typeof child !== "object" || child === null) continue;
const encodedKey = encodeJsonFormKey(key);
const rawValue = Object.prototype.hasOwnProperty.call(value, key)
? value[key]
: value[encodedKey];
if (
rawValue !== undefined ||
Object.prototype.hasOwnProperty.call(value, key)
) {
encoded[encodedKey] = encodeJsonFormValue(child, rawValue);
}
}
const propertyKeys = new Set(Object.keys(properties));
for (const [key, rawValue] of Object.entries(value)) {
const decodedKey = decodeJsonFormKey(key);
if (propertyKeys.has(key) || propertyKeys.has(decodedKey)) continue;
const childSchema = dynamicPropertySchemaFor(inner, decodedKey);
if (!childSchema) continue;
encoded[encodeJsonFormKey(decodedKey)] = encodeJsonFormValue(
childSchema,
rawValue,
);
}
return encoded;
}
export function decodeJsonFormValue(schema: Schema, value: unknown): unknown {
const { schema: inner } = unwrapNullable(schema);
const kind = fieldKind(inner);
if (kind === "array") {
if (!Array.isArray(value)) return value;
return value.map((item, index) =>
decodeJsonFormValue(arrayItemSchemaAt(inner, index), item),
);
}
if (kind !== "object" || !isRecordValue(value)) return value;
const properties = schemaProperties(inner);
const decoded: Record<string, unknown> = {};
const handledKeys = new Set<string>();
for (const [key, child] of Object.entries(properties)) {
if (typeof child !== "object" || child === null) continue;
const encodedKey = encodeJsonFormKey(key);
const hasEncoded = Object.prototype.hasOwnProperty.call(value, encodedKey);
const rawValue = hasEncoded ? value[encodedKey] : value[key];
handledKeys.add(encodedKey);
handledKeys.add(key);
if (
rawValue !== undefined ||
hasEncoded ||
Object.prototype.hasOwnProperty.call(value, key)
) {
decoded[key] = decodeJsonFormValue(child, rawValue);
}
}
for (const [key, rawValue] of Object.entries(value)) {
const decodedKey = decodeJsonFormKey(key);
if (handledKeys.has(key) || handledKeys.has(decodedKey)) continue;
const childSchema = dynamicPropertySchemaFor(inner, decodedKey);
if (!childSchema) continue;
decoded[decodedKey] = decodeJsonFormValue(childSchema, rawValue);
}
return decoded;
}
export function emptyArrayItemFormValue(schema: Schema): unknown {
const { schema: inner, nullable } = unwrapNullable(schema);
if (nullable) return null;
if (fieldKind(inner) !== "object") return emptyValueFor(inner);
const value: Record<string, unknown> = {};
const shouldEncodeKeys = schemaNeedsJsonFormPathEncoding(inner);
for (const [key, child] of Object.entries(schemaProperties(inner))) {
if (typeof child === "object" && child !== null) {
value[shouldEncodeKeys ? encodeJsonFormKey(key) : key] =
emptyValueFor(child);
}
}
return value;
}
components/json-form/scalar-control.tsx
"use client";
import {
BooleanControl,
NullableBooleanControl,
} from "@/components/json-form/scalar/boolean-control";
import {
datetimeLocalInputValue,
DateTimeScalarControl,
} from "@/components/json-form/scalar/date-time-control";
import {
EnumControl,
enumLabel,
enumValueEquals,
} from "@/components/json-form/scalar/enum-control";
import {
dataCellNumberValue,
NumberControl,
} from "@/components/json-form/scalar/number-control";
import {
dataCellTextValue,
TextControl,
} from "@/components/json-form/scalar/text-control";
import type {
ControlFieldApi,
DateTimeControlKind,
JsonFormTextInput,
ScalarControlDomProps,
} from "@/components/json-form/scalar/types";
import type { FieldKind, Schema } from "@/components/json-form/schema-model";
export type {
ControlFieldApi,
JsonFormTextInput,
ScalarControlDomProps,
} from "@/components/json-form/scalar/types";
export {
BooleanControl,
dataCellNumberValue,
dataCellTextValue,
datetimeLocalInputValue,
enumLabel,
enumValueEquals,
NullableBooleanControl,
};
export function ScalarControl({
kind,
schema,
field,
textInput,
compact = false,
nullable = false,
...controlProps
}: {
kind: FieldKind;
schema: Schema;
field: ControlFieldApi;
textInput?: JsonFormTextInput;
/** Dense, single-line variant for table cells. */
compact?: boolean;
nullable?: boolean;
} & ScalarControlDomProps) {
if (kind === "enum") {
return (
<EnumControl
{...controlProps}
schema={schema}
field={field}
compact={compact}
nullable={nullable}
/>
);
}
if (kind === "number" || kind === "integer") {
return (
<NumberControl
{...controlProps}
kind={kind}
field={field}
compact={compact}
nullable={nullable}
/>
);
}
if (
schema.format === "date" ||
schema.format === "time" ||
schema.format === "date-time"
) {
return (
<DateTimeScalarControl
{...controlProps}
kind={schema.format as DateTimeControlKind}
field={field}
compact={compact}
nullable={nullable}
/>
);
}
return (
<TextControl
{...controlProps}
schema={schema}
field={field}
textInput={textInput}
compact={compact}
nullable={nullable}
/>
);
}
components/json-form/scalar/boolean-control.tsx
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/json-form/form-primitives";
import { useJsonFormReadOnly } from "@/components/json-form/read-only";
import { NULL_SELECT_VALUE } from "@/components/json-form/scalar/enum-control";
import {
type ControlFieldApi,
type ScalarControlDomProps,
} from "@/components/json-form/scalar/types";
export function BooleanControl({
field,
label,
}: {
field: ControlFieldApi;
label: string;
}) {
const readOnly = useJsonFormReadOnly();
return (
<Checkbox
disabled={readOnly}
checked={Boolean(field.value)}
aria-label={label}
onCheckedChange={(value) => field.onChange(value === true)}
onBlur={field.onBlur}
/>
);
}
export function NullableBooleanControl({
field,
label,
...controlProps
}: {
field: ControlFieldApi;
label: string;
} & ScalarControlDomProps) {
const readOnly = useJsonFormReadOnly();
const selectValue =
field.value === true
? "true"
: field.value === false
? "false"
: NULL_SELECT_VALUE;
const displayValue =
field.value === true
? "True"
: field.value === false
? "False"
: "No value";
return (
<Select
disabled={readOnly}
value={selectValue}
onValueChange={(value) => {
if (value === "true") {
field.onChange(true);
return;
}
if (value === "false") {
field.onChange(false);
return;
}
field.onChange(null);
}}
>
<SelectTrigger {...controlProps} aria-label={label}>
<SelectValue placeholder="Select...">{displayValue}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NULL_SELECT_VALUE}>No value</SelectItem>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
);
}
components/json-form/scalar/date-time-control.tsx
"use client";
import * as React from "react";
import { CalendarIcon, ClockIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
DataCell,
formatDataCellDisplayValue,
} from "@/components/ui/data-cell";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useJsonFormReadOnly } from "@/components/json-form/read-only";
import {
compactJsonFormDataCellClass,
type ControlFieldApi,
type DateTimeControlKind,
type ScalarControlDomProps,
} from "@/components/json-form/scalar/types";
export function datetimeLocalInputValue(value: string): string {
const withoutTimezone = value.trim().replace(/(?:Z|[+-]\d{2}:\d{2})$/, "");
return withoutTimezone.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/)?.[0] ?? value;
}
export function DateTimeScalarControl({
kind,
field,
compact,
nullable,
...controlProps
}: {
kind: DateTimeControlKind;
field: ControlFieldApi;
compact: boolean;
nullable: boolean;
} & ScalarControlDomProps) {
const readOnly = useJsonFormReadOnly();
const value = field.value == null ? "" : String(field.value);
if (!compact) {
return (
<DateTimePickerControl
{...controlProps}
kind={kind}
field={field}
nullable={nullable}
/>
);
}
return (
<DataCell
{...controlProps}
kind={kind}
active={!readOnly}
value={field.value == null ? null : value}
dateTimeZone={kind === "date-time" ? "preserve" : undefined}
draftValue={kind === "date-time" ? datetimeLocalInputValue(value) : value}
className={compactJsonFormDataCellClass}
onDraftValueChange={(nextValue) =>
field.onChange(nextValue === "" && nullable ? null : nextValue)
}
onCommit={(nextValue) =>
field.onChange(nextValue === "" && nullable ? null : nextValue)
}
onBlur={field.onBlur}
name={field.name}
/>
);
}
function pickerPlaceholder(kind: DateTimeControlKind): string {
if (kind === "time") return "Pick a time";
if (kind === "date-time") return "Pick a date and time";
return "Pick a date";
}
function pickerEditValue(kind: DateTimeControlKind, value: string): string {
if (kind === "date-time") return datetimeLocalInputValue(value);
if (kind === "date") return value.match(/^\d{4}-\d{2}-\d{2}/)?.[0] ?? value;
if (kind === "time")
return value.match(/^\d{2}:\d{2}(?::\d{2})?/)?.[0] ?? value;
return value;
}
function pickerDate(
kind: DateTimeControlKind,
value: string,
): Date | undefined {
if (kind === "time" || value === "") return undefined;
const dateValue =
kind === "date-time" ? value.match(/^\d{4}-\d{2}-\d{2}/)?.[0] : value;
const match = dateValue?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return undefined;
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
function pickerTime(kind: DateTimeControlKind, value: string): string {
if (kind === "date") return "";
return value.match(/\d{2}:\d{2}(?::\d{2})?/)?.[0] ?? "";
}
function pickerValueWithDate(
kind: DateTimeControlKind,
value: string,
date: Date,
): string {
const dateValue = formatPickerDate(date);
if (kind === "date") return dateValue;
if (kind === "time") return value;
return `${dateValue}T${pickerTime("date-time", value) || "00:00"}`;
}
function pickerValueWithTime(
kind: DateTimeControlKind,
value: string,
time: string,
): string {
if (kind === "time") return time;
if (kind === "date") return value;
const dateValue =
value.match(/^\d{4}-\d{2}-\d{2}/)?.[0] ?? formatPickerDate(new Date());
return `${dateValue}T${time || "00:00"}`;
}
function formatPickerDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function DateTimePickerControl({
kind,
field,
nullable,
...controlProps
}: {
kind: DateTimeControlKind;
field: ControlFieldApi;
nullable: boolean;
} & ScalarControlDomProps) {
const readOnly = useJsonFormReadOnly();
const [open, setOpen] = React.useState(false);
const value = field.value == null ? "" : String(field.value);
const pickerValue = pickerEditValue(kind, value);
const selectedDate = pickerDate(kind, pickerValue);
const timeValue = pickerTime(kind, pickerValue);
const displayValue = formatDataCellDisplayValue(kind, value);
const isEmpty = displayValue === "";
const commitPickerValue = (nextValue: string) => {
field.onChange(nextValue === "" && nullable ? null : nextValue);
};
const setDate = (date: Date) => {
const nextValue = pickerValueWithDate(kind, pickerValue, date);
commitPickerValue(nextValue);
if (kind === "date") setOpen(false);
};
const setTime = (time: string) => {
commitPickerValue(pickerValueWithTime(kind, pickerValue, time));
};
const setToday = () => {
commitPickerValue(pickerValueWithDate(kind, pickerValue, new Date()));
if (kind === "date") setOpen(false);
};
const setNow = () => {
const now = new Date();
const time = `${String(now.getHours()).padStart(2, "0")}:${String(
now.getMinutes(),
).padStart(2, "0")}`;
commitPickerValue(
kind === "time"
? time
: pickerValueWithTime(
kind,
pickerValueWithDate(kind, pickerValue, now),
time,
),
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
{...controlProps}
type="button"
disabled={readOnly}
data-empty={isEmpty || undefined}
className="border-input bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 dark:bg-input/32 inline-flex h-8.5 w-full min-w-0 items-center justify-between gap-2 rounded-lg border px-[calc(--spacing(3)-1px)] text-left text-base font-normal shadow-xs/5 transition-shadow outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-64 sm:h-7.5 sm:text-sm"
onBlur={field.onBlur}
>
<span className={cn("truncate", isEmpty && "text-muted-foreground")}>
{isEmpty ? pickerPlaceholder(kind) : displayValue}
</span>
{kind === "time" ? (
<ClockIcon className="size-4.5 shrink-0 opacity-80 sm:size-4" />
) : (
<CalendarIcon className="size-4.5 shrink-0 opacity-80 sm:size-4" />
)}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto rounded-xl p-2 before:rounded-[calc(var(--radius-xl)-1px)]"
>
{(kind === "date" || kind === "date-time") && (
<Calendar
mode="single"
selected={selectedDate}
defaultMonth={selectedDate}
onSelect={(date) => {
if (date) setDate(date);
}}
/>
)}
{(kind === "time" || kind === "date-time") && (
<div className="border-t p-3 first:border-t-0">
<Input
nativeInput
type="time"
step={1}
value={timeValue}
onChange={(event) => setTime(event.currentTarget.value)}
/>
</div>
)}
<div className="flex items-center justify-between gap-2 border-t p-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => commitPickerValue("")}
>
Clear
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={kind === "time" ? setNow : setToday}
>
{kind === "time" ? "Now" : "Today"}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
components/json-form/scalar/enum-control.tsx
"use client";
import * as React from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useJsonFormReadOnly } from "@/components/json-form/read-only";
import {
compactJsonFormSelectDataCellClass,
type ControlFieldApi,
type ScalarControlDomProps,
} from "@/components/json-form/scalar/types";
import {
isRecordValue,
type Schema,
} from "@/components/json-form/schema-model";
export const NULL_SELECT_VALUE = "__json-form-null__";
function enumOptionValue(index: number): string {
return `enum:${index}`;
}
export function enumLabel(value: unknown): string {
if (value === null) return "No value";
if (typeof value === "string") return value;
return JSON.stringify(value);
}
function hasOwnRecordValue(
value: Record<string, unknown>,
key: string,
): boolean {
return Object.prototype.hasOwnProperty.call(value, key);
}
export function enumValueEquals(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return false;
}
return a.every((item, index) => enumValueEquals(item, b[index]));
}
if (!isRecordValue(a) || !isRecordValue(b)) {
return false;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every(
(key) => hasOwnRecordValue(b, key) && enumValueEquals(a[key], b[key]),
);
}
export function EnumControl({
schema,
field,
compact,
nullable,
...controlProps
}: {
schema: Schema;
field: ControlFieldApi;
compact: boolean;
nullable: boolean;
} & ScalarControlDomProps) {
const readOnly = useJsonFormReadOnly();
const enumValues = schema.enum ?? [];
const hasNullEnumValue = enumValues.some((value) => value === null);
const currentIndex = enumValues.findIndex((value) =>
enumValueEquals(value, field.value),
);
const selectValue =
currentIndex >= 0
? enumOptionValue(currentIndex)
: field.value === null && nullable
? NULL_SELECT_VALUE
: undefined;
const displayValue =
currentIndex >= 0
? enumLabel(enumValues[currentIndex])
: field.value === null && nullable
? "No value"
: undefined;
return (
<Select
disabled={readOnly}
value={selectValue}
onValueChange={(value) => {
if (typeof value !== "string") return;
if (value === NULL_SELECT_VALUE) {
field.onChange(null);
return;
}
const index = Number(value.replace("enum:", ""));
field.onChange(enumValues[index]);
}}
>
<SelectTrigger
{...controlProps}
{...(compact
? {
"data-slot": "data-cell",
"data-kind": "text",
"data-mode": "edit",
}
: {})}
className={compact ? compactJsonFormSelectDataCellClass : undefined}
>
<SelectValue placeholder="Select...">{displayValue}</SelectValue>
</SelectTrigger>
<SelectContent>
{nullable && !hasNullEnumValue ? (
<SelectItem value={NULL_SELECT_VALUE}>No value</SelectItem>
) : null}
{enumValues.map((option, index) => (
<SelectItem
key={enumOptionValue(index)}
value={enumOptionValue(index)}
>
{enumLabel(option)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
components/json-form/scalar/number-control.tsx
"use client";
import {
DataCell,
parseDataCellNumberInput,
type DataCellCommitValue,
type DataCellValueMeta,
} from "@/components/ui/data-cell";
import { Input } from "@/components/ui/input";
import { useJsonFormReadOnly } from "@/components/json-form/read-only";
import {
compactJsonFormDataCellClass,
type ControlFieldApi,
type ScalarControlDomProps,
} from "@/components/json-form/scalar/types";
export function NumberControl({
kind,
field,
compact,
nullable,
...controlProps
}: {
kind: "number" | "integer";
field: ControlFieldApi;
compact: boolean;
nullable: boolean;
} & ScalarControlDomProps) {
const readOnly = useJsonFormReadOnly();
if (!compact) {
return (
<Input
{...controlProps}
nativeInput
readOnly={readOnly}
type="number"
inputMode={kind === "integer" ? "numeric" : "decimal"}
step={kind === "integer" ? 1 : "any"}
value={field.value == null ? "" : String(field.value)}
onChange={(event) =>
updateNumberValue({
kind,
value: event.currentTarget.value,
nullable,
field,
})
}
onBlur={field.onBlur}
name={field.name}
/>
);
}
return (
<DataCell
{...controlProps}
kind={kind}
active={!readOnly}
value={dataCellNumberValue(field.value)}
draftValue={field.value == null ? "" : String(field.value)}
className={compactJsonFormDataCellClass}
onDraftValueChange={(value, meta) =>
updateNumberValue({ kind, value, meta, nullable, field })
}
onCommit={(value, meta) =>
updateNumberValue({ kind, value, meta, nullable, field })
}
onBlur={field.onBlur}
name={field.name}
/>
);
}
function updateNumberValue({
kind,
value,
meta,
nullable,
field,
}: {
kind: "number" | "integer";
value: DataCellCommitValue | string;
meta?: DataCellValueMeta;
nullable: boolean;
field: ControlFieldApi;
}) {
const rawValue = meta?.rawValue ?? (typeof value === "string" ? value : "");
const parsed = parseDataCellNumberInput({ kind, value: rawValue });
if (!parsed.isValid) return;
if (parsed.isEmpty) {
field.onChange(nullable ? null : undefined);
return;
}
field.onChange(parsed.value);
}
export function dataCellNumberValue(value: unknown): string | number | null {
return typeof value === "number" || typeof value === "string" ? value : null;
}
components/json-form/scalar/text-control.tsx
"use client";
import { DataCell } from "@/components/ui/data-cell";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/json-form/form-primitives";
import { useJsonFormReadOnly } from "@/components/json-form/read-only";
import {
compactJsonFormDataCellClass,
type ControlFieldApi,
type JsonFormTextInput,
type ScalarControlDomProps,
} from "@/components/json-form/scalar/types";
import type { Schema } from "@/components/json-form/schema-model";
export function TextControl({
schema,
field,
textInput,
compact,
nullable,
...controlProps
}: {
schema: Schema;
field: ControlFieldApi;
textInput?: JsonFormTextInput;
compact: boolean;
nullable: boolean;
} & ScalarControlDomProps) {
const readOnly = useJsonFormReadOnly();
const value = field.value == null ? "" : String(field.value);
if (!compact && shouldRenderTextarea(schema, textInput)) {
return (
<Textarea
{...controlProps}
readOnly={readOnly}
value={value}
onChange={(event) =>
field.onChange(
event.target.value === "" && nullable ? null : event.target.value,
)
}
onBlur={field.onBlur}
name={field.name}
/>
);
}
if (!compact) {
return (
<Input
{...controlProps}
readOnly={readOnly}
value={value}
onChange={(event) => {
const nextValue = event.currentTarget.value;
field.onChange(nextValue === "" && nullable ? null : nextValue);
}}
onBlur={field.onBlur}
name={field.name}
/>
);
}
return (
<DataCell
{...controlProps}
kind="text"
active={!readOnly}
value={field.value == null ? null : value}
draftValue={value}
className={compactJsonFormDataCellClass}
onDraftValueChange={(nextValue) =>
field.onChange(nextValue === "" && nullable ? null : nextValue)
}
onCommit={(nextValue) =>
field.onChange(nextValue === "" && nullable ? null : nextValue)
}
onBlur={field.onBlur}
name={field.name}
/>
);
}
function shouldRenderTextarea(
schema: Schema,
textInput: JsonFormTextInput | undefined,
): boolean {
if (textInput === "input") return false;
if (textInput === "textarea") return true;
return schema.format === "textarea" || (schema.maxLength ?? 0) > 120;
}
export function dataCellTextValue(value: unknown): string | null {
return value === null || value === undefined ? null : String(value);
}
components/json-form/scalar/types.ts
import type * as React from "react";
export type JsonFormTextInput = "input" | "textarea";
export type DateTimeControlKind = "date" | "time" | "date-time";
export interface ControlFieldApi {
value: unknown;
onChange: (value: unknown) => void;
onBlur: () => void;
name: string;
ref?: React.Ref<HTMLElement>;
}
export type ScalarControlDomProps = {
id?: string;
"aria-describedby"?: string;
"aria-invalid"?: boolean;
"data-slot"?: string;
};
export const compactJsonFormDataCellClass =
"h-8 rounded-md border-transparent bg-transparent px-2 text-sm shadow-none transition-colors hover:border-border hover:bg-background focus-visible:border-ring focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring/30";
export const compactJsonFormSelectDataCellClass =
"h-8 rounded-md border-transparent bg-transparent px-2 text-sm shadow-none transition-colors hover:border-border hover:bg-background focus-visible:border-ring focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring/30";
components/json-form/schema-model.ts
import type { JSONSchema7, JSONSchema7Definition } from "json-schema";
export type Schema = JSONSchema7;
export type JsonFormSchemaNode = Schema;
export type FieldKind =
| "string"
| "number"
| "integer"
| "boolean"
| "enum"
| "object"
| "array";
export type JsonFormFieldKind = FieldKind;
export interface NormalizedSchema {
schema: Schema;
nullable: boolean;
}
export interface Column {
key: string;
schema: Schema;
kind: FieldKind;
required: boolean;
nullable: boolean;
}
export type JsonFormColumn = Column;
export function isSchema(
value: JSONSchema7Definition | unknown,
): value is Schema {
return typeof value === "object" && value !== null;
}
export function isRecordValue(
value: unknown,
): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cloneSchema(schema: Schema): Schema {
return JSON.parse(JSON.stringify(schema)) as Schema;
}
function definitionsFrom(
schema: Schema,
): Record<string, JSONSchema7Definition> {
return {
...((schema.definitions ?? {}) as Record<string, JSONSchema7Definition>),
...((schema.$defs ?? {}) as Record<string, JSONSchema7Definition>),
};
}
function mergeSchemas(base: Schema, next: Schema): Schema {
const merged: Schema = { ...base, ...next };
if (base.properties || next.properties) {
const baseProperties = (base.properties ?? {}) as Record<
string,
JSONSchema7Definition
>;
const nextProperties = (next.properties ?? {}) as Record<
string,
JSONSchema7Definition
>;
merged.properties = { ...baseProperties };
for (const [key, value] of Object.entries(nextProperties)) {
const existing = merged.properties[key];
merged.properties[key] =
isSchema(existing) && isSchema(value)
? mergeSchemas(existing, value)
: value;
}
}
if (base.required || next.required) {
merged.required = Array.from(
new Set([...(base.required ?? []), ...(next.required ?? [])]),
);
}
return merged;
}
function decodePointerSegment(segment: string): string {
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
function resolveLocalPointer(
schema: Schema,
ref: string,
): JSONSchema7Definition | undefined {
if (!ref.startsWith("#/")) return undefined;
let current: unknown = schema;
for (const segment of ref.slice(2).split("/").map(decodePointerSegment)) {
if (!isSchema(current) && !Array.isArray(current)) return undefined;
current = (current as Record<string, unknown>)[segment];
}
return current as JSONSchema7Definition | undefined;
}
function refKey(ref: string): string | null {
if (ref.startsWith("#/$defs/")) {
return decodePointerSegment(ref.slice("#/$defs/".length));
}
if (ref.startsWith("#/definitions/")) {
return decodePointerSegment(ref.slice("#/definitions/".length));
}
return null;
}
function resolveRef(
ref: string,
rootSchema: Schema,
definitions: Record<string, JSONSchema7Definition>,
): JSONSchema7Definition | undefined {
return resolveLocalPointer(rootSchema, ref) ?? definitions[refKey(ref) ?? ""];
}
export function normalizeJsonFormSchema(schema: Schema): JsonFormSchemaNode {
return expandRefs(schema);
}
export function expandRefs(
schema: Schema,
definitions: Record<string, JSONSchema7Definition> = definitionsFrom(schema),
visited: Set<string> = new Set(),
rootSchema: Schema = schema,
): Schema {
const working = cloneSchema(schema);
if (typeof working.$ref === "string") {
const ref = working.$ref;
const target = resolveRef(ref, rootSchema, definitions);
if (!isSchema(target) || visited.has(ref)) return working;
const nextVisited = new Set(visited);
nextVisited.add(ref);
const { $ref: _ref, ...overrides } = working;
return expandRefs(
mergeSchemas(
expandRefs(target, definitions, nextVisited, rootSchema),
overrides,
),
definitions,
nextVisited,
rootSchema,
);
}
let normalized = working;
if (Array.isArray(normalized.allOf)) {
const allOf = normalized.allOf;
delete normalized.allOf;
normalized = allOf.reduce<Schema>((merged, branch) => {
return isSchema(branch)
? mergeSchemas(
merged,
expandRefs(branch, definitions, visited, rootSchema),
)
: merged;
}, normalized);
}
for (const key of ["anyOf", "oneOf"] as const) {
const branches = normalized[key];
if (Array.isArray(branches)) {
normalized[key] = branches.map((branch) =>
isSchema(branch)
? expandRefs(branch, definitions, visited, rootSchema)
: branch,
);
}
}
if (normalized.properties) {
const properties: Record<string, JSONSchema7Definition> = {};
for (const [key, value] of Object.entries(normalized.properties)) {
properties[key] = isSchema(value)
? expandRefs(value, definitions, visited, rootSchema)
: value;
}
normalized.properties = properties;
}
if (isSchema(normalized.items)) {
normalized.items = expandRefs(
normalized.items,
definitions,
visited,
rootSchema,
);
} else if (Array.isArray(normalized.items)) {
normalized.items = normalized.items.map((item) =>
isSchema(item)
? expandRefs(item, definitions, visited, rootSchema)
: item,
);
}
if (isSchema(normalized.additionalProperties)) {
normalized.additionalProperties = expandRefs(
normalized.additionalProperties,
definitions,
visited,
rootSchema,
);
}
if (normalized.patternProperties) {
const patternProperties: Record<string, JSONSchema7Definition> = {};
for (const [key, value] of Object.entries(normalized.patternProperties)) {
patternProperties[key] = isSchema(value)
? expandRefs(value, definitions, visited, rootSchema)
: value;
}
normalized.patternProperties = patternProperties;
}
return normalized;
}
/** Resolve nullable unions like `["string","null"]` or `anyOf:[X,{type:"null"}]`. */
export function unwrapNullable(schema: Schema): NormalizedSchema {
if (Array.isArray(schema.type)) {
const nonNull = schema.type.filter((type) => type !== "null");
return {
schema: {
...schema,
type: nonNull.length === 1 ? nonNull[0] : nonNull,
},
nullable: schema.type.includes("null"),
};
}
for (const key of ["anyOf", "oneOf"] as const) {
const branches = schema[key];
if (!Array.isArray(branches)) continue;
const schemaBranches = branches.filter(isSchema);
const nullable = schemaBranches.some((branch) => branch.type === "null");
const main = schemaBranches.find((branch) => branch.type !== "null");
if (main) {
return {
schema: {
...main,
title: schema.title ?? main.title,
description: schema.description ?? main.description,
},
nullable,
};
}
}
return { schema, nullable: false };
}
export function fieldKind(schema: Schema): FieldKind {
if (Array.isArray(schema.enum)) return "enum";
const type = Array.isArray(schema.type)
? schema.type.find((item) => item !== "null")
: schema.type;
switch (type) {
case "number":
return "number";
case "integer":
return "integer";
case "boolean":
return "boolean";
case "object":
return "object";
case "array":
return "array";
default:
return "string";
}
}
export const jsonFormFieldKind = fieldKind;
export function labelFor(
name: string,
schema: Schema,
explicit?: string,
): string {
if (explicit) return explicit;
if (schema.title) return schema.title;
const leaf = name.split(".").pop() ?? name;
return leaf
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
.trim();
}
export function emptyValueFor(schema: Schema): unknown {
const { schema: inner, nullable } = unwrapNullable(schema);
if (nullable) return null;
switch (fieldKind(inner)) {
case "boolean":
return false;
case "object":
return {};
case "array":
return [];
case "number":
case "integer":
return undefined;
default:
return "";
}
}
export const emptyJsonFormValue = emptyValueFor;
export function isScalarKind(kind: FieldKind): boolean {
return kind !== "object" && kind !== "array";
}
export function schemaProperties(
schema: Schema,
): Record<string, JSONSchema7Definition> {
return (schema.properties ?? {}) as Record<string, JSONSchema7Definition>;
}
export function schemaPatternProperties(
schema: Schema,
): Record<string, JSONSchema7Definition> {
return (schema.patternProperties ?? {}) as Record<
string,
JSONSchema7Definition
>;
}
function patternPropertySchemaFor(schema: Schema, key: string): Schema | null {
for (const [pattern, child] of Object.entries(
schemaPatternProperties(schema),
)) {
if (!isRecordValue(child)) continue;
try {
if (new RegExp(pattern).test(key)) return child as Schema;
} catch {
continue;
}
}
return null;
}
function additionalPropertySchemaFor(schema: Schema): Schema | null {
return isRecordValue(schema.additionalProperties)
? (schema.additionalProperties as Schema)
: null;
}
export function dynamicPropertySchemaFor(
schema: Schema,
key: string,
): Schema | null {
return (
patternPropertySchemaFor(schema, key) ?? additionalPropertySchemaFor(schema)
);
}
export function hasDynamicObjectProperties(schema: Schema): boolean {
const { schema: inner } = unwrapNullable(schema);
if (fieldKind(inner) !== "object") return false;
return (
isRecordValue(inner.additionalProperties) ||
Object.values(schemaPatternProperties(inner)).some(isRecordValue)
);
}
export function scalarObjectColumns(itemSchema: Schema): Column[] | null {
const { schema } = unwrapNullable(itemSchema);
if (fieldKind(schema) !== "object") return null;
const properties = schemaProperties(schema);
const required = new Set(schema.required ?? []);
const columns: Column[] = [];
for (const [key, child] of Object.entries(properties)) {
if (!isSchema(child)) return null;
const { schema: inner, nullable } = unwrapNullable(child);
const kind = fieldKind(inner);
if (!isScalarKind(kind)) return null;
columns.push({
key,
schema: inner,
kind,
required: required.has(key),
nullable,
});
}
return columns.length > 0 ? columns : null;
}
export function jsonFormTableColumns(itemSchema: Schema): Column[] | null {
return scalarObjectColumns(itemSchema);
}
export function arrayItemSchemaAt(schema: Schema, index: number): Schema {
const items = schema.items;
if (Array.isArray(items)) {
const item = items[index];
if (isRecordValue(item)) return item as Schema;
if (isRecordValue(schema.additionalItems)) {
return schema.additionalItems as Schema;
}
return { type: "string" };
}
return isRecordValue(items) ? (items as Schema) : { type: "string" };
}
export const jsonFormArrayItemNode = arrayItemSchemaAt;
export function canAppendArrayItem(schema: Schema, length: number): boolean {
if (typeof schema.maxItems === "number" && length >= schema.maxItems) {
return false;
}
if (
Array.isArray(schema.items) &&
schema.additionalItems === false &&
length >= schema.items.length
) {
return false;
}
return true;
}
export function canRemoveArrayItem(schema: Schema, length: number): boolean {
return typeof schema.minItems !== "number" || length > schema.minItems;
}
components/json-form/source-link-table-hover.ts
"use client";
import * as React from "react";
import type { SourceFieldLink } from "@/components/ui/source-field-link";
import { useSourceLinkFocusPreviewIntent } from "@/components/json-form/source-link-focus-intent";
import { useKeyedMountEffect } from "@/hooks/use-keyed-mount-effect";
import { joinEffectKey } from "@/lib/effect-key";
export type JsonFormSourceLinkActions = Omit<
SourceFieldLink,
"activeSourcePath"
>;
type SourceTableCell = HTMLElement;
type SourcePointerPoint = { x: number; y: number };
type TableSourceHoverState = {
phase: "idle" | "focusing" | "hovering" | "scrolling";
pointerPoint: SourcePointerPoint | null;
sourcePath: string | null;
};
const SOURCE_PATH_ATTRIBUTE = "data-source-path";
const SOURCE_ACTIVE_ATTRIBUTE = "data-source-active";
const SOURCE_CELL_SELECTOR = `[${SOURCE_PATH_ATTRIBUTE}]`;
const TABLE_CELL_SELECTOR = "[data-table-cell]";
const SCROLL_SOURCE_HOVER_INTERVAL_MS = 32;
export function useSourceTableHoverController({
tableRef,
activeSourcePath,
sourceLinkActions,
refreshKey,
}: {
tableRef: React.RefObject<HTMLElement | null>;
activeSourcePath: string | null;
sourceLinkActions: JsonFormSourceLinkActions | null;
refreshKey: unknown;
}) {
const sourceLinked = Boolean(sourceLinkActions);
const activeSourceCellRef = React.useRef<Element | null>(null);
const hoverStateRef = React.useRef<TableSourceHoverState>({
phase: "idle",
pointerPoint: null,
sourcePath: null,
});
const pendingHoverPathRef = React.useRef<string | null>(null);
const pendingHoverFrameRef = React.useRef<number | null>(null);
const pendingScrollHoverFrameRef = React.useRef<number | null>(null);
const latestScrollHoverAtRef = React.useRef(Number.NEGATIVE_INFINITY);
const shouldPreviewFocus = useSourceLinkFocusPreviewIntent();
const setActiveSourceCell = React.useCallback((cell: Element | null) => {
if (activeSourceCellRef.current === cell) return;
activeSourceCellRef.current?.removeAttribute(SOURCE_ACTIVE_ATTRIBUTE);
if (cell) cell.setAttribute(SOURCE_ACTIVE_ATTRIBUTE, "true");
activeSourceCellRef.current = cell;
}, []);
const sourcePathForCell = React.useCallback(
(cell: Element | null): string | null =>
cell?.getAttribute(SOURCE_PATH_ATTRIBUTE) ?? null,
[],
);
useKeyedMountEffect(
joinEffectKey([
activeSourcePath,
sourceLinked,
refreshKey,
setActiveSourceCell,
tableRef,
]),
() => {
if (!sourceLinked || !activeSourcePath) {
setActiveSourceCell(null);
return;
}
if (
hoverStateRef.current.sourcePath === activeSourcePath &&
activeSourceCellRef.current?.getAttribute(SOURCE_PATH_ATTRIBUTE) ===
activeSourcePath
) {
return;
}
const table = tableRef.current;
if (!table) return;
for (const cell of table.querySelectorAll(SOURCE_CELL_SELECTOR)) {
if (cell.getAttribute(SOURCE_PATH_ATTRIBUTE) === activeSourcePath) {
setActiveSourceCell(cell);
return;
}
}
setActiveSourceCell(null);
},
);
const getCellFromTarget = React.useCallback(
(target: EventTarget | null): SourceTableCell | null => {
if (!(target instanceof Element)) return null;
const cell = target.closest<SourceTableCell>(TABLE_CELL_SELECTOR);
return cell && tableRef.current?.contains(cell) ? cell : null;
},
[tableRef],
);
const cancelPendingHover = React.useCallback(() => {
if (pendingHoverFrameRef.current === null) return;
cancelAnimationFrame(pendingHoverFrameRef.current);
pendingHoverFrameRef.current = null;
}, []);
const cancelPendingScrollHover = React.useCallback(() => {
if (pendingScrollHoverFrameRef.current === null) return;
cancelAnimationFrame(pendingScrollHoverFrameRef.current);
pendingScrollHoverFrameRef.current = null;
}, []);
const reportHoverSourcePath = React.useCallback(
(path: string | null) => {
if (!sourceLinkActions) return;
pendingHoverPathRef.current = path;
if (pendingHoverFrameRef.current !== null) return;
pendingHoverFrameRef.current = requestAnimationFrame(() => {
pendingHoverFrameRef.current = null;
sourceLinkActions.onSourceHover(pendingHoverPathRef.current);
});
},
[sourceLinkActions],
);
const setHoverSourcePath = React.useCallback(
(path: string | null, cell: Element | null) => {
if (!sourceLinkActions) return;
const currentState = hoverStateRef.current;
if (currentState.sourcePath === path) return;
hoverStateRef.current = {
phase:
currentState.phase === "scrolling"
? "scrolling"
: path
? "hovering"
: "idle",
pointerPoint: currentState.pointerPoint,
sourcePath: path,
};
setActiveSourceCell(cell);
reportHoverSourcePath(path);
},
[sourceLinkActions, reportHoverSourcePath, setActiveSourceCell],
);
useKeyedMountEffect(
joinEffectKey([cancelPendingHover, cancelPendingScrollHover]),
() => () => {
cancelPendingHover();
cancelPendingScrollHover();
},
);
const selectCellSource = React.useCallback(
(cell: HTMLElement | null) => {
const sourcePath = sourcePathForCell(cell);
if (!sourcePath) return false;
cancelPendingHover();
sourceLinkActions?.selectSourcePath?.(sourcePath);
return true;
},
[cancelPendingHover, sourceLinkActions, sourcePathForCell],
);
const handlePointerMove = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
if (!sourceLinkActions) return;
hoverStateRef.current = {
...hoverStateRef.current,
pointerPoint: {
x: event.clientX,
y: event.clientY,
},
};
if (hoverStateRef.current.phase === "scrolling") return;
const cell = getCellFromTarget(event.target);
setHoverSourcePath(sourcePathForCell(cell), cell);
},
[
sourceLinkActions,
getCellFromTarget,
setHoverSourcePath,
sourcePathForCell,
],
);
const handlePointerLeave = React.useCallback(
(event: React.PointerEvent<HTMLElement>) => {
hoverStateRef.current = {
...hoverStateRef.current,
pointerPoint: {
x: event.clientX,
y: event.clientY,
},
};
setHoverSourcePath(null, null);
},
[setHoverSourcePath],
);
const handleScrollStart = React.useCallback(() => {
hoverStateRef.current = {
phase: "scrolling",
pointerPoint: hoverStateRef.current.pointerPoint,
sourcePath: hoverStateRef.current.sourcePath,
};
}, []);
const restoreHoverSourceAtPointer = React.useCallback(() => {
if (!sourceLinkActions) return;
const point = hoverStateRef.current.pointerPoint;
if (!point) return;
const ownerDocument = tableRef.current?.ownerDocument;
if (!ownerDocument) return;
const element = ownerDocument.elementFromPoint(point.x, point.y);
const cell = getCellFromTarget(element);
setHoverSourcePath(sourcePathForCell(cell), cell);
}, [
sourceLinkActions,
tableRef,
getCellFromTarget,
setHoverSourcePath,
sourcePathForCell,
]);
const handleScrollEnd = React.useCallback(() => {
hoverStateRef.current = {
phase: hoverStateRef.current.sourcePath ? "hovering" : "idle",
pointerPoint: hoverStateRef.current.pointerPoint,
sourcePath: hoverStateRef.current.sourcePath,
};
cancelPendingScrollHover();
latestScrollHoverAtRef.current = Number.NEGATIVE_INFINITY;
restoreHoverSourceAtPointer();
}, [cancelPendingScrollHover, restoreHoverSourceAtPointer]);
const handleScrollMove = React.useCallback(() => {
if (!sourceLinkActions || pendingScrollHoverFrameRef.current !== null) {
return;
}
const now = performance.now();
if (
now - latestScrollHoverAtRef.current <
SCROLL_SOURCE_HOVER_INTERVAL_MS
) {
return;
}
pendingScrollHoverFrameRef.current = requestAnimationFrame(() => {
pendingScrollHoverFrameRef.current = null;
latestScrollHoverAtRef.current = performance.now();
restoreHoverSourceAtPointer();
});
}, [sourceLinkActions, restoreHoverSourceAtPointer]);
const handleFocus = React.useCallback(
(event: React.FocusEvent<HTMLElement>) => {
if (!sourceLinkActions) return;
if (!shouldPreviewFocus(event)) return;
const cell = getCellFromTarget(event.target);
if (!cell) return;
const sourcePath = sourcePathForCell(cell);
hoverStateRef.current = {
phase: sourcePath ? "focusing" : "idle",
pointerPoint: hoverStateRef.current.pointerPoint,
sourcePath,
};
setActiveSourceCell(cell);
sourceLinkActions.onSourceHover(sourcePath);
},
[
sourceLinkActions,
shouldPreviewFocus,
getCellFromTarget,
setActiveSourceCell,
sourcePathForCell,
],
);
const handleBlur = React.useCallback(
(event: React.FocusEvent<HTMLElement>) => {
if (!sourceLinkActions) return;
const cell = getCellFromTarget(event.target);
if (!cell || cell.contains(event.relatedTarget as Node | null)) return;
const sourcePath = sourcePathForCell(cell);
if (
hoverStateRef.current.phase !== "focusing" ||
hoverStateRef.current.sourcePath !== sourcePath
) {
return;
}
hoverStateRef.current = {
phase: "idle",
pointerPoint: hoverStateRef.current.pointerPoint,
sourcePath: null,
};
setActiveSourceCell(null);
sourceLinkActions.onSourceHover(null);
},
[
sourceLinkActions,
getCellFromTarget,
setActiveSourceCell,
sourcePathForCell,
],
);
return {
sourceLinked,
getCellFromTarget,
selectCellSource,
handlePointerMove,
handlePointerLeave,
handleFocus,
handleBlur,
handleScrollStart,
handleScrollMove,
handleScrollEnd,
};
}
components/json-form/source-link.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import type { SourceFieldLink } from "@/components/ui/source-field-link";
import { useSourceLinkFocusPreviewIntent } from "@/components/json-form/source-link-focus-intent";
import {
useSourceTableHoverController,
type JsonFormSourceLinkActions,
} from "@/components/json-form/source-link-table-hover";
export type JsonFormSourceLink = SourceFieldLink;
const ActiveSourcePathContext = React.createContext<string | null>(null);
const SourceLinkActionsContext =
React.createContext<JsonFormSourceLinkActions | null>(null);
export function JsonFormSourceLinkProvider({
sourceLink,
children,
}: {
sourceLink?: JsonFormSourceLink;
children: React.ReactNode;
}) {
const onSourceHover = sourceLink?.onSourceHover;
const selectSourcePath = sourceLink?.selectSourcePath;
const sourceLinkActions = React.useMemo<JsonFormSourceLinkActions | null>(
() => (onSourceHover ? { onSourceHover, selectSourcePath } : null),
[onSourceHover, selectSourcePath],
);
return (
<SourceLinkActionsContext.Provider value={sourceLinkActions}>
<ActiveSourcePathContext.Provider
value={sourceLink?.activeSourcePath ?? null}
>
{children}
</ActiveSourcePathContext.Provider>
</SourceLinkActionsContext.Provider>
);
}
export function useActiveSourcePath(): string | null {
return React.useContext(ActiveSourcePathContext);
}
export function useSourceLinkActions(): JsonFormSourceLinkActions | null {
return React.useContext(SourceLinkActionsContext);
}
export function useSourceLinkedTableCells({
tableRef,
refreshKey,
}: {
tableRef: React.RefObject<HTMLElement | null>;
refreshKey: unknown;
}) {
return useSourceTableHoverController({
activeSourcePath: useActiveSourcePath(),
refreshKey,
sourceLinkActions: useSourceLinkActions(),
tableRef,
});
}
function shouldSelectSourceFromKeyDown(event: React.KeyboardEvent): boolean {
if (event.defaultPrevented || event.key !== "Enter") return false;
return !(event.target instanceof HTMLTextAreaElement);
}
function shouldPreviewSourceFromPointerMove(
event: React.PointerEvent,
): boolean {
return !event.defaultPrevented && event.pointerType !== "touch";
}
/**
* Wraps a scalar leaf so it reports its source path from explicit pointer or
* keyboard intent. Without a source link, it renders children unchanged.
*/
export function SourceLinkShell({
sourcePath,
children,
}: {
sourcePath: string;
children: React.ReactNode;
}) {
const activeSourcePath = useActiveSourcePath();
const sourceLinkActions = useSourceLinkActions();
const shouldPreviewFocus = useSourceLinkFocusPreviewIntent();
const focusPreviewedRef = React.useRef(false);
const pointerPreviewedRef = React.useRef(false);
if (!sourceLinkActions) return <>{children}</>;
const active = activeSourcePath === sourcePath;
const clearPreviewIfIdle = () => {
if (focusPreviewedRef.current || pointerPreviewedRef.current) return;
sourceLinkActions.onSourceHover(null);
};
return (
<div
data-source-active={active ? "true" : "false"}
data-source-path={sourcePath}
onPointerMove={(event) => {
if (
pointerPreviewedRef.current ||
!shouldPreviewSourceFromPointerMove(event)
) {
return;
}
pointerPreviewedRef.current = true;
sourceLinkActions.onSourceHover(sourcePath);
}}
onPointerLeave={() => {
if (!pointerPreviewedRef.current) return;
pointerPreviewedRef.current = false;
clearPreviewIfIdle();
}}
onFocus={(event) => {
if (!shouldPreviewFocus(event)) return;
focusPreviewedRef.current = true;
sourceLinkActions.onSourceHover(sourcePath);
}}
onBlur={() => {
if (!focusPreviewedRef.current) return;
focusPreviewedRef.current = false;
clearPreviewIfIdle();
}}
onClick={() => sourceLinkActions.selectSourcePath?.(sourcePath)}
onKeyDownCapture={(event) => {
if (shouldSelectSourceFromKeyDown(event)) {
sourceLinkActions.selectSourcePath?.(sourcePath);
}
}}
className={cn(
"rounded-md border px-3 py-2 transition-colors",
active
? "border-primary/40 bg-primary/5"
: "hover:bg-muted/60 border-transparent",
)}
>
{children}
</div>
);
}
components/json-form/table/array-table-body.tsx
"use client";
import * as React from "react";
import { FixedGridRowWindow } from "@/components/ui/fixed-grid-row-window";
import { useFixedRowVirtualization } from "@/components/ui/fixed-grid-virtualization";
import {
TABLE_JUMP_ROW_OVERSCAN,
TABLE_MAX_HEIGHT,
TABLE_ROW_HEIGHT,
TABLE_ROW_OVERSCAN,
} from "@/components/json-form/table/array-table-config";
import {
useArrayTableScrollActivity,
type ArrayTableScrollHandlers,
} from "@/components/json-form/table/array-table-scroll";
type ArrayTableField = { id: string };
export function StaticArrayTableBody({
fields,
scrollHandlers,
renderItem,
}: {
fields: ArrayTableField[];
scrollHandlers: ArrayTableScrollHandlers;
renderItem: (index: number) => React.ReactNode;
}) {
const scrollRef = React.useRef<HTMLDivElement>(null);
useArrayTableScrollActivity(scrollRef, scrollHandlers);
return (
<div
ref={scrollRef}
data-slot="json-form-table-scroll"
className="overflow-y-auto"
style={{ maxHeight: TABLE_MAX_HEIGHT }}
>
<div className="[contain:layout_paint_style]">
{fields.map((entry, index) => (
<React.Fragment key={entry.id}>{renderItem(index)}</React.Fragment>
))}
</div>
</div>
);
}
export function FixedArrayTableBody({
fields,
scrollHandlers,
renderItem,
}: {
fields: ArrayTableField[];
scrollHandlers: ArrayTableScrollHandlers;
renderItem: (index: number, rowTopPx: number) => React.ReactNode;
}) {
const scrollRef = React.useRef<HTMLDivElement>(null);
const { virtualRowWindow, totalRowSize, viewportClientHeight } =
useFixedRowVirtualization({
rowCount: fields.length,
rowSize: TABLE_ROW_HEIGHT,
rowOverscan: TABLE_ROW_OVERSCAN,
jumpRowOverscan: TABLE_JUMP_ROW_OVERSCAN,
scrollRef,
});
useArrayTableScrollActivity(scrollRef, scrollHandlers);
return (
<div
ref={scrollRef}
data-slot="json-form-table-scroll"
className="overflow-y-auto"
style={{ maxHeight: TABLE_MAX_HEIGHT }}
>
<FixedGridRowWindow
totalSize={totalRowSize}
minWidth="100%"
rowMinWidth="100%"
virtualRowWindow={virtualRowWindow}
viewportHeight={viewportClientHeight}
offsetDataSlot="json-form-table-row-offset"
windowDataSlot="json-form-table-row-window"
className="[contain:layout_paint_style]"
>
{virtualRowWindow.items.map((virtualRow) => (
<React.Fragment key={fields[virtualRow.index].id}>
{renderItem(virtualRow.index, virtualRow.start)}
</React.Fragment>
))}
</FixedGridRowWindow>
</div>
);
}
components/json-form/table/array-table-cell-commit.ts
import { type DataCellValueMeta } from "@/components/ui/data-cell";
import { datetimeLocalInputValue } from "@/components/json-form/scalar-control";
import type { Column } from "@/components/json-form/schema-model";
export type SetArrayTableCellValue = (
path: string,
value: unknown,
options: {
shouldDirty: true;
shouldTouch: true;
shouldValidate: true;
},
) => void;
export type CommitArrayTableCellValue = (
value: unknown,
meta?: DataCellValueMeta,
) => void;
export const NO_ARRAY_TABLE_CELL_COMMIT = Symbol("NO_ARRAY_TABLE_CELL_COMMIT");
export function normalizeArrayTableCellValue({
column,
currentValue,
nextValue,
meta,
}: {
column: Column;
currentValue: unknown;
nextValue: unknown;
meta?: DataCellValueMeta;
}): unknown | typeof NO_ARRAY_TABLE_CELL_COMMIT {
let normalizedValue: unknown;
if (column.kind === "enum") {
normalizedValue = nextValue;
} else if (column.kind === "number" || column.kind === "integer") {
if (meta && !meta.isValid) return NO_ARRAY_TABLE_CELL_COMMIT;
normalizedValue =
typeof nextValue === "number"
? nextValue
: nextValue === null && column.nullable && meta?.isEmpty !== false
? null
: undefined;
if (normalizedValue === undefined) return NO_ARRAY_TABLE_CELL_COMMIT;
} else if (column.kind === "boolean") {
normalizedValue = Boolean(nextValue);
} else {
const currentText = currentValue == null ? "" : String(currentValue);
const currentDisplay =
column.schema.format === "date-time"
? datetimeLocalInputValue(currentText)
: currentText;
const nextText = typeof nextValue === "string" ? nextValue : "";
const nextDisplay =
column.schema.format === "date-time"
? datetimeLocalInputValue(nextText)
: nextText;
if (nextDisplay === currentDisplay) return NO_ARRAY_TABLE_CELL_COMMIT;
normalizedValue =
nextDisplay === "" && column.nullable ? null : nextDisplay;
}
return Object.is(currentValue, normalizedValue)
? NO_ARRAY_TABLE_CELL_COMMIT
: normalizedValue;
}
export function commitArrayTableCellValue({
column,
currentValue,
meta,
nextValue,
path,
setValue,
}: {
column: Column;
currentValue: unknown;
meta?: DataCellValueMeta;
nextValue: unknown;
path: string;
setValue: SetArrayTableCellValue;
}) {
const normalizedValue = normalizeArrayTableCellValue({
column,
currentValue,
nextValue,
meta,
});
if (normalizedValue === NO_ARRAY_TABLE_CELL_COMMIT) return;
setValue(path, normalizedValue, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
}
components/json-form/table/array-table-cell-model.ts
import { labelFor, type Column } from "@/components/json-form/schema-model";
import {
dataCellKindForColumn,
formatArrayTableCellValue,
type ArrayTableDataCellKind,
} from "@/components/json-form/table/array-table-format";
export type ArrayTableCellModel = {
path: string;
sourcePath: string;
label: string;
displayText: string;
kind: ArrayTableDataCellKind;
value: unknown;
isEnum: boolean;
sourceLinked: boolean;
};
export function createArrayTableCellModel({
path,
sourcePath,
column,
value,
sourceLinked,
}: {
path: string;
sourcePath: string;
column: Column;
value: unknown;
sourceLinked: boolean;
}): ArrayTableCellModel {
const isEnum = column.kind === "enum";
return {
path,
sourcePath,
label: labelFor(column.key, column.schema),
displayText: formatArrayTableCellValue({ value, column }),
kind: dataCellKindForColumn(column),
value,
isEnum,
sourceLinked,
};
}
components/json-form/table/array-table-cell-props.ts
import { cn } from "@/lib/utils";
import type { ArrayTableCellModel } from "@/components/json-form/table/array-table-cell-model";
export function arrayTableCellClassName({
isEditing,
model,
}: {
isEditing: boolean;
model: ArrayTableCellModel;
}): string {
return cn(
"min-w-0 rounded text-sm data-[source-active=true]:bg-primary/5 data-[source-active=true]:ring-1 data-[source-active=true]:ring-primary/30",
!isEditing
? "hover:bg-background focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring/30"
: "px-1 py-0.5",
model.sourceLinked && isEditing && "hover:bg-muted/55",
);
}
export function arrayTableCellProps(
model: ArrayTableCellModel,
{ isEditing = false }: { isEditing?: boolean } = {},
) {
return {
"data-slot": "data-cell",
"data-table-cell": "",
"data-source-path": model.sourceLinked ? model.sourcePath : undefined,
className: arrayTableCellClassName({ isEditing, model }),
};
}
components/json-form/table/array-table-cell.tsx
"use client";
import * as React from "react";
import { DataCell } from "@/components/ui/data-cell";
import { useJsonFormReadOnly } from "@/components/json-form/read-only";
import type { Column } from "@/components/json-form/schema-model";
import {
useArrayTableCellActive,
type ArrayTableActiveCellStore,
} from "@/components/json-form/table/array-table-active-cell-store";
import {
commitArrayTableCellValue,
type CommitArrayTableCellValue,
type SetArrayTableCellValue,
} from "@/components/json-form/table/array-table-cell-commit";
import { createArrayTableDataCellProps } from "@/components/json-form/table/array-table-data-cell-props";
import type { ArrayTableCellModel } from "@/components/json-form/table/array-table-cell-model";
function ArrayTableCellContent({
model,
column,
activeCellStore,
setValue,
closeEditor,
}: {
model: ArrayTableCellModel;
column: Column;
activeCellStore: ArrayTableActiveCellStore;
setValue: SetArrayTableCellValue;
closeEditor: () => void;
}) {
const readOnly = useJsonFormReadOnly();
const isEditing = useArrayTableCellActive(activeCellStore, model.path);
const commitValue = React.useCallback<CommitArrayTableCellValue>(
(nextValue, meta) => {
commitArrayTableCellValue({
column,
currentValue: model.value,
meta,
nextValue,
path: model.path,
setValue,
});
},
[column, model.path, model.value, setValue],
);
return (
<DataCell
{...createArrayTableDataCellProps({
column,
commitValue,
isEditing,
model,
onEditingEnd: closeEditor,
readOnly,
})}
/>
);
}
export const ArrayTableCell = React.memo(
ArrayTableCellContent,
(previous, next) =>
previous.model.path === next.model.path &&
previous.model.sourcePath === next.model.sourcePath &&
previous.model.label === next.model.label &&
previous.model.displayText === next.model.displayText &&
previous.model.kind === next.model.kind &&
Object.is(previous.model.value, next.model.value) &&
previous.model.isEnum === next.model.isEnum &&
previous.model.sourceLinked === next.model.sourceLinked &&
previous.column === next.column &&
previous.activeCellStore === next.activeCellStore &&
previous.setValue === next.setValue &&
previous.closeEditor === next.closeEditor,
);
ArrayTableCell.displayName = "ArrayTableCell";
components/json-form/table/array-table-config.ts
export const TABLE_MAX_HEIGHT = 420;
export const TABLE_ROW_HEIGHT = 44;
export const TABLE_SCROLL_THRESHOLD = Math.floor(
TABLE_MAX_HEIGHT / TABLE_ROW_HEIGHT,
);
export const TABLE_VIRTUALIZE_THRESHOLD = TABLE_SCROLL_THRESHOLD * 3;
export const TABLE_ROW_OVERSCAN = 3;
export const TABLE_JUMP_ROW_OVERSCAN = 6;
components/json-form/table/array-table-format.ts
import {
enumLabel,
enumValueEquals,
} from "@/components/json-form/scalar-control";
import type { Column } from "@/components/json-form/schema-model";
export type ArrayTableDataCellKind =
| "text"
| "number"
| "integer"
| "boolean"
| "select"
| "date"
| "time"
| "date-time";
export function formatArrayTableCellValue({
value,
column,
}: {
value: unknown;
column: Column;
}) {
if (value == null || value === "") return "—";
if (column.kind === "enum") {
const option = column.schema.enum?.find((candidate) =>
enumValueEquals(candidate, value),
);
return option === undefined ? enumLabel(value) : enumLabel(option);
}
if (typeof value === "number")
return Number.isFinite(value) ? String(value) : "—";
if (typeof value === "boolean") return value ? "True" : "False";
return String(value);
}
export function dataCellKindForColumn(column: Column): ArrayTableDataCellKind {
if (column.kind === "enum") return "select";
if (column.kind === "number" || column.kind === "integer") return column.kind;
if (column.kind === "boolean") return "boolean";
if (column.schema.format === "date-time") return "date-time";
if (column.schema.format === "date") return "date";
if (column.schema.format === "time") return "time";
return "text";
}
components/json-form/table/array-table-row.tsx
"use client";
import * as React from "react";
import { X } from "lucide-react";
import { useFormContext, useWatch } from "react-hook-form";
import { cn } from "@/lib/utils";
import { getFixedGridRowStyle } from "@/components/ui/fixed-grid-row-style";
import {
encodeJsonFormKey,
joinJsonFormPath,
joinJsonSourcePath,
} from "@/components/json-form/path-codec";
import { useJsonFormReadOnly } from "@/components/json-form/read-only";
import type { Column } from "@/components/json-form/schema-model";
import type { ArrayTableActiveCellStore } from "@/components/json-form/table/array-table-active-cell-store";
import { ArrayTableCell } from "@/components/json-form/table/array-table-cell";
import { createArrayTableCellModel } from "@/components/json-form/table/array-table-cell-model";
import { TABLE_ROW_HEIGHT } from "@/components/json-form/table/array-table-config";
export const ArrayTableRow = React.memo(function ArrayTableRow({
name,
sourcePath,
index,
isLastRow,
columns,
remove,
canRemove,
sourceLinked,
template,
rowTopPx,
activeCellStore,
}: {
name: string;
sourcePath: string;
index: number;
isLastRow: boolean;
columns: Column[];
remove: (index: number) => void;
canRemove: boolean;
sourceLinked: boolean;
template: string;
rowTopPx?: number;
activeCellStore: ArrayTableActiveCellStore;
}) {
const readOnly = useJsonFormReadOnly();
const { control, getValues, setValue } = useFormContext();
const rowPath = joinJsonFormPath(name, index);
const rowSourcePath = joinJsonSourcePath(sourcePath, index);
const watchedRowValue = useWatch({
control,
name: rowPath,
}) as Record<string, unknown> | undefined;
const rowValue = (watchedRowValue ?? getValues(rowPath)) as
| Record<string, unknown>
| undefined;
const rowStyle = React.useMemo(
() =>
rowTopPx === undefined
? { gridTemplateColumns: template }
: getFixedGridRowStyle({
gridTemplate: template,
rowHeight: TABLE_ROW_HEIGHT,
top: rowTopPx,
}),
[rowTopPx, template],
);
const closeEditor = React.useCallback(
() => activeCellStore.setActivePath(null),
[activeCellStore],
);
return (
<div
data-index={index}
className={cn(
"hover:bg-muted/25 grid items-center gap-1 border-b px-2 py-1 [contain:layout_paint_style]",
isLastRow && "border-b-0",
)}
style={rowStyle}
>
{columns.map((column) => {
const path = joinJsonFormPath(rowPath, column.key);
const value = rowValue?.[encodeJsonFormKey(column.key)];
return (
<ArrayTableCell
key={column.key}
model={createArrayTableCellModel({
path,
sourcePath: joinJsonSourcePath(rowSourcePath, column.key),
column,
value,
sourceLinked,
})}
column={column}
activeCellStore={activeCellStore}
setValue={setValue}
closeEditor={closeEditor}
/>
);
})}
{readOnly ? null : (
<button
type="button"
className="text-muted-foreground hover:border-border hover:text-destructive focus-visible:ring-ring flex size-8 items-center justify-center rounded-md border border-transparent text-base leading-none transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
onClick={() => remove(index)}
aria-label="Remove row"
disabled={!canRemove}
>
<X className="size-4" />
</button>
)}
</div>
);
});
components/json-form/table/array-table-scroll.ts
"use client";
import * as React from "react";
import { useMountEffect } from "@/hooks/use-mount-effect";
export type ArrayTableScrollHandlers = {
onScrollStart: () => void;
onScrollMove: () => void;
onScrollEnd: () => void;
};
export function useArrayTableScrollActivity(
scrollRef: React.RefObject<HTMLElement | null>,
{ onScrollStart, onScrollMove, onScrollEnd }: ArrayTableScrollHandlers,
) {
const isScrollingRef = React.useRef(false);
const scrollEndTimeoutRef = React.useRef(0);
const callbacksRef = React.useRef({
onScrollStart,
onScrollMove,
onScrollEnd,
});
callbacksRef.current = { onScrollStart, onScrollMove, onScrollEnd };
const handleScroll = React.useCallback(() => {
if (!isScrollingRef.current) {
isScrollingRef.current = true;
callbacksRef.current.onScrollStart();
}
callbacksRef.current.onScrollMove();
window.clearTimeout(scrollEndTimeoutRef.current);
scrollEndTimeoutRef.current = window.setTimeout(() => {
isScrollingRef.current = false;
callbacksRef.current.onScrollEnd();
}, 120);
}, []);
useMountEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
scrollElement.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.clearTimeout(scrollEndTimeoutRef.current);
scrollElement.removeEventListener("scroll", handleScroll);
};
});
}
components/json-form/table/array-table.tsx
"use client";
import * as React from "react";
import { getFixedGridCanvasStyle } from "@/components/ui/fixed-grid-layout";
import { WithDescription } from "@/components/json-form/disclosure";
import { labelFor, type Column } from "@/components/json-form/schema-model";
import { useSourceLinkedTableCells } from "@/components/json-form/source-link";
import { useJsonFormReadOnly } from "@/components/json-form/read-only";
import {
createArrayTableActiveCellStore,
type ArrayTableActiveCellStore,
} from "@/components/json-form/table/array-table-active-cell-store";
import {
FixedArrayTableBody,
StaticArrayTableBody,
} from "@/components/json-form/table/array-table-body";
import {
TABLE_SCROLL_THRESHOLD,
TABLE_VIRTUALIZE_THRESHOLD,
} from "@/components/json-form/table/array-table-config";
import { ArrayTableRow } from "@/components/json-form/table/array-table-row";
export function ArrayTable({
name,
sourcePath,
fields,
remove,
canRemove,
columns,
}: {
name: string;
sourcePath: string;
fields: { id: string }[];
remove: (index: number) => void;
canRemove: boolean;
columns: Column[];
}) {
const readOnly = useJsonFormReadOnly();
const template = `${columns.map(() => "minmax(9rem, 1fr)").join(" ")}${
readOnly ? "" : " 2.25rem"
}`;
const minWidth = columns.length * 150 + (readOnly ? 0 : 36);
const activeCellStoreRef = React.useRef<ArrayTableActiveCellStore | null>(
null,
);
if (!activeCellStoreRef.current) {
activeCellStoreRef.current = createArrayTableActiveCellStore();
}
const activeCellStore = activeCellStoreRef.current;
const tableRef = React.useRef<HTMLDivElement>(null);
const sourceTable = useSourceLinkedTableCells({
tableRef,
refreshKey: fields.length,
});
const sourceLinked = sourceTable.sourceLinked;
const virtualize = fields.length > TABLE_VIRTUALIZE_THRESHOLD;
const scrollHandlers = React.useMemo(
() => ({
onScrollStart: sourceTable.handleScrollStart,
onScrollMove: sourceTable.handleScrollMove,
onScrollEnd: sourceTable.handleScrollEnd,
}),
[sourceTable],
);
const handleTableClickCapture = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const table = tableRef.current;
const activeElement = table?.ownerDocument.activeElement;
if (
!(activeElement instanceof HTMLElement) ||
activeElement.dataset.tableCellEditor !== "true" ||
!table?.contains(activeElement) ||
activeElement === event.target ||
activeElement.contains(event.target as Node)
) {
return;
}
activeElement.blur();
},
[],
);
const handleTableClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const cell = sourceTable.getCellFromTarget(event.target);
if (!cell) return;
sourceTable.selectCellSource(cell);
if (cell.dataset.tableCellEditable !== "true") return;
const path = cell.dataset.tableCellPath;
if (path) activeCellStore.setActivePath(path);
},
[activeCellStore, sourceTable],
);
const handleTableKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
const cell = sourceTable.getCellFromTarget(event.target);
if (!cell || cell.dataset.tableCellEditable !== "true") return;
const path = cell.dataset.tableCellPath;
if (!path) return;
sourceTable.selectCellSource(cell);
event.preventDefault();
activeCellStore.setActivePath(path);
},
[activeCellStore, sourceTable],
);
const renderRow = React.useCallback(
(index: number, rowTopPx?: number) => (
<ArrayTableRow
name={name}
sourcePath={sourcePath}
index={index}
isLastRow={index === fields.length - 1}
columns={columns}
remove={remove}
canRemove={canRemove}
sourceLinked={sourceLinked}
template={template}
rowTopPx={rowTopPx}
activeCellStore={activeCellStore}
/>
),
[
activeCellStore,
name,
sourcePath,
fields.length,
columns,
remove,
canRemove,
sourceLinked,
template,
],
);
return (
<div
ref={tableRef}
onClickCapture={handleTableClickCapture}
onClick={handleTableClick}
onKeyDown={handleTableKeyDown}
onPointerMove={sourceLinked ? sourceTable.handlePointerMove : undefined}
onPointerLeave={sourceLinked ? sourceTable.handlePointerLeave : undefined}
onFocus={sourceTable.handleFocus}
onBlur={sourceTable.handleBlur}
className="bg-background overflow-x-auto"
>
<div style={getFixedGridCanvasStyle({ minWidth })}>
<div
className="bg-muted/35 grid h-9 items-center gap-1 border-b px-2"
style={{ gridTemplateColumns: template }}
>
{columns.map((column) => (
<div
key={column.key}
className="text-muted-foreground flex min-w-0 items-center gap-1 px-2 text-xs font-medium"
>
<WithDescription text={column.schema.description}>
<span className="truncate">
{labelFor(column.key, column.schema)}
</span>
</WithDescription>
{column.required ? (
<span className="text-destructive">*</span>
) : null}
</div>
))}
{readOnly ? null : <span className="sr-only">Actions</span>}
</div>
{virtualize ? (
<FixedArrayTableBody
fields={fields}
scrollHandlers={scrollHandlers}
renderItem={renderRow}
/>
) : fields.length > TABLE_SCROLL_THRESHOLD ? (
<StaticArrayTableBody
fields={fields}
scrollHandlers={scrollHandlers}
renderItem={renderRow}
/>
) : (
<div>
{fields.map((entry, index) => (
<React.Fragment key={entry.id}>{renderRow(index)}</React.Fragment>
))}
</div>
)}
</div>
</div>
);
}
components/json-form/virtual-list.tsx
"use client";
import * as React from "react";
import { FixedGridRowWindow } from "@/components/ui/fixed-grid-row-window";
import { useMeasuredRowVirtualization } from "@/components/ui/measured-row-virtualization";
export function VirtualList({
fields,
estimateSize,
renderItem,
maxHeight = 480,
gap = 0,
}: {
fields: { id: string }[];
estimateSize: number;
renderItem: (index: number) => React.ReactNode;
maxHeight?: number;
gap?: number;
}) {
const parentRef = React.useRef<HTMLDivElement>(null);
const getItemKey = React.useCallback(
(index: number) => fields[index]?.id ?? index,
[fields],
);
const { measureRow, totalSize, viewportClientHeight, virtualRowWindow } =
useMeasuredRowVirtualization({
count: fields.length,
estimateSize: estimateSize + gap,
getItemKey,
overscan: 8,
scrollRef: parentRef,
});
return (
<div ref={parentRef} style={{ maxHeight }} className="overflow-y-auto">
<FixedGridRowWindow
data-slot="json-form-virtual-list-spacer"
totalSize={totalSize}
virtualRowWindow={virtualRowWindow}
viewportHeight={viewportClientHeight}
offsetDataSlot="json-form-virtual-list-row-offset"
windowDataSlot="json-form-virtual-list-row-window"
>
{virtualRowWindow.items.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={(element) => measureRow(virtualRow.index, element)}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
paddingBottom: gap,
}}
>
{renderItem(virtualRow.index)}
</div>
))}
</FixedGridRowWindow>
</div>
);
}
registry/new-york-v4/ui/measured-row-virtualization.ts
"use client";
import * as React from "react";
import { useKeyedLayoutEffect } from "@/hooks/use-keyed-layout-effect";
import { joinEffectKey } from "@/lib/effect-key";
const DEFAULT_INITIAL_VIEWPORT_HEIGHT = 600;
const DEFAULT_OVERSCAN = 6;
const MAX_VIRTUAL_ITEMS = 1_000;
export interface MeasuredRowVirtualItem {
index: number;
key: React.Key;
start: number;
size: number;
end: number;
}
export interface MeasuredRowVirtualItemWindow {
end: number;
items: MeasuredRowVirtualItem[];
size: number;
start: number;
}
export interface MeasuredRowOffsets {
starts: number[];
totalSize: number;
}
export interface MeasuredRowScrollTarget {
align?: "start" | "center" | "end";
behavior?: ScrollBehavior;
}
export function buildMeasuredRowOffsets({
rowSizes,
paddingStart = 0,
paddingEnd = 0,
}: {
rowSizes: readonly number[];
paddingStart?: number;
paddingEnd?: number;
}): MeasuredRowOffsets {
const starts: number[] = [];
let offset = safeSize(paddingStart);
for (const rowSize of rowSizes) {
starts.push(offset);
offset += safeSize(rowSize);
}
return {
starts,
totalSize: offset + safeSize(paddingEnd),
};
}
export function getMeasuredRowVirtualItems({
getItemKey,
maxItems = MAX_VIRTUAL_ITEMS,
offsets,
overscan = DEFAULT_OVERSCAN,
rowSizes,
scrollTop,
viewportHeight,
}: {
getItemKey?: (index: number) => React.Key;
maxItems?: number;
offsets: MeasuredRowOffsets;
overscan?: number;
rowSizes: readonly number[];
scrollTop: number;
viewportHeight: number;
}): MeasuredRowVirtualItem[] {
const count = rowSizes.length;
if (count === 0) return [];
const safeScrollTop = safeOffset(scrollTop);
const safeViewportHeight = Math.max(1, safeSize(viewportHeight));
const visibleStartIndex = findFirstRowEndingAfter({
offset: safeScrollTop,
rowSizes,
starts: offsets.starts,
});
const visibleEndExclusive = findFirstRowStartingAtOrAfter({
offset: safeScrollTop + safeViewportHeight,
starts: offsets.starts,
});
const start = Math.max(0, visibleStartIndex - safeCount(overscan));
const end = Math.min(
count,
Math.max(visibleStartIndex + 1, visibleEndExclusive) + safeCount(overscan),
);
const [cappedStart, cappedEnd] = capMeasuredRowRange({
count,
end,
maxItems,
start,
visibleEnd: Math.min(
count,
Math.max(visibleStartIndex + 1, visibleEndExclusive),
),
visibleStart: visibleStartIndex,
});
return Array.from({ length: cappedEnd - cappedStart }, (_, offset) => {
const index = cappedStart + offset;
const rowStart = offsets.starts[index] ?? 0;
const size = safeSize(rowSizes[index]);
return {
index,
key: getItemKey?.(index) ?? index,
start: rowStart,
size,
end: rowStart + size,
};
});
}
export function measuredRowVirtualItemWindow(
items: readonly MeasuredRowVirtualItem[],
): MeasuredRowVirtualItemWindow {
const start = items[0]?.start ?? 0;
const end = items.length ? items[items.length - 1]!.end : start;
return {
end,
items: items.map((item) => ({
...item,
start: item.start - start,
end: item.end - start,
})),
size: Math.max(0, end - start),
start,
};
}
export function measuredRowScrollTopForIndex({
align = "center",
index,
offsets,
rowSizes,
viewportHeight,
}: {
align?: NonNullable<MeasuredRowScrollTarget["align"]>;
index: number;
offsets: MeasuredRowOffsets;
rowSizes: readonly number[];
viewportHeight: number;
}) {
if (!Number.isSafeInteger(index) || index < 0) return 0;
const rowStart = offsets.starts[index];
if (rowStart == null) return 0;
const rowSize = safeSize(rowSizes[index]);
const safeViewportHeight = safeSize(viewportHeight);
if (align === "end")
return Math.max(0, rowStart - safeViewportHeight + rowSize);
if (align === "center") {
return Math.max(0, rowStart - safeViewportHeight / 2 + rowSize / 2);
}
return Math.max(0, rowStart);
}
export function useMeasuredRowVirtualization({
count,
estimateSize,
getItemKey,
initialViewportHeight = DEFAULT_INITIAL_VIEWPORT_HEIGHT,
overscan = DEFAULT_OVERSCAN,
paddingEnd = 0,
paddingStart = 0,
scrollRef,
}: {
count: number;
estimateSize: number;
getItemKey?: (index: number) => React.Key;
initialViewportHeight?: number;
overscan?: number;
paddingEnd?: number;
paddingStart?: number;
scrollRef: React.RefObject<HTMLElement | null>;
}) {
const safeCount = measuredRowCount(count);
const safeEstimateSize = Math.max(1, safeSize(estimateSize));
const [version, forceVersion] = React.useReducer(
(current: number) => current + 1,
0,
);
const [scrollElement, setScrollElement] = React.useState<HTMLElement | null>(
scrollRef.current,
);
const [viewport, setViewport] = React.useState({
clientHeight: 0,
scrollTop: 0,
});
const measuredSizesRef = React.useRef(new Map<number, number>());
const rowElementsRef = React.useRef(new Map<number, HTMLElement>());
const observedIndexesRef = React.useRef(new Map<HTMLElement, number>());
const rowObserverRef = React.useRef<ResizeObserver | null>(null);
const offsetsRef = React.useRef<MeasuredRowOffsets>({
starts: [],
totalSize: 0,
});
const rowSizesRef = React.useRef<number[]>([]);
const viewportRef = React.useRef(viewport);
viewportRef.current = viewport;
const updateMeasuredSize = React.useCallback(
(index: number, nextSize: number | null) => {
if (!Number.isSafeInteger(index) || index < 0 || index >= safeCount) {
return;
}
if (nextSize == null) return;
const safeNextSize = safeSize(nextSize);
if (safeNextSize <= 0) return;
const measuredSizes = measuredSizesRef.current;
const previousSize = measuredSizes.get(index) ?? safeEstimateSize;
if (previousSize === safeNextSize) return;
const previousOffsets = offsetsRef.current;
const previousStart = previousOffsets.starts[index] ?? 0;
const previousEnd = previousStart + previousSize;
const delta = safeNextSize - previousSize;
const currentScrollTop = viewportRef.current.scrollTop;
measuredSizes.set(index, safeNextSize);
const currentScrollElement = scrollRef.current;
if (
currentScrollElement &&
previousEnd <= currentScrollTop &&
delta !== 0
) {
currentScrollElement.scrollTop = Math.max(
0,
currentScrollElement.scrollTop + delta,
);
setViewport((current) => ({
...current,
scrollTop: safeOffset(currentScrollElement.scrollTop),
}));
}
forceVersion();
},
[safeCount, safeEstimateSize, scrollRef],
);
const measureRow = React.useCallback(
(index: number, element: HTMLElement | null) => {
if (!Number.isSafeInteger(index) || index < 0) return;
const rowElements = rowElementsRef.current;
const previousElement = rowElements.get(index);
if (previousElement && previousElement !== element) {
rowObserverRef.current?.unobserve(previousElement);
observedIndexesRef.current.delete(previousElement);
rowElements.delete(index);
}
if (!element) return;
rowElements.set(index, element);
observedIndexesRef.current.set(element, index);
rowObserverRef.current?.observe(element);
updateMeasuredSize(index, readElementHeight(element));
},
[updateMeasuredSize],
);
useKeyedLayoutEffect(
joinEffectKey([scrollRef, scrollRef.current, scrollElement]),
() => {
const nextScrollElement = scrollRef.current;
if (nextScrollElement !== scrollElement) {
setScrollElement(nextScrollElement);
}
},
);
useKeyedLayoutEffect(
joinEffectKey([scrollElement, updateMeasuredSize]),
() => {
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = observedIndexesRef.current.get(
entry.target as HTMLElement,
);
if (index == null) continue;
updateMeasuredSize(index, readResizeEntryHeight(entry));
}
});
rowObserverRef.current = observer;
for (const element of rowElementsRef.current.values()) {
observer.observe(element);
}
return () => {
observer.disconnect();
if (rowObserverRef.current === observer) rowObserverRef.current = null;
};
},
);
useKeyedLayoutEffect(
joinEffectKey([scrollElement, initialViewportHeight]),
() => {
if (!scrollElement) {
setViewport({ clientHeight: 0, scrollTop: 0 });
return;
}
let frame = 0;
const readViewport = () => {
frame = 0;
const next = {
clientHeight: safeSize(scrollElement.clientHeight),
scrollTop: safeOffset(scrollElement.scrollTop),
};
setViewport((current) =>
current.clientHeight === next.clientHeight &&
current.scrollTop === next.scrollTop
? current
: next,
);
};
const scheduleRead = () => {
if (frame) return;
frame = requestFrame(readViewport);
};
readViewport();
scrollElement.addEventListener("scroll", scheduleRead, { passive: true });
const observer =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(scheduleRead)
: null;
observer?.observe(scrollElement);
return () => {
if (frame) cancelFrame(frame);
scrollElement.removeEventListener("scroll", scheduleRead);
observer?.disconnect();
};
},
);
const rowSizes = React.useMemo(
() => {
void version;
return Array.from(
{ length: safeCount },
(_, index) => measuredSizesRef.current.get(index) ?? safeEstimateSize,
);
},
[safeCount, safeEstimateSize, version],
);
const offsets = React.useMemo(
() =>
buildMeasuredRowOffsets({
paddingEnd,
paddingStart,
rowSizes,
}),
[paddingEnd, paddingStart, rowSizes],
);
const viewportHeight =
viewport.clientHeight ||
safeSize(initialViewportHeight) ||
safeEstimateSize;
const virtualRows = React.useMemo(
() =>
getMeasuredRowVirtualItems({
getItemKey,
offsets,
overscan,
rowSizes,
scrollTop: viewport.scrollTop,
viewportHeight,
}),
[
getItemKey,
offsets,
overscan,
rowSizes,
viewport.scrollTop,
viewportHeight,
],
);
const virtualRowWindow = React.useMemo(
() => measuredRowVirtualItemWindow(virtualRows),
[virtualRows],
);
const scrollToIndex = React.useCallback(
(index: number, options?: MeasuredRowScrollTarget) => {
const currentScrollElement = scrollRef.current;
if (!currentScrollElement) return;
const top = measuredRowScrollTopForIndex({
align: options?.align ?? "center",
index,
offsets: offsetsRef.current,
rowSizes: rowSizesRef.current,
viewportHeight: currentScrollElement.clientHeight || viewportHeight,
});
if (typeof currentScrollElement.scrollTo === "function") {
currentScrollElement.scrollTo({
behavior: options?.behavior ?? "smooth",
top,
});
} else {
currentScrollElement.scrollTop = top;
}
setViewport((current) => ({
...current,
scrollTop: safeOffset(currentScrollElement.scrollTop),
}));
},
[scrollRef, viewportHeight],
);
offsetsRef.current = offsets;
rowSizesRef.current = rowSizes;
return {
measureRow,
scrollToIndex,
totalSize: offsets.totalSize,
viewportClientHeight: viewportHeight,
virtualRowWindow,
virtualRows,
};
}
function capMeasuredRowRange({
count,
end,
maxItems,
start,
visibleEnd,
visibleStart,
}: {
count: number;
end: number;
maxItems: number;
start: number;
visibleEnd: number;
visibleStart: number;
}) {
const safeMaxItems = Math.max(1, safeCount(maxItems));
if (end - start <= safeMaxItems) return [start, end] as const;
const visibleCount = Math.min(
safeMaxItems,
Math.max(1, visibleEnd - visibleStart),
);
const leadingBudget = Math.floor((safeMaxItems - visibleCount) / 2);
let cappedStart = Math.max(start, visibleStart - leadingBudget);
let cappedEnd = Math.min(end, cappedStart + safeMaxItems);
if (cappedEnd - cappedStart < safeMaxItems) {
cappedStart = Math.max(start, cappedEnd - safeMaxItems);
}
if (cappedEnd < visibleEnd) {
cappedEnd = Math.min(end, visibleEnd);
cappedStart = Math.max(start, cappedEnd - safeMaxItems);
}
cappedEnd = Math.min(count, cappedEnd);
return [cappedStart, cappedEnd] as const;
}
function findFirstRowEndingAfter({
offset,
rowSizes,
starts,
}: {
offset: number;
rowSizes: readonly number[];
starts: readonly number[];
}) {
let low = 0;
let high = rowSizes.length - 1;
let result = rowSizes.length - 1;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const end = (starts[middle] ?? 0) + safeSize(rowSizes[middle]);
if (end > offset) {
result = middle;
high = middle - 1;
} else {
low = middle + 1;
}
}
return result;
}
function findFirstRowStartingAtOrAfter({
offset,
starts,
}: {
offset: number;
starts: readonly number[];
}) {
let low = 0;
let high = starts.length - 1;
let result = starts.length;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
if ((starts[middle] ?? 0) >= offset) {
result = middle;
high = middle - 1;
} else {
low = middle + 1;
}
}
return result;
}
function readElementHeight(element: HTMLElement) {
const rectHeight = element.getBoundingClientRect().height;
if (Number.isFinite(rectHeight) && rectHeight > 0) return rectHeight;
return element.offsetHeight;
}
function readResizeEntryHeight(entry: ResizeObserverEntry) {
const borderBoxSize = entry.borderBoxSize;
const boxSize = Array.isArray(borderBoxSize)
? borderBoxSize[0]
: borderBoxSize;
if (boxSize && Number.isFinite(boxSize.blockSize) && boxSize.blockSize > 0) {
return boxSize.blockSize;
}
if (
Number.isFinite(entry.contentRect.height) &&
entry.contentRect.height > 0
) {
return entry.contentRect.height;
}
return readElementHeight(entry.target as HTMLElement);
}
function requestFrame(callback: FrameRequestCallback) {
if (typeof window !== "undefined" && window.requestAnimationFrame) {
return window.requestAnimationFrame(callback);
}
return setTimeout(() => callback(performance.now()), 0) as unknown as number;
}
function cancelFrame(frame: number) {
if (typeof window !== "undefined" && window.cancelAnimationFrame) {
window.cancelAnimationFrame(frame);
return;
}
clearTimeout(frame);
}
function measuredRowCount(count: number) {
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
}
function safeCount(value: number) {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
function safeOffset(value: number) {
return Number.isFinite(value) && value > 0 ? value : 0;
}
function safeSize(value: number | undefined) {
return Number.isFinite(value) && value != null && value > 0 ? value : 0;
}
components/json-form/table/array-table-active-cell-store.ts
"use client";
import * as React from "react";
export type ArrayTableActiveCellStore = {
getSnapshot: () => string | null;
setActivePath: (path: string | null) => void;
subscribe: (listener: () => void) => () => void;
};
export function createArrayTableActiveCellStore(): ArrayTableActiveCellStore {
let activePath: string | null = null;
const listeners = new Set<() => void>();
return {
getSnapshot: () => activePath,
setActivePath: (path) => {
if (activePath === path) return;
activePath = path;
listeners.forEach((listener) => listener());
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
export function useArrayTableCellActive(
store: ArrayTableActiveCellStore,
path: string,
): boolean {
return React.useSyncExternalStore(
store.subscribe,
() => store.getSnapshot() === path,
() => false,
);
}
components/json-form/table/array-table-data-cell-props.ts
import type {
DataCellProps,
DataCellSelectOption,
} from "@/components/ui/data-cell";
import {
dataCellNumberValue,
dataCellTextValue,
enumLabel,
enumValueEquals,
} from "@/components/json-form/scalar-control";
import { NULL_SELECT_VALUE } from "@/components/json-form/scalar/enum-control";
import type { Column } from "@/components/json-form/schema-model";
import type { CommitArrayTableCellValue } from "@/components/json-form/table/array-table-cell-commit";
import type { ArrayTableCellModel } from "@/components/json-form/table/array-table-cell-model";
import { arrayTableCellProps } from "@/components/json-form/table/array-table-cell-props";
type ArrayTableDataCellSharedProps = {
active: boolean;
autoFocus: boolean;
editable: boolean;
name: string;
onCommit: CommitArrayTableCellValue;
onEditingEnd: () => void;
role?: "button";
tabIndex?: 0;
"aria-label": string;
"data-table-cell-editable"?: "true";
"data-table-cell-editor"?: "true";
"data-table-cell-path"?: string;
};
export function createArrayTableDataCellProps({
column,
commitValue,
isEditing,
model,
onEditingEnd,
readOnly,
}: {
column: Column;
commitValue: CommitArrayTableCellValue;
isEditing: boolean;
model: ArrayTableCellModel;
onEditingEnd: () => void;
readOnly: boolean;
}): DataCellProps {
const sharedProps = arrayTableDataCellSharedProps({
commitValue,
isEditing,
model,
onEditingEnd,
readOnly,
});
if (column.kind === "enum") {
return arrayTableSelectDataCellProps({
column,
commitValue,
model,
sharedProps,
});
}
if (model.kind === "number" || model.kind === "integer") {
return {
...arrayTableCellProps(model, { isEditing }),
...sharedProps,
kind: model.kind,
value: dataCellNumberValue(model.value),
formatValue: () => model.displayText,
placeholder: "",
};
}
if (model.kind === "boolean") {
return {
...arrayTableCellProps(model, { isEditing }),
...sharedProps,
kind: "boolean",
value:
model.value === null || model.value === undefined
? null
: Boolean(model.value),
};
}
if (model.kind === "date") {
return {
...arrayTableCellProps(model, { isEditing }),
...sharedProps,
kind: "date",
value: dataCellTextValue(model.value),
formatValue: () => model.displayText,
placeholder: "",
};
}
if (model.kind === "time") {
return {
...arrayTableCellProps(model, { isEditing }),
...sharedProps,
kind: "time",
value: dataCellTextValue(model.value),
formatValue: () => model.displayText,
placeholder: "",
};
}
if (model.kind === "date-time") {
return {
...arrayTableCellProps(model, { isEditing }),
...sharedProps,
kind: "date-time",
value: dataCellTextValue(model.value),
formatValue: () => model.displayText,
placeholder: "",
};
}
return {
...arrayTableCellProps(model, { isEditing }),
...sharedProps,
kind: "text",
value: dataCellTextValue(model.value),
formatValue: () => model.displayText,
placeholder: "",
};
}
function arrayTableDataCellSharedProps({
commitValue,
isEditing,
model,
onEditingEnd,
readOnly,
}: {
commitValue: CommitArrayTableCellValue;
isEditing: boolean;
model: ArrayTableCellModel;
onEditingEnd: () => void;
readOnly: boolean;
}): ArrayTableDataCellSharedProps {
// A read-only cell keeps its source-link affordances and drops every editing
// one: no activation target, no focus stop, and no editor to open.
const editing = isEditing && !readOnly;
return {
active: editing,
editable: editing,
autoFocus: editing,
name: model.path,
onCommit: commitValue,
onEditingEnd,
role: editing || readOnly ? undefined : "button",
tabIndex: readOnly ? undefined : 0,
"aria-label": `${model.label} ${model.displayText}`,
"data-table-cell-editable": editing || readOnly ? undefined : "true",
"data-table-cell-editor": editing ? "true" : undefined,
"data-table-cell-path": editing || readOnly ? undefined : model.path,
};
}
function arrayTableSelectDataCellProps({
column,
commitValue,
model,
sharedProps,
}: {
column: Column;
commitValue: CommitArrayTableCellValue;
model: ArrayTableCellModel;
sharedProps: ArrayTableDataCellSharedProps;
}): DataCellProps {
return {
...arrayTableCellProps(model, { isEditing: sharedProps.active }),
...sharedProps,
kind: "select",
value: arrayTableSelectValue({
column,
value: model.value,
}),
selectOptions: arrayTableSelectOptions(column),
placeholder: "Select...",
formatValue: () => model.displayText,
onCommit: (value, meta) => {
commitValue(
arrayTableSelectCommitValue({
column,
value,
}),
meta,
);
},
};
}
function enumOptionValue(index: number): string {
return `enum:${index}`;
}
function arrayTableSelectOptions(column: Column): DataCellSelectOption[] {
const enumValues = column.schema.enum ?? [];
const hasNullEnumValue = enumValues.some((value) => value === null);
const nullableOptions: DataCellSelectOption[] =
column.nullable && !hasNullEnumValue
? [{ value: NULL_SELECT_VALUE, label: "No value" }]
: [];
return [
...nullableOptions,
...enumValues.map((value, index) => ({
value: enumOptionValue(index),
label: enumLabel(value),
})),
];
}
function arrayTableSelectValue({
column,
value,
}: {
column: Column;
value: unknown;
}): string | null {
const enumIndex =
column.schema.enum?.findIndex((candidate) =>
enumValueEquals(candidate, value),
) ?? -1;
if (enumIndex >= 0) return enumOptionValue(enumIndex);
if (value === null && column.nullable) return NULL_SELECT_VALUE;
return null;
}
function arrayTableSelectCommitValue({
column,
value,
}: {
column: Column;
value: string | null;
}): unknown {
if (value === NULL_SELECT_VALUE || value === null) return null;
const match = value.match(/^enum:(\d+)$/);
if (!match) return undefined;
return column.schema.enum?.[Number(match[1])];
}
components/json-form/source-link-focus-intent.ts
"use client";
import * as React from "react";
import { useMountEffect } from "@/hooks/use-mount-effect";
type SourceLinkFocusModality = "none" | "keyboard" | "pointer";
type SourceLinkFocusIntentState = {
modality: SourceLinkFocusModality;
};
const sourceLinkFocusIntentByDocument = new WeakMap<
Document,
SourceLinkFocusIntentState
>();
function isKeyboardFocusNavigation(event: KeyboardEvent): boolean {
if (
event.defaultPrevented ||
event.altKey ||
event.ctrlKey ||
event.metaKey
) {
return false;
}
return (
event.key === "Tab" ||
event.key === "ArrowDown" ||
event.key === "ArrowLeft" ||
event.key === "ArrowRight" ||
event.key === "ArrowUp" ||
event.key === "End" ||
event.key === "Home" ||
event.key === "PageDown" ||
event.key === "PageUp"
);
}
function getSourceLinkFocusIntentState(
ownerDocument: Document,
): SourceLinkFocusIntentState {
const existingState = sourceLinkFocusIntentByDocument.get(ownerDocument);
if (existingState) return existingState;
const state: SourceLinkFocusIntentState = { modality: "none" };
sourceLinkFocusIntentByDocument.set(ownerDocument, state);
ownerDocument.addEventListener(
"keydown",
(event) => {
if (isKeyboardFocusNavigation(event)) state.modality = "keyboard";
},
true,
);
ownerDocument.addEventListener(
"pointerdown",
() => {
state.modality = "pointer";
},
true,
);
ownerDocument.addEventListener(
"mousedown",
() => {
state.modality = "pointer";
},
true,
);
ownerDocument.addEventListener(
"touchstart",
() => {
state.modality = "pointer";
},
true,
);
return state;
}
export function useSourceLinkFocusPreviewIntent() {
useMountEffect(() => {
getSourceLinkFocusIntentState(document).modality = "none";
});
return React.useCallback((event: React.FocusEvent<HTMLElement>): boolean => {
if (event.defaultPrevented) return false;
return (
getSourceLinkFocusIntentState(event.currentTarget.ownerDocument)
.modality === "keyboard"
);
}, []);
}
components/json-form/read-only.tsx
"use client";
import * as React from "react";
/**
* Read-only mode for a whole form subtree.
*
* Viewer surfaces mount the form purely to display a result: nothing there is
* submitted, so an editable control silently discards whatever the user types.
* Read-only mode keeps the same layout, labels, and source links, but makes
* every control inert — scalars render with the native `readOnly` flag (still
* selectable and copyable), controls with no native read-only state are
* disabled, and the array add/remove affordances disappear entirely.
*/
const JsonFormReadOnlyContext = React.createContext(false);
export function JsonFormReadOnlyProvider({
readOnly,
children,
}: {
readOnly: boolean;
children: React.ReactNode;
}) {
return (
<JsonFormReadOnlyContext.Provider value={readOnly}>
{children}
</JsonFormReadOnlyContext.Provider>
);
}
export function useJsonFormReadOnly(): boolean {
return React.useContext(JsonFormReadOnlyContext);
}