diff --git a/examples/vite_basic/src/App.tsx b/examples/vite_basic/src/App.tsx index 9296a65..4b460b7 100644 --- a/examples/vite_basic/src/App.tsx +++ b/examples/vite_basic/src/App.tsx @@ -18,6 +18,12 @@ const App = () => { handleFileChange, } = useFileLoader(AVAILABLE_FILES[0]); + console.log("test page ", testPage); + + if (testPage) { + testPage.children = [undefined]; + } + // Get backrefs for the currently selected file const currentBackrefs = selectedFile?.backrefs || []; diff --git a/typescript/src/renderer/JsonDocRenderer.tsx b/typescript/src/renderer/JsonDocRenderer.tsx index c9da51b..e4c4238 100644 --- a/typescript/src/renderer/JsonDocRenderer.tsx +++ b/typescript/src/renderer/JsonDocRenderer.tsx @@ -1,15 +1,10 @@ import "./styles/index.css"; -import React, { useEffect } from "react"; +import React from "react"; import { Page } from "@/models/generated"; -import { loadPage } from "@/serialization/loader"; import { BlockRenderer } from "./components/BlockRenderer"; -import { PageDelimiter } from "./components/PageDelimiter"; -import { JsonViewPanel } from "./components/dev/JsonViewPanel"; -import { RendererProvider } from "./context/RendererContext"; -import { HighlightNavigation } from "./components/HighlightNavigation"; -import { useHighlights } from "./hooks/useHighlights"; +import { RendererContainer } from "./components/RendererContainer"; import { Backref } from "./utils/highlightUtils"; import { GlobalErrorBoundary } from "./components/ErrorBoundary"; @@ -40,101 +35,22 @@ export const JsonDocRenderer = ({ backrefs = [], onError, }: JsonDocRendererProps) => { - // Use the modular hooks for highlight management - const { highlightCount, currentActiveIndex, navigateToHighlight } = - useHighlights({ - backrefs, - }); - - useEffect(() => { - try { - //TODO: this is not throwing for invalid page object (one that doesn't follow schema) - loadPage(page); - } catch (_) { - // console.log("error ", error); - } - }, [page]); - - // return null; - const renderedContent = ( -
- {/* Page icon */} - {page.icon && ( -
- {page.icon.type === "emoji" && page.icon.emoji} -
- )} - {/* Page title */} - {page.properties?.title && ( -

- {page.properties.title.title?.[0]?.plain_text || "Untitled"} -

- )} - {/* Page children blocks */} - {page.children && page.children.length > 0 && ( -
- {page.children.map((block: any, index: number) => { - const currentPageNum = block.metadata?.origin?.page_num; - const nextPageNum = - index < page.children.length - 1 - ? (page.children[index + 1]?.metadata as any)?.origin?.page_num - : null; - - // Show delimiter after the last block of each page - const showPageDelimiter = - currentPageNum && - (nextPageNum !== currentPageNum || - index === page.children.length - 1); - - return ( - - - - {showPageDelimiter && !components?.page_delimiter && ( - - )} - {showPageDelimiter && components?.page_delimiter && ( - - )} - - ); - })} -
- )} -
- ); - + console.log("theme: ", theme); return (
- -
- {viewJson ? ( -
- -
- ) : ( - renderedContent - )} - {/* Show highlight navigation when there are highlights */} - {highlightCount > 0 && ( - - )} -
-
+
); diff --git a/typescript/src/renderer/components/ErrorBoundary.tsx b/typescript/src/renderer/components/ErrorBoundary.tsx index a980e32..8945dee 100644 --- a/typescript/src/renderer/components/ErrorBoundary.tsx +++ b/typescript/src/renderer/components/ErrorBoundary.tsx @@ -9,7 +9,7 @@ interface GlobalErrorBoundaryProps { function GlobalErrorFallback({ error, resetErrorBoundary }: FallbackProps) { console.log("error ", error); return ( -
+

Document Failed to Load

Something went wrong while rendering this document.

diff --git a/typescript/src/renderer/components/RendererContainer.tsx b/typescript/src/renderer/components/RendererContainer.tsx new file mode 100644 index 0000000..cd5e654 --- /dev/null +++ b/typescript/src/renderer/components/RendererContainer.tsx @@ -0,0 +1,130 @@ +import React, { useEffect } from "react"; + +import { Page } from "@/models/generated"; +import { loadPage } from "@/serialization/loader"; + +import { RendererProvider } from "../context/RendererContext"; +import { useHighlights } from "../hooks/useHighlights"; +import { Backref } from "../utils/highlightUtils"; + +import { HighlightNavigation } from "./HighlightNavigation"; +import { JsonViewPanel } from "./dev/JsonViewPanel"; +import { BlockRenderer } from "./BlockRenderer"; +import { PageDelimiter } from "./PageDelimiter"; + +interface RendererContainerProps { + page: Page; + className?: string; + components?: React.ComponentProps["components"] & { + page_delimiter: React.ComponentType<{ + pageNumber: number; + }>; + }; + devMode?: boolean; + resolveImageUrl?: (url: string) => Promise; + viewJson?: boolean; + backrefs?: Backref[]; +} + +export const RendererContainer: React.FC = ({ + page, + className = "", + components, + devMode = false, + resolveImageUrl, + viewJson = false, + backrefs = [], +}) => { + // Use the modular hooks for highlight management + const { highlightCount, currentActiveIndex, navigateToHighlight } = + useHighlights({ + backrefs, + }); + + useEffect(() => { + try { + //TODO: this is not throwing for invalid page object (one that doesn't follow schema) + loadPage(page); + } catch (_) { + // console.log("error ", error); + } + }, [page]); + + const renderedContent = ( +
+ {/* Page icon */} + {page.icon && ( +
+ {page.icon.type === "emoji" && page.icon.emoji} +
+ )} + {/* Page title */} + {page.properties?.title && ( +

+ {page.properties.title.title?.[0]?.plain_text || "Untitled"} +

+ )} + {/* Page children blocks */} + {page.children && page.children.length > 0 && ( +
+ {page.children.map((block: any, index: number) => { + const currentPageNum = block.metadata?.origin?.page_num; + const nextPageNum = + index < page.children.length - 1 + ? (page.children[index + 1]?.metadata as any)?.origin?.page_num + : null; + + // Show delimiter after the last block of each page + const showPageDelimiter = + currentPageNum && + (nextPageNum !== currentPageNum || + index === page.children.length - 1); + + return ( + + + + {showPageDelimiter && !components?.page_delimiter && ( + + )} + {showPageDelimiter && components?.page_delimiter && ( + + )} + + ); + })} +
+ )} +
+ ); + + return ( + +
+ {viewJson ? ( +
+ +
+ ) : ( + renderedContent + )} + {/* Show highlight navigation when there are highlights */} + {highlightCount > 0 && ( + + )} +
+
+ ); +}; diff --git a/typescript/src/renderer/utils/highlightUtils.ts b/typescript/src/renderer/utils/highlightUtils.ts index 57f3c17..3d9aed3 100644 --- a/typescript/src/renderer/utils/highlightUtils.ts +++ b/typescript/src/renderer/utils/highlightUtils.ts @@ -17,6 +17,7 @@ export function createHighlightSpan(text: string): HTMLSpanElement { const highlightSpan = document.createElement("span"); highlightSpan.className = "json-doc-highlight"; highlightSpan.textContent = text; + highlightSpan.role = "note"; return highlightSpan; } diff --git a/typescript/tests/BlockRenderer.test.tsx b/typescript/tests/BlockRenderer.test.tsx index 18e7419..b52ff56 100644 --- a/typescript/tests/BlockRenderer.test.tsx +++ b/typescript/tests/BlockRenderer.test.tsx @@ -6,27 +6,7 @@ import userEvent from "@testing-library/user-event"; import { describe, it, expect } from "vitest"; import { JsonDocRenderer } from "../src/renderer/JsonDocRenderer"; import { mockBlocks, mockPageWithAllBlocks } from "./fixtures/test-blocks"; - -// Helper to create a page with specific blocks -const createPageWithBlocks = (blocks: any[]) => ({ - object: "page", - id: "test-page", - properties: { - title: { - type: "title", - title: [ - { - href: null, - type: "text", - text: { link: null, content: "Test Page" }, - annotations: {}, - plain_text: "Test Page", - }, - ], - }, - }, - children: blocks, -}); +import { createPageWithBlocks } from "./utils/helpers"; describe("JsonDocRenderer - All Block Types", () => { it("renders page title correctly", () => { @@ -50,7 +30,7 @@ describe("JsonDocRenderer - All Block Types", () => { const page = createPageWithBlocks([mockBlocks.heading_1]); render(); - screen.debug(); + // screen.debug(); expect(screen.getByText("Main Heading")).toBeInTheDocument(); }); @@ -204,13 +184,10 @@ describe("JsonDocRenderer - All Block Types", () => { ).toBeInTheDocument(); }); - it("renders text annotations correctly", () => { + it("renders text annotations (bold, italic, underline etc.) correctly", () => { const page = createPageWithBlocks([mockBlocks.paragraph]); const { container } = render(); - // Debug: Print the HTML structure - // screen.debug(); - // Test bold annotation const boldText = screen.getByText("bold text"); expect(boldText).toBeInTheDocument(); @@ -246,4 +223,79 @@ describe("JsonDocRenderer - All Block Types", () => { expect(screen.getByText("Header 1")).toBeInTheDocument(); expect(screen.getByText("Content in first column")).toBeInTheDocument(); }); + + it("incorrect block page number shouldn't break the rendering", () => { + // Test misordered page numbers (3, 1, 2) + const blocksWithMisorderedPages = [ + { + ...mockBlocks.paragraph, + metadata: { + origin: { + file_id: "test-file", + page_num: 3, + }, + }, + }, + { + ...mockBlocks.heading_1, + metadata: { + origin: { + file_id: "test-file", + page_num: 1, + }, + }, + }, + { + ...mockBlocks.heading_2, + metadata: { + origin: { + file_id: "test-file", + page_num: 2, + }, + }, + }, + ]; + + const pageWithMisorderedBlocks = createPageWithBlocks( + blocksWithMisorderedPages + ); + + // This should not throw an error or break rendering + expect(() => { + render(); + }).not.toThrow(); + + // Verify that content still renders despite misordered page numbers + expect(screen.getByText(/This is a paragraph with/)).toBeInTheDocument(); + expect(screen.getByText(/Main Heading/)).toBeInTheDocument(); + expect(screen.getByText(/Subheading/)).toBeInTheDocument(); + + // Verify page delimiters are rendered in the order they appear + expect(screen.getByText(/Page 3/)).toBeInTheDocument(); + expect(screen.getByText(/Page 1/)).toBeInTheDocument(); + expect(screen.getByText(/Page 2/)).toBeInTheDocument(); + }); + + it("should handle bad page input gracefully", () => { + const badBlocks = [ + undefined, + { + ...mockBlocks.paragraph, + metadata: { + origin: { + file_id: "test-file", + page_num: 3, + }, + }, + }, + ]; + const badPage = createPageWithBlocks(badBlocks); + + render(); + + screen.debug(); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); + }); }); diff --git a/typescript/tests/Highlighting.test.tsx b/typescript/tests/Highlighting.test.tsx new file mode 100644 index 0000000..474047f --- /dev/null +++ b/typescript/tests/Highlighting.test.tsx @@ -0,0 +1,113 @@ +import "@testing-library/jest-dom"; +import { describe, it, expect, vi } from "vitest"; +import { render, screen, act, within } from "@testing-library/react"; +import React from "react"; + +import { JsonDocRenderer } from "../src/renderer/JsonDocRenderer"; +import { mockPageWithAllBlocks } from "./fixtures/test-blocks"; +import { Backref } from "../src/renderer/utils/highlightUtils"; + +describe("JsonDocRenderer Highlighting", () => { + it("creates highlights when backrefs are provided", async () => { + const paragraphBackref = { + start_idx: 4, + end_idx: 40, + block_id: "para-1", + }; + + const headingBackref = { + start_idx: 0, + end_idx: 4, + block_id: "h1-1", + }; + + const backrefs: Backref[] = [paragraphBackref, headingBackref]; + + const { container } = render( + + ); + + // Wait for highlights to be processed + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + // // Check that highlight spans are created + const highlights = within(container).getAllByRole("note"); + expect(highlights.length).toBeGreaterThan(0); + + screen.debug(); + + const paragraphEl = container.querySelector( + `[data-block-id=${paragraphBackref.block_id}]` + ); + expect(paragraphEl).toBeInTheDocument(); + + const headingEl = container.querySelector( + `[data-block-id=${headingBackref.block_id}]` + ); + expect(headingEl).toBeInTheDocument(); + + const combinedElText = + (paragraphEl?.textContent || "") + " " + (headingEl?.textContent || ""); + + highlights.forEach((item) => { + expect(item.textContent?.length).toBeGreaterThan(0); + if (item.textContent) + expect(combinedElText.includes(item.textContent)).toBeTruthy(); + }); + }); + + it("highlights page title when page_id backref is provided", async () => { + const firstBackref = { + start_idx: 0, + end_idx: 4, + page_id: mockPageWithAllBlocks.id, + }; + const backrefs: Backref[] = [firstBackref]; + + const { container } = render( + + ); + + // Wait for highlights to be processed + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + // Check that highlight spans are created in the page title + const pageTitle = within(container).getAllByRole("heading")[0]; + const highlightsInTitle = within(pageTitle).getAllByRole("note"); + + expect(highlightsInTitle.length).toBeGreaterThan(0); + + const content = + mockPageWithAllBlocks.properties.title.title[0].text.content; + const textToHighlight = content.slice( + firstBackref.start_idx, + firstBackref.end_idx + ); + + expect(highlightsInTitle[0]).toHaveTextContent(textToHighlight); + }); + + it("handles invalid backrefs gracefully", async () => { + const backrefs: Backref[] = [ + { start_idx: 0, end_idx: 4, block_id: "non-existent" }, + { start_idx: 99, end_idx: 1, block_id: "para-1" }, + ]; + + const { container } = render( + + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const highlights = within(container).queryAllByRole("note"); + + // No highlights should be created + expect(highlights.length).equals(0); + }); +}); diff --git a/typescript/tests/ThemeSwitching.test.tsx b/typescript/tests/ThemeSwitching.test.tsx index f2f63da..b8035c4 100644 --- a/typescript/tests/ThemeSwitching.test.tsx +++ b/typescript/tests/ThemeSwitching.test.tsx @@ -5,32 +5,12 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import { JsonDocRenderer } from "../src/renderer/JsonDocRenderer"; import { mockBlocks, mockPageWithAllBlocks } from "./fixtures/test-blocks"; - -// Helper to create a simple page for theme testing -const createSimplePage = () => ({ - object: "page", - id: "theme-test-page", - properties: { - title: { - type: "title", - title: [ - { - href: null, - type: "text", - text: { link: null, content: "Theme Test Page" }, - annotations: {}, - plain_text: "Theme Test Page", - }, - ], - }, - }, - children: [mockBlocks.paragraph], -}); +import { createPageWithBlocks } from "./utils/helpers"; const JSON_DOC_ROOT_TEST_ID = "jsondoc-renderer-root"; describe("JsonDocRenderer - Theme Switching", () => { - const simplePage = createSimplePage(); + const simplePage = createPageWithBlocks([mockBlocks.paragraph]); it("renders with light theme by default", () => { const { container } = render(); diff --git a/typescript/tests/UnsupportedBlockHandling.test.tsx b/typescript/tests/UnsupportedBlockHandling.test.tsx index 160a78d..b5fc126 100644 --- a/typescript/tests/UnsupportedBlockHandling.test.tsx +++ b/typescript/tests/UnsupportedBlockHandling.test.tsx @@ -3,27 +3,7 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { JsonDocRenderer } from "../src/renderer/JsonDocRenderer"; import { mockBlocks } from "./fixtures/test-blocks"; - -// Helper to create a page with specific blocks -const createPageWithBlocks = (blocks: any[]) => ({ - object: "page", - id: "test-page", - properties: { - title: { - type: "title", - title: [ - { - href: null, - type: "text", - text: { link: null, content: "Test Page" }, - annotations: {}, - plain_text: "Test Page", - }, - ], - }, - }, - children: blocks, -}); +import { createPageWithBlocks } from "./utils/helpers"; describe("Unsupported Block Handling", () => { // Mock console.warn to test logging behavior diff --git a/typescript/tests/fixtures/test-blocks.ts b/typescript/tests/fixtures/test-blocks.ts index 8293a72..46752ab 100644 --- a/typescript/tests/fixtures/test-blocks.ts +++ b/typescript/tests/fixtures/test-blocks.ts @@ -18,10 +18,7 @@ import { Page, } from "@/models/generated"; -// Mock data for all block types to test comprehensive rendering -export const mockBlocks: Record< - string, - | Block +type AllBlocks = | ParagraphBlock | Heading1Block | Heading2Block @@ -36,8 +33,12 @@ export const mockBlocks: Record< | ToggleBlock | TableBlock | ColumnListBlock - | EquationBlock -> = { + | EquationBlock; + +type BlockType = Block["type"]; + +// Mock data for all block types to test comprehensive rendering +export const mockBlocks: Record = { paragraph: { object: "block", id: "para-1", @@ -428,6 +429,7 @@ export const mockBlocks: Record< type: "paragraph", created_time: "2025-06-24T09:44:12.014249Z", has_children: false, + // @ts-expect-error paragraph: { rich_text: [ { @@ -470,7 +472,6 @@ export const mockBlocks: Record< type: "ai_block" as any, created_time: "2025-06-24T09:44:12.014249Z", has_children: false, - // @ts-expect-error ai_block: { prompt: "Generate something amazing", }, diff --git a/typescript/tests/setup.ts b/typescript/tests/setup.ts index 6bf66a5..c46f0f4 100644 --- a/typescript/tests/setup.ts +++ b/typescript/tests/setup.ts @@ -11,3 +11,6 @@ global.IntersectionObserver = vi.fn().mockImplementation(() => ({ unobserve: vi.fn(), takeRecords: vi.fn(() => []), })); + +// Mock scrollIntoView for jsdom +Element.prototype.scrollIntoView = vi.fn(); diff --git a/typescript/tests/test-cases.md b/typescript/tests/test-cases.md index b980a0c..399cea8 100644 --- a/typescript/tests/test-cases.md +++ b/typescript/tests/test-cases.md @@ -1,8 +1,8 @@ -- page title highlighting work -- block highlighting should work -- invalid backref shouldn't trigger highlighting -- incorrect block page number shouldn't break the rendering -- make sure all blocks are rendered -- make sure numbered lists are correctly labeled -- make sure bad page is gracefully handled -- invalid block type should show error block UI + + + + + + + + diff --git a/typescript/tests/utils/helpers.ts b/typescript/tests/utils/helpers.ts new file mode 100644 index 0000000..94ca5c4 --- /dev/null +++ b/typescript/tests/utils/helpers.ts @@ -0,0 +1,20 @@ +// Helper to create a page with specific blocks +export const createPageWithBlocks = (blocks: any[]) => ({ + object: "page", + id: "test-page", + properties: { + title: { + type: "title", + title: [ + { + href: null, + type: "text", + text: { link: null, content: "Test Page" }, + annotations: {}, + plain_text: "Test Page", + }, + ], + }, + }, + children: blocks, +});