@elabs-ai/components-viewer 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/chunk-2NQ4RSJ3.js +18 -0
- package/dist/chunk-2NQ4RSJ3.js.map +1 -0
- package/dist/chunk-3U3MESBU.js +21 -0
- package/dist/chunk-3U3MESBU.js.map +1 -0
- package/dist/chunk-4OFQYALG.js +147 -0
- package/dist/chunk-4OFQYALG.js.map +1 -0
- package/dist/chunk-5OMONA2G.js +87 -0
- package/dist/chunk-5OMONA2G.js.map +1 -0
- package/dist/chunk-AORNMH77.js +58 -0
- package/dist/chunk-AORNMH77.js.map +1 -0
- package/dist/chunk-BCF3RWRL.js +18 -0
- package/dist/chunk-BCF3RWRL.js.map +1 -0
- package/dist/chunk-FELIAGKI.js +22 -0
- package/dist/chunk-FELIAGKI.js.map +1 -0
- package/dist/chunk-GGR7BNW4.js +18 -0
- package/dist/chunk-GGR7BNW4.js.map +1 -0
- package/dist/chunk-H3OAON3D.js +22 -0
- package/dist/chunk-H3OAON3D.js.map +1 -0
- package/dist/chunk-HMG2ERXH.js +17 -0
- package/dist/chunk-HMG2ERXH.js.map +1 -0
- package/dist/chunk-KOMDRWOU.js +21 -0
- package/dist/chunk-KOMDRWOU.js.map +1 -0
- package/dist/chunk-NMA57QZ7.js +117 -0
- package/dist/chunk-NMA57QZ7.js.map +1 -0
- package/dist/chunk-RGWDIQJR.js +26 -0
- package/dist/chunk-RGWDIQJR.js.map +1 -0
- package/dist/chunk-SLXRUYT3.js +91 -0
- package/dist/chunk-SLXRUYT3.js.map +1 -0
- package/dist/chunk-UJIJECEF.js +21 -0
- package/dist/chunk-UJIJECEF.js.map +1 -0
- package/dist/chunk-UL43NGUG.js +68 -0
- package/dist/chunk-UL43NGUG.js.map +1 -0
- package/dist/chunk-VO2273Z2.js +55 -0
- package/dist/chunk-VO2273Z2.js.map +1 -0
- package/dist/code-adapter-ADZ4UOGN.js +226 -0
- package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
- package/dist/csv-adapter-6VU3FFVU.js +100 -0
- package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
- package/dist/docx-adapter-5CQDHWTD.js +383 -0
- package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
- package/dist/image-adapter-WOHZR24J.js +132 -0
- package/dist/image-adapter-WOHZR24J.js.map +1 -0
- package/dist/index.d.ts +1353 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/dist/json-adapter-ZUW5GQHE.js +90 -0
- package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
- package/dist/markdown-adapter-YC6WTBS4.js +214 -0
- package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
- package/dist/media-adapter-MCTB4GBH.js +78 -0
- package/dist/media-adapter-MCTB4GBH.js.map +1 -0
- package/dist/pdf-adapter-5PMKEXUD.js +424 -0
- package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
- package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
- package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
- package/dist/text-adapter-NFNWB5W3.js +81 -0
- package/dist/text-adapter-NFNWB5W3.js.map +1 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
- package/package.json +103 -0
- package/src/adapters/code/code-adapter.test.tsx +221 -0
- package/src/adapters/code/code-adapter.tsx +272 -0
- package/src/adapters/code/code-language.test.ts +45 -0
- package/src/adapters/code/code-language.ts +100 -0
- package/src/adapters/code/code-manifest.ts +19 -0
- package/src/adapters/code/code-theme.test.ts +46 -0
- package/src/adapters/code/code-theme.ts +99 -0
- package/src/adapters/csv/csv-adapter.test.tsx +169 -0
- package/src/adapters/csv/csv-adapter.tsx +146 -0
- package/src/adapters/csv/csv-manifest.ts +20 -0
- package/src/adapters/docx/docx-adapter.test.tsx +192 -0
- package/src/adapters/docx/docx-adapter.tsx +328 -0
- package/src/adapters/docx/docx-manifest.ts +18 -0
- package/src/adapters/docx/docx-model.test.ts +181 -0
- package/src/adapters/docx/docx-model.ts +294 -0
- package/src/adapters/image/image-adapter.test.tsx +141 -0
- package/src/adapters/image/image-adapter.tsx +191 -0
- package/src/adapters/image/image-manifest.ts +15 -0
- package/src/adapters/index.ts +64 -0
- package/src/adapters/json/json-adapter.test.tsx +77 -0
- package/src/adapters/json/json-adapter.tsx +141 -0
- package/src/adapters/json/json-manifest.ts +11 -0
- package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
- package/src/adapters/markdown/markdown-adapter.tsx +353 -0
- package/src/adapters/markdown/markdown-manifest.ts +21 -0
- package/src/adapters/markdown/markdown-marks.test.ts +85 -0
- package/src/adapters/markdown/markdown-marks.ts +119 -0
- package/src/adapters/media/media-adapter.test.tsx +79 -0
- package/src/adapters/media/media-adapter.tsx +123 -0
- package/src/adapters/media/media-manifest.ts +17 -0
- package/src/adapters/office-fixture.ts +239 -0
- package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
- package/src/adapters/pdf/pdf-adapter.tsx +599 -0
- package/src/adapters/pdf/pdf-engine.ts +171 -0
- package/src/adapters/pdf/pdf-fixture.ts +27 -0
- package/src/adapters/pdf/pdf-manifest.ts +30 -0
- package/src/adapters/pdf/pdf-text.test.ts +171 -0
- package/src/adapters/pdf/pdf-text.ts +202 -0
- package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
- package/src/adapters/pptx/pptx-adapter.tsx +341 -0
- package/src/adapters/pptx/pptx-manifest.ts +19 -0
- package/src/adapters/pptx/pptx-model.test.ts +219 -0
- package/src/adapters/pptx/pptx-model.ts +248 -0
- package/src/adapters/text/text-adapter.test.tsx +125 -0
- package/src/adapters/text/text-adapter.tsx +117 -0
- package/src/adapters/text/text-manifest.ts +19 -0
- package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
- package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
- package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
- package/src/components/grid-text.test.ts +68 -0
- package/src/components/grid-text.ts +77 -0
- package/src/components/marked-text.tsx +36 -0
- package/src/components/sheet-table.tsx +142 -0
- package/src/core/errors.ts +120 -0
- package/src/core/highlight-marks.test.ts +95 -0
- package/src/core/highlight-marks.ts +95 -0
- package/src/core/highlight-resolve.test.ts +208 -0
- package/src/core/highlight-resolve.ts +191 -0
- package/src/core/highlight.ts +125 -0
- package/src/core/registry.test.ts +172 -0
- package/src/core/registry.ts +188 -0
- package/src/core/scroll-host.ts +42 -0
- package/src/core/text-index.test.ts +95 -0
- package/src/core/text-index.ts +159 -0
- package/src/core/types.ts +285 -0
- package/src/core/use-highlight-scroll.ts +59 -0
- package/src/core/use-page-control.ts +47 -0
- package/src/core/use-paged-scroll.ts +194 -0
- package/src/core/use-viewport-size.ts +54 -0
- package/src/core/zoom.ts +56 -0
- package/src/file-viewer/file-viewer-context.tsx +239 -0
- package/src/file-viewer/file-viewer-find.tsx +204 -0
- package/src/file-viewer/file-viewer-pager.tsx +120 -0
- package/src/file-viewer/file-viewer-zoom.tsx +166 -0
- package/src/file-viewer/file-viewer.stories.tsx +947 -0
- package/src/file-viewer/file-viewer.test.tsx +947 -0
- package/src/file-viewer/file-viewer.tsx +1143 -0
- package/src/index.ts +157 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/adapters/markdown/markdown-adapter.tsx","../src/adapters/markdown/markdown-marks.ts"],"sourcesContent":["\"use client\";\n\n/**\n * Markdown adapter — a `.md` file read as a DOCUMENT, not as source.\n *\n * Renders through `streamdown`, the same markdown engine\n * `@elabs-ai/components-ai`'s `MarkdownView` and\n * `@elabs-ai/components-editor`'s preview use, mapped onto the same\n * `Prose*` primitives from `@elabs-ai/components-ui`. That is the\n * point: a README must not look like three different documents depending on\n * which pane it opened in. The element map below is deliberately a near-copy of\n * `MarkdownView`'s — the packages are siblings and may not import each other,\n * and the shared half that could move down (the `Prose*` set, the Streamdown\n * locale bridge) already has.\n *\n * What this does NOT do, on purpose:\n *\n * - **No plugins.** `@streamdown/code`, `/math`, `/cjk` and `/mermaid` are four\n * more packages a consumer would have to install to open one file. Fenced code\n * is rendered as an unhighlighted block by the element map below (Streamdown\n * emits a bare `<code>` without the code plugin, so the `<pre>` is ours); a\n * source file opened directly still gets Shiki from the `code` adapter, and a\n * diagram fence reads as its own text rather than as a picture.\n * - **No streaming.** A file arrives settled or not at all, so `mode=\"static\"`\n * and `parseIncompleteMarkdown={false}` — the incomplete-token repair that\n * makes Streamdown good at chat is only overhead for a file on disk.\n */\n\nimport {\n ProseBlockquote,\n ProseHeading,\n ProseInlineCode,\n ProseLink,\n ProseList,\n ProseListItem,\n ProseText,\n StatePanel,\n cn,\n useLocale,\n useStreamdownTranslations,\n} from \"@elabs-ai/components-ui\";\nimport type { ProseHeadingLevel, ResolvedFileSource } from \"@elabs-ai/components-ui\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { createContext, use, useMemo, useRef } from \"react\";\nimport type * as StreamdownExports from \"streamdown\";\n\nimport { toViewerError } from \"../../core/errors\";\nimport { toMarkRanges, type MarkRanges } from \"../../core/highlight-marks\";\nimport { useScrollActiveHighlightIntoView } from \"../../core/use-highlight-scroll\";\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n} from \"../../core/types\";\nimport { blockMark, blockMarkAttributes, type PositionedNode } from \"./markdown-marks\";\nimport { markdownManifest } from \"./markdown-manifest\";\n\n/**\n * Characters kept before truncation. Markdown is parsed into an element tree,\n * which costs more than a `<pre>` — this sits between the code and plain-text\n * limits for that reason.\n */\nexport const MARKDOWN_CHARACTER_LIMIT = 1_000_000;\n\nexport interface MarkdownDocument extends AdapterDocument {\n kind: \"markdown\";\n text: string;\n /** Total characters in the file, when more than what is shown. */\n totalCharacters?: number;\n}\n\n// Type-only, so it erases: the runtime edge is the dynamic import below.\ntype StreamdownModule = typeof StreamdownExports;\ntype StreamdownComponents = NonNullable<\n ComponentProps<StreamdownModule[\"Streamdown\"]>[\"components\"]\n>;\n\n/**\n * The engine, resolved once per page.\n *\n * Loaded in `load()` rather than imported at the top of this module for the\n * reason every other adapter does it: `streamdown` is an OPTIONAL peer, and a\n * static edge would make this module unresolvable — not merely heavier — for a\n * consumer who never installs it. The renderer reads the same cached module,\n * which is guaranteed to be populated because a document only exists once\n * `load()` has resolved.\n */\nlet streamdown: StreamdownModule | undefined;\n\nasync function loadStreamdown(): Promise<StreamdownModule> {\n // Dynamic: the ONLY edge to the optional peer (heavy-deps:check).\n streamdown ??= await import(\"streamdown\");\n return streamdown;\n}\n\nclass MarkdownAdapter implements FileAdapter {\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<MarkdownDocument> {\n let raw: string;\n try {\n raw = await source.text(context.signal);\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n\n // Before returning: a document this adapter's Renderer cannot draw is a\n // failed load, not a blank pane. A missing peer surfaces from here as\n // `parser-missing` (see `parserMissingError`).\n await loadStreamdown();\n\n const truncated = raw.length > MARKDOWN_CHARACTER_LIMIT;\n return {\n kind: \"markdown\",\n text: truncated ? raw.slice(0, MARKDOWN_CHARACTER_LIMIT) : raw,\n totalCharacters: truncated ? raw.length : undefined,\n };\n }\n}\n\n/* -------------------------------------------------------------------------- */\n/* Renderer */\n/* -------------------------------------------------------------------------- */\n\n/** Never past `h6`, whatever the offset — an `h7` is not an element. */\nconst clampHeadingLevel = (level: number): ProseHeadingLevel =>\n Math.min(6, Math.max(1, level)) as ProseHeadingLevel;\n\n/* -------------------------------------------------------------------------- */\n/* Highlight plates */\n/* -------------------------------------------------------------------------- */\n\nconst EMPTY_MARKS: MarkRanges = { ranges: [], activeIndex: -1 };\n\n/**\n * The marks, read by the block components.\n *\n * A context rather than a closure over `buildProseComponents`, so the element\n * map stays memoized on the heading offset alone: rebuilding it per keystroke\n * of a find-as-you-type would hand Streamdown a new `components` object every\n * time, which it treats as a reason to re-render every block in the file.\n */\nconst MarkContext = createContext<MarkRanges>(EMPTY_MARKS);\n\n/** Set once a block has plated, so a nested one does not plate on top of it. */\nconst PlatedContext = createContext(false);\n\n/**\n * The plate for one block, and a wrapper that stops its children plating again.\n *\n * A loose list item renders as `li > p`, and both nodes' source spans contain\n * the same mark — without the suppression the reader sees two stacked fills for\n * one citation.\n */\nfunction usePlate(node: PositionedNode | undefined, className?: string) {\n const marks = use(MarkContext);\n const plated = use(PlatedContext);\n const mark = plated ? { marked: false, active: false } : blockMark(marks, node);\n return {\n props: blockMarkAttributes(mark, className),\n wrap: (children: ReactNode) =>\n mark.marked ? <PlatedContext value={true}>{children}</PlatedContext> : children,\n };\n}\n\n/*\n * The plating blocks are named components rather than inline arrows in the map\n * below: they call a hook, and a lowercase `p:` key is not a component name as\n * far as the rules-of-hooks lint can tell.\n */\n\nconst MarkdownParagraph: NonNullable<StreamdownComponents[\"p\"]> = ({\n node,\n className,\n children,\n ...props\n}) => {\n const plate = usePlate(node, className);\n return (\n <ProseText {...plate.props} {...props}>\n {plate.wrap(children)}\n </ProseText>\n );\n};\n\nconst MarkdownListItem: NonNullable<StreamdownComponents[\"li\"]> = ({\n node,\n className,\n children,\n ...props\n}) => {\n const plate = usePlate(node, className);\n return (\n <ProseListItem {...plate.props} {...props}>\n {plate.wrap(children)}\n </ProseListItem>\n );\n};\n\nconst MarkdownCode: NonNullable<StreamdownComponents[\"code\"]> = ({\n node,\n className,\n children,\n ...props\n}) => {\n const fence = /\\blanguage-/.test(className ?? \"\");\n // The plate goes on the `<pre>` — the whole fence is what a citation points\n // at. Its own `bg-surface-muted` wins over the plate's fill (see\n // `blockMarkAttributes`), so the code keeps its ground and the rail is what\n // says it is cited.\n const plate = usePlate(\n fence ? node : undefined,\n \"bg-surface-muted text-code overflow-x-auto rounded-md p-3 font-mono whitespace-pre\",\n );\n // A fence needs its own `<pre>`. With no `@streamdown/code` plugin installed\n // Streamdown emits a BARE `<code data-block>` — no block wrapper — which the\n // surrounding prose's `whitespace-normal` then collapses into one run-on\n // line. The fence is unhighlighted here by design (see the module docblock);\n // it still has to read as a block of code.\n return fence ? (\n <pre {...plate.props}>\n <code className={className} {...props}>\n {plate.wrap(children)}\n </code>\n </pre>\n ) : (\n <ProseInlineCode className={className} {...props}>\n {children}\n </ProseInlineCode>\n );\n};\n\n/** Everything that does not depend on the heading offset. */\nconst SHARED_COMPONENTS: StreamdownComponents = {\n p: MarkdownParagraph,\n a: ({ node: _node, ...props }) => <ProseLink {...props} />,\n // `ref` is stripped: ProseList's intersection ref type (ul & ol) is narrower\n // than the per-element ref react-markdown passes.\n ul: ({ node: _node, ref: _ref, ...props }) => <ProseList {...props} />,\n ol: ({ node: _node, ref: _ref, ...props }) => <ProseList ordered {...props} />,\n li: MarkdownListItem,\n blockquote: ({ node: _node, ...props }) => <ProseBlockquote {...props} />,\n code: MarkdownCode,\n};\n\n/**\n * Map the markdown element tree onto the Prose primitives.\n *\n * Built per `baseHeadingLevel` (memoized by the renderer, not rebuilt each\n * render): the map is otherwise pure and Streamdown puts it into a context, so\n * a fresh object per render would re-render every block.\n *\n * The heading offset is the reason this takes an argument at all. A viewed file\n * carries its own heading tree, correct only relative to the page hosting it —\n * a README's `#` inside an app that already has an `<h1>` would otherwise put\n * two `h1`s in a screen reader's flat heading list. Same seam, same arithmetic\n * as `@elabs-ai/components-ai`'s `MarkdownView`.\n */\nfunction buildProseComponents(baseHeadingLevel: ProseHeadingLevel): StreamdownComponents {\n const heading =\n (markdownLevel: number): StreamdownComponents[\"h1\"] =>\n ({ node, className, children, ...props }) => {\n const plate = usePlate(node, className);\n return (\n <ProseHeading\n level={clampHeadingLevel(markdownLevel + baseHeadingLevel - 1)}\n {...plate.props}\n {...props}\n >\n {plate.wrap(children)}\n </ProseHeading>\n );\n };\n return {\n h1: heading(1),\n h2: heading(2),\n h3: heading(3),\n h4: heading(4),\n h5: heading(5),\n h6: heading(6),\n ...SHARED_COMPONENTS,\n };\n}\n\nfunction MarkdownRenderer({\n document: doc,\n className,\n baseHeadingLevel = 2,\n highlights,\n activeHighlightId,\n}: AdapterRendererProps) {\n const markdown = doc as MarkdownDocument;\n const { t, formatNumber } = useLocale();\n const translations = useStreamdownTranslations();\n const components = useMemo(() => buildProseComponents(baseHeadingLevel), [baseHeadingLevel]);\n const container = useRef<HTMLDivElement>(null);\n const marks = useMemo(\n () => toMarkRanges(highlights, markdown.text.length),\n [highlights, markdown.text],\n );\n\n useScrollActiveHighlightIntoView(container, activeHighlightId);\n\n if (markdown.text.trim().length === 0) {\n return <StatePanel kind=\"empty\" title={t(\"viewer.file.empty\")} className={className} />;\n }\n\n const Streamdown = streamdown?.Streamdown;\n if (!Streamdown) return null;\n\n return (\n // No `overflow-auto` here: `FileViewerContent` is the scroll boundary, and\n // nesting a second one clips the last block above the outer pane's padding.\n <div ref={container} className={cn(\"flex flex-col gap-2\", className)}>\n {markdown.totalCharacters !== undefined && (\n // Not an error — the file is fine, we are showing part of it.\n <p role=\"status\" className=\"text-meta text-muted-foreground\">\n {t(\"viewer.text.truncated\", {\n shown: formatNumber(markdown.text.length),\n total: formatNumber(markdown.totalCharacters),\n })}\n </p>\n )}\n {/* `max-w-prose` because this is genuine long-form reading, not a label:\n a README run edge-to-edge across a wide pane loses the line. The pane's\n own padding is the page margin — the article adds none. */}\n <article className=\"mx-auto max-w-prose space-y-3\">\n {/* `mode=\"static\"` is what makes the plates addressable: it hands the\n whole file to ONE parse, so every node's `position.start.offset` is\n an offset into `document.text`. Splitting into blocks (streaming\n mode) would restart them per block. */}\n <MarkContext value={marks}>\n <Streamdown\n mode=\"static\"\n parseIncompleteMarkdown={false}\n components={components}\n translations={translations}\n >\n {markdown.text}\n </Streamdown>\n </MarkContext>\n </article>\n </div>\n );\n}\n\nconst adapterModule: AdapterModule = {\n manifest: markdownManifest,\n create: () => new MarkdownAdapter(),\n Renderer: MarkdownRenderer,\n};\n\nexport default adapterModule;\n","/**\n * Markdown highlights are BLOCK-shaped, and that is a deliberate downgrade.\n *\n * Every other text adapter marks characters, because its `document.text` is the\n * thing on screen. Markdown's is not: `text` is the SOURCE, so offset 12 might\n * land in the middle of `**bold**` — two of whose characters are never drawn at\n * all — or inside a link's URL, which the reader only sees as a word. There is\n * no honest character-level mapping from source offsets to rendered text\n * without re-implementing the parser's own position bookkeeping for inline\n * nodes, and getting that subtly wrong would put marks on the wrong words while\n * looking exactly as confident as a correct one.\n *\n * So a markdown mark says \"this passage is in HERE\" — the paragraph, heading,\n * list item or fence whose source span the mark touches is plated whole. That\n * is enough for the case the feature exists for (a citation pointing a reader at\n * the passage an answer came from) and it cannot be subtly wrong: a block either\n * contains the source range or it does not.\n *\n * The offsets are trustworthy because the renderer runs Streamdown in\n * `mode=\"static\"`, which hands the WHOLE file to one parse — no per-block\n * splitting, no incomplete-token repair — so every hast node's\n * `position.start.offset` is an offset into `document.text` itself.\n */\n\nimport { cn } from \"@elabs-ai/components-ui\";\n\nimport type { MarkRanges } from \"../../core/highlight-marks\";\n\n/** The subset of a unist `Position` this needs. Structural, so no unist import. */\nexport interface SourcePosition {\n start?: { offset?: number };\n end?: { offset?: number };\n}\n\n/** A hast node as the component overrides receive it. */\nexport interface PositionedNode {\n position?: SourcePosition;\n}\n\n/** Whether a block carries any mark, and whether it carries the current one. */\nexport interface BlockMark {\n marked: boolean;\n active: boolean;\n}\n\nconst UNMARKED: BlockMark = { marked: false, active: false };\n\n/**\n * Which marks land inside one block's source span.\n *\n * Half-open on both sides and strict, so a mark ending exactly where a block\n * begins belongs to the block before it — otherwise the paragraph after a\n * marked heading would light up too, on every citation.\n */\nexport function blockMark(marks: MarkRanges, node: PositionedNode | undefined): BlockMark {\n const start = node?.position?.start?.offset;\n const end = node?.position?.end?.offset;\n if (start === undefined || end === undefined || end <= start) return UNMARKED;\n\n let marked = false;\n let active = false;\n marks.ranges.forEach(([from, to], index) => {\n if (from >= end || to <= start) return;\n marked = true;\n if (index === marks.activeIndex) active = true;\n });\n return marked ? { marked, active } : UNMARKED;\n}\n\n/**\n * The attributes a plated block carries.\n *\n * `data-slot`/`data-active` are the same seam the character marks use, so one\n * selector finds \"the current highlight\" whatever adapter drew it — that is what\n * `useScrollActiveHighlightIntoView` scrolls to. `aria-current` is the part that\n * is not decoration: the active block is announced as the current one rather\n * than being distinguishable only by a warmer plate (WCAG 1.4.1).\n */\nexport interface BlockMarkAttributes {\n \"data-slot\"?: \"highlight-block\";\n /**\n * Present-or-absent, never `\"false\"` — the same shape `MatchHighlight`'s marks\n * and the PDF's boxes emit, so one selector (`[data-active]`) finds the current\n * highlight whichever painter drew it.\n */\n \"data-active\"?: \"\";\n \"aria-current\"?: \"true\";\n className?: string;\n}\n\n/**\n * Plate classes.\n *\n * A marked block gets a fill AND a rail; the current one doubles the rail's\n * width on top of warming the fill, so \"which one am I on\" survives greyscale\n * and a colour-vision difference. The negative margins let the plate bleed into\n * the prose column's own gutter instead of shifting the text when it appears.\n */\nconst MARKED_CLASS = \"bg-highlight/40 border-s-2 border-highlight rounded-e-sm ps-2 -ms-2\";\nconst ACTIVE_CLASS =\n \"bg-highlight-active/40 border-s-4 border-highlight-active rounded-e-sm ps-2 -ms-2\";\n\n/**\n * Spreadable attributes for a block, given how the marks fell on it.\n *\n * The block's OWN classes are merged last and therefore win, which is the\n * deliberate answer for a fenced code block: `bg-surface-muted` beats the\n * plate's fill, so the code keeps its own ground and the rail alone says it is\n * cited. Leaving the order to CSS source order instead would make that outcome\n * accidental.\n */\nexport function blockMarkAttributes(mark: BlockMark, className?: string): BlockMarkAttributes {\n if (!mark.marked) return className === undefined ? {} : { className };\n return {\n \"data-slot\": \"highlight-block\",\n ...(mark.active ? { \"data-active\": \"\" as const, \"aria-current\": \"true\" as const } : {}),\n className: cn(mark.active ? ACTIVE_CLASS : MARKED_CLASS, className),\n };\n}\n"],"mappings":"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","names":["cn","cn"]}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import {
|
|
4
|
+
mediaManifest
|
|
5
|
+
} from "./chunk-HMG2ERXH.js";
|
|
6
|
+
import {
|
|
7
|
+
toViewerError
|
|
8
|
+
} from "./chunk-AORNMH77.js";
|
|
9
|
+
|
|
10
|
+
// src/adapters/media/media-adapter.tsx
|
|
11
|
+
import { cn, StatePanel, useLocale } from "@elabs-ai/components-ui";
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
13
|
+
import { jsx } from "react/jsx-runtime";
|
|
14
|
+
var MediaAdapter = class {
|
|
15
|
+
#source;
|
|
16
|
+
async load(source, context) {
|
|
17
|
+
this.#source = source;
|
|
18
|
+
try {
|
|
19
|
+
const url = await source.url(context.signal);
|
|
20
|
+
return { kind: "media", url, media: source.category === "audio" ? "audio" : "video" };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw toViewerError(error, "read-failed", { fileName: source.name });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
dispose() {
|
|
26
|
+
this.#source?.revoke();
|
|
27
|
+
this.#source = void 0;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
function MediaRenderer({ document: doc, source, className }) {
|
|
31
|
+
const media = doc;
|
|
32
|
+
const { t } = useLocale();
|
|
33
|
+
const [failed, setFailed] = useState(false);
|
|
34
|
+
useEffect(() => setFailed(false), [media.url]);
|
|
35
|
+
if (failed) {
|
|
36
|
+
return /* @__PURE__ */ jsx("div", { className: cn("flex min-h-full flex-col justify-center p-4", className), children: /* @__PURE__ */ jsx(
|
|
37
|
+
StatePanel,
|
|
38
|
+
{
|
|
39
|
+
kind: "error",
|
|
40
|
+
title: t("viewer.media.unsupportedTitle"),
|
|
41
|
+
description: t("viewer.media.unsupported", { name: source.name })
|
|
42
|
+
}
|
|
43
|
+
) });
|
|
44
|
+
}
|
|
45
|
+
const label = t("viewer.media.label", { name: source.name });
|
|
46
|
+
if (media.media === "audio") {
|
|
47
|
+
return /* @__PURE__ */ jsx("div", { className: cn("flex min-h-full items-center justify-center", className), children: /* @__PURE__ */ jsx(
|
|
48
|
+
"audio",
|
|
49
|
+
{
|
|
50
|
+
src: media.url,
|
|
51
|
+
controls: true,
|
|
52
|
+
"aria-label": label,
|
|
53
|
+
onError: () => setFailed(true),
|
|
54
|
+
className: "w-full max-w-lg"
|
|
55
|
+
}
|
|
56
|
+
) });
|
|
57
|
+
}
|
|
58
|
+
return /* @__PURE__ */ jsx("div", { className: cn("flex min-h-full items-center justify-center", className), children: /* @__PURE__ */ jsx(
|
|
59
|
+
"video",
|
|
60
|
+
{
|
|
61
|
+
src: media.url,
|
|
62
|
+
controls: true,
|
|
63
|
+
"aria-label": label,
|
|
64
|
+
onError: () => setFailed(true),
|
|
65
|
+
className: "max-h-full max-w-full"
|
|
66
|
+
}
|
|
67
|
+
) });
|
|
68
|
+
}
|
|
69
|
+
var adapterModule = {
|
|
70
|
+
manifest: mediaManifest,
|
|
71
|
+
create: () => new MediaAdapter(),
|
|
72
|
+
Renderer: MediaRenderer
|
|
73
|
+
};
|
|
74
|
+
var media_adapter_default = adapterModule;
|
|
75
|
+
export {
|
|
76
|
+
media_adapter_default as default
|
|
77
|
+
};
|
|
78
|
+
//# sourceMappingURL=media-adapter-MCTB4GBH.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/adapters/media/media-adapter.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Video / audio adapter — native elements, deliberately.\n *\n * `@elabs-ai/components-ai` ships a designed `AudioPlayer` built on\n * `media-chrome`, and this adapter does NOT reach for it: `ai` is a Layer-2\n * sibling, so importing it would be a sideways edge the dependency gate rejects\n * (and would pull a chat package into a file viewer). Native `<video controls>`\n * / `<audio controls>` are the honest answer here — they bring the platform's\n * own accessible transport, keyboard handling, picture-in-picture, captions\n * menu and OS media keys, none of which a custom skin gets for free.\n *\n * The plan's \"extract `AudioPlayer` down to `ui`\" would let both sides share one\n * skinned player. That is a real refactor of an existing component with its own\n * tests and stories, so it is tracked separately rather than smuggled in here.\n */\n\nimport { cn, StatePanel, useLocale, type ResolvedFileSource } from \"@elabs-ai/components-ui\";\nimport { useEffect, useState } from \"react\";\n\nimport { toViewerError } from \"../../core/errors\";\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n} from \"../../core/types\";\nimport { mediaManifest } from \"./media-manifest\";\n\nexport interface MediaDocument extends AdapterDocument {\n kind: \"media\";\n /** A URL the element can play — the source's own URL, or a minted object URL. */\n url: string;\n /** Which element to render. `resolveFileKind` already decided this. */\n media: \"video\" | \"audio\";\n}\n\nclass MediaAdapter implements FileAdapter {\n #source?: ResolvedFileSource;\n\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<MediaDocument> {\n this.#source = source;\n try {\n // Deliberately `url()`, never `bytes()`: an object URL lets the browser\n // stream and seek. Buffering a 2 GB video into memory to hand it to a\n // `<video>` element would be the wrong shape at every size.\n const url = await source.url(context.signal);\n return { kind: \"media\", url, media: source.category === \"audio\" ? \"audio\" : \"video\" };\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n }\n\n dispose(): void {\n this.#source?.revoke();\n this.#source = undefined;\n }\n}\n\nfunction MediaRenderer({ document: doc, source, className }: AdapterRendererProps) {\n const media = doc as MediaDocument;\n const { t } = useLocale();\n const [failed, setFailed] = useState(false);\n\n useEffect(() => setFailed(false), [media.url]);\n\n if (failed) {\n // Terminal: the browser has no decoder for this codec. Retrying cannot help,\n // so this states the fact rather than offering a button that does nothing.\n // The panel carries `role=\"alert\"` itself, and sits centred like every other\n // viewer state — a message pinned to the top-left of an empty pane reads as\n // a broken render.\n return (\n <div className={cn(\"flex min-h-full flex-col justify-center p-4\", className)}>\n <StatePanel\n kind=\"error\"\n title={t(\"viewer.media.unsupportedTitle\")}\n description={t(\"viewer.media.unsupported\", { name: source.name })}\n />\n </div>\n );\n }\n\n const label = t(\"viewer.media.label\", { name: source.name });\n\n if (media.media === \"audio\") {\n return (\n <div className={cn(\"flex min-h-full items-center justify-center\", className)}>\n <audio\n src={media.url}\n controls\n aria-label={label}\n onError={() => setFailed(true)}\n className=\"w-full max-w-lg\"\n />\n </div>\n );\n }\n\n return (\n <div className={cn(\"flex min-h-full items-center justify-center\", className)}>\n <video\n src={media.url}\n controls\n // No autoplay: a viewer opens files the reader chose to look at, not to\n // listen to. Sound starting on its own is the reason browsers block it.\n aria-label={label}\n onError={() => setFailed(true)}\n className=\"max-h-full max-w-full\"\n />\n </div>\n );\n}\n\nconst adapterModule: AdapterModule = {\n manifest: mediaManifest,\n create: () => new MediaAdapter(),\n Renderer: MediaRenderer,\n};\n\nexport default adapterModule;\n"],"mappings":";;;;;;;;;;AAkBA,SAAS,IAAI,YAAY,iBAA0C;AACnE,SAAS,WAAW,gBAAgB;AAyD5B;AArCR,IAAM,eAAN,MAA0C;AAAA,EACxC;AAAA,EAEA,MAAM,KAAK,QAA4B,SAAqD;AAC1F,SAAK,UAAU;AACf,QAAI;AAIF,YAAM,MAAM,MAAM,OAAO,IAAI,QAAQ,MAAM;AAC3C,aAAO,EAAE,MAAM,SAAS,KAAK,OAAO,OAAO,aAAa,UAAU,UAAU,QAAQ;AAAA,IACtF,SAAS,OAAO;AACd,YAAM,cAAc,OAAO,eAAe,EAAE,UAAU,OAAO,KAAK,CAAC;AAAA,IACrE;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,SAAK,SAAS,OAAO;AACrB,SAAK,UAAU;AAAA,EACjB;AACF;AAEA,SAAS,cAAc,EAAE,UAAU,KAAK,QAAQ,UAAU,GAAyB;AACjF,QAAM,QAAQ;AACd,QAAM,EAAE,EAAE,IAAI,UAAU;AACxB,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAE1C,YAAU,MAAM,UAAU,KAAK,GAAG,CAAC,MAAM,GAAG,CAAC;AAE7C,MAAI,QAAQ;AAMV,WACE,oBAAC,SAAI,WAAW,GAAG,+CAA+C,SAAS,GACzE;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAO,EAAE,+BAA+B;AAAA,QACxC,aAAa,EAAE,4BAA4B,EAAE,MAAM,OAAO,KAAK,CAAC;AAAA;AAAA,IAClE,GACF;AAAA,EAEJ;AAEA,QAAM,QAAQ,EAAE,sBAAsB,EAAE,MAAM,OAAO,KAAK,CAAC;AAE3D,MAAI,MAAM,UAAU,SAAS;AAC3B,WACE,oBAAC,SAAI,WAAW,GAAG,+CAA+C,SAAS,GACzE;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,MAAM;AAAA,QACX,UAAQ;AAAA,QACR,cAAY;AAAA,QACZ,SAAS,MAAM,UAAU,IAAI;AAAA,QAC7B,WAAU;AAAA;AAAA,IACZ,GACF;AAAA,EAEJ;AAEA,SACE,oBAAC,SAAI,WAAW,GAAG,+CAA+C,SAAS,GACzE;AAAA,IAAC;AAAA;AAAA,MACC,KAAK,MAAM;AAAA,MACX,UAAQ;AAAA,MAGR,cAAY;AAAA,MACZ,SAAS,MAAM,UAAU,IAAI;AAAA,MAC7B,WAAU;AAAA;AAAA,EACZ,GACF;AAEJ;AAEA,IAAM,gBAA+B;AAAA,EACnC,UAAU;AAAA,EACV,QAAQ,MAAM,IAAI,aAAa;AAAA,EAC/B,UAAU;AACZ;AAEA,IAAO,wBAAQ;","names":[]}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use client";
|
|
3
|
+
import {
|
|
4
|
+
usePageControl,
|
|
5
|
+
usePagedScroll,
|
|
6
|
+
useViewportSize
|
|
7
|
+
} from "./chunk-4OFQYALG.js";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_ZOOM,
|
|
10
|
+
openPdfDocument,
|
|
11
|
+
pdfManifest
|
|
12
|
+
} from "./chunk-SLXRUYT3.js";
|
|
13
|
+
import {
|
|
14
|
+
createTextIndexBuilder,
|
|
15
|
+
spanAt
|
|
16
|
+
} from "./chunk-UL43NGUG.js";
|
|
17
|
+
import {
|
|
18
|
+
localizeRanges,
|
|
19
|
+
toMarkRanges,
|
|
20
|
+
useScrollActiveHighlightIntoView
|
|
21
|
+
} from "./chunk-VO2273Z2.js";
|
|
22
|
+
import {
|
|
23
|
+
toViewerError
|
|
24
|
+
} from "./chunk-AORNMH77.js";
|
|
25
|
+
|
|
26
|
+
// src/adapters/pdf/pdf-adapter.tsx
|
|
27
|
+
import { cn, StatePanel, useLocale } from "@elabs-ai/components-ui";
|
|
28
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
29
|
+
|
|
30
|
+
// src/adapters/pdf/pdf-text.ts
|
|
31
|
+
var PDF_ITEM_SEPARATOR = " ";
|
|
32
|
+
var PDF_PAGE_SEPARATOR = "\n\n";
|
|
33
|
+
function pageTextChunk(items) {
|
|
34
|
+
const spans = [];
|
|
35
|
+
const parts = [];
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
items.forEach((item, index) => {
|
|
38
|
+
if (index > 0) cursor += PDF_ITEM_SEPARATOR.length;
|
|
39
|
+
parts.push(item.str);
|
|
40
|
+
spans.push({ start: cursor, end: cursor + item.str.length });
|
|
41
|
+
cursor += item.str.length;
|
|
42
|
+
});
|
|
43
|
+
return { text: parts.join(PDF_ITEM_SEPARATOR), spans };
|
|
44
|
+
}
|
|
45
|
+
var FALLBACK_ADVANCE = 0.5;
|
|
46
|
+
function rangeBoxes(spans, chunk, pageStart, marks) {
|
|
47
|
+
if (marks.ranges.length === 0) return [];
|
|
48
|
+
const boxes = [];
|
|
49
|
+
for (const span of spans) {
|
|
50
|
+
const item = chunk.spans[span.item];
|
|
51
|
+
if (!item || item.end === item.start) continue;
|
|
52
|
+
const local = localizeRanges(marks, pageStart + item.start, pageStart + item.end);
|
|
53
|
+
if (local.ranges.length === 0) continue;
|
|
54
|
+
const length = item.end - item.start;
|
|
55
|
+
const advance = span.width > 0 ? span.width / length : span.fontSize * FALLBACK_ADVANCE;
|
|
56
|
+
local.ranges.forEach(([from, to], index) => {
|
|
57
|
+
boxes.push({
|
|
58
|
+
left: span.left + advance * from,
|
|
59
|
+
top: span.top,
|
|
60
|
+
width: advance * (to - from),
|
|
61
|
+
height: span.fontSize,
|
|
62
|
+
active: index === local.activeIndex
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return boxes;
|
|
67
|
+
}
|
|
68
|
+
function rectBoxes(highlights, size) {
|
|
69
|
+
const boxes = [];
|
|
70
|
+
for (const highlight of highlights) {
|
|
71
|
+
for (const rect of highlight.rects ?? []) {
|
|
72
|
+
boxes.push({
|
|
73
|
+
left: clampFraction(rect.x) * size.width,
|
|
74
|
+
top: clampFraction(rect.y) * size.height,
|
|
75
|
+
width: clampFraction(rect.width) * size.width,
|
|
76
|
+
height: clampFraction(rect.height) * size.height,
|
|
77
|
+
active: highlight.active
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return boxes;
|
|
82
|
+
}
|
|
83
|
+
function clampFraction(value) {
|
|
84
|
+
if (!Number.isFinite(value)) return 0;
|
|
85
|
+
return Math.max(0, Math.min(1, value));
|
|
86
|
+
}
|
|
87
|
+
function pageOfHighlight(highlight, index) {
|
|
88
|
+
if (!highlight || highlight.status !== "resolved") return void 0;
|
|
89
|
+
if (highlight.page !== void 0) return highlight.page;
|
|
90
|
+
if (!highlight.range || !index) return void 0;
|
|
91
|
+
const span = spanAt(index, highlight.range[0]);
|
|
92
|
+
return typeof span?.ref === "number" ? span.ref : void 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/adapters/pdf/pdf-adapter.tsx
|
|
96
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
97
|
+
var FIT_GUTTER = 16;
|
|
98
|
+
var MIN_SCALE = 0.1;
|
|
99
|
+
var MAX_SCALE = 10;
|
|
100
|
+
var PDF_TEXT_PAGE_LIMIT = 50;
|
|
101
|
+
var PAGE_GAP = 16;
|
|
102
|
+
var PAGE_BORDER = 2;
|
|
103
|
+
var FALLBACK_PAGE_HEIGHT = 1056;
|
|
104
|
+
var PdfAdapter = class {
|
|
105
|
+
#session;
|
|
106
|
+
async load(source, context) {
|
|
107
|
+
let buffer;
|
|
108
|
+
try {
|
|
109
|
+
buffer = await source.bytes(context.signal);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw toViewerError(error, "read-failed", { fileName: source.name });
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const session = await openPdfDocument(new Uint8Array(buffer), context.signal);
|
|
115
|
+
this.#session = session;
|
|
116
|
+
const handle = session.document;
|
|
117
|
+
const { textIndex, pageSizes } = await extractText(handle, context.signal);
|
|
118
|
+
return {
|
|
119
|
+
kind: "pdf",
|
|
120
|
+
handle,
|
|
121
|
+
pageCount: handle.numPages,
|
|
122
|
+
pageSizes,
|
|
123
|
+
text: textIndex?.text,
|
|
124
|
+
textIndex,
|
|
125
|
+
// So a passage the shell cannot find in a 900-page report reads as "past
|
|
126
|
+
// the pages we extracted" rather than "not in this document".
|
|
127
|
+
textTruncated: handle.numPages > PDF_TEXT_PAGE_LIMIT || void 0
|
|
128
|
+
};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw toViewerError(error, "parse-failed", { fileName: source.name });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
dispose() {
|
|
134
|
+
void this.#session?.destroy();
|
|
135
|
+
this.#session = void 0;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
async function extractText(handle, signal) {
|
|
139
|
+
const pages = Math.min(handle.numPages, PDF_TEXT_PAGE_LIMIT);
|
|
140
|
+
const builder = createTextIndexBuilder({ separator: PDF_PAGE_SEPARATOR });
|
|
141
|
+
const pageSizes = [];
|
|
142
|
+
try {
|
|
143
|
+
for (let pageNumber = 1; pageNumber <= pages; pageNumber += 1) {
|
|
144
|
+
if (signal?.aborted) break;
|
|
145
|
+
const page = await handle.getPage(pageNumber);
|
|
146
|
+
const { width, height } = page.getViewport({ scale: 1 });
|
|
147
|
+
pageSizes.push({ width, height });
|
|
148
|
+
const content = await page.getTextContent();
|
|
149
|
+
builder.push(pageTextChunk(content.items).text, pageNumber);
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
}
|
|
153
|
+
const index = builder.build();
|
|
154
|
+
return {
|
|
155
|
+
textIndex: index.text.length > 0 ? index : void 0,
|
|
156
|
+
pageSizes: pageSizes.length > 0 ? pageSizes : void 0
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function PdfRenderer({
|
|
160
|
+
document: doc,
|
|
161
|
+
source,
|
|
162
|
+
className,
|
|
163
|
+
highlights,
|
|
164
|
+
activeHighlightId,
|
|
165
|
+
pageNumber: pageNumberProp,
|
|
166
|
+
onPageChange,
|
|
167
|
+
zoom = DEFAULT_ZOOM,
|
|
168
|
+
onZoomResolved
|
|
169
|
+
}) {
|
|
170
|
+
const pdf = doc;
|
|
171
|
+
const [pageNumber, goToPage] = usePageControl(pageNumberProp, onPageChange, pdf.pageCount);
|
|
172
|
+
const listRef = useRef(null);
|
|
173
|
+
const viewport = useViewportSize(listRef);
|
|
174
|
+
const naturalAt = useCallback(
|
|
175
|
+
(index) => pdf.pageSizes?.[index] ?? pdf.pageSizes?.[0],
|
|
176
|
+
[pdf.pageSizes]
|
|
177
|
+
);
|
|
178
|
+
const natural = naturalAt(pageNumber - 1);
|
|
179
|
+
const scale = useMemo(() => {
|
|
180
|
+
if (typeof zoom === "number") return zoom;
|
|
181
|
+
if (!natural || !viewport) return DEFAULT_ZOOM;
|
|
182
|
+
const byWidth = (viewport.width - FIT_GUTTER) / natural.width;
|
|
183
|
+
const fitted = zoom === "fit-width" ? byWidth : Math.min(byWidth, (viewport.height - FIT_GUTTER) / natural.height);
|
|
184
|
+
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, fitted));
|
|
185
|
+
}, [zoom, natural, viewport]);
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
onZoomResolved?.(scale);
|
|
188
|
+
}, [scale, onZoomResolved]);
|
|
189
|
+
const estimateSize = useCallback(
|
|
190
|
+
(index) => Math.round((naturalAt(index)?.height ?? FALLBACK_PAGE_HEIGHT) * scale) + PAGE_BORDER + PAGE_GAP,
|
|
191
|
+
[naturalAt, scale]
|
|
192
|
+
);
|
|
193
|
+
const virtualizer = usePagedScroll({
|
|
194
|
+
count: pdf.pageCount,
|
|
195
|
+
listRef,
|
|
196
|
+
pageNumber,
|
|
197
|
+
goToPage,
|
|
198
|
+
estimateSize,
|
|
199
|
+
sizeKey: scale
|
|
200
|
+
});
|
|
201
|
+
const marks = useMemo(() => toMarkRanges(highlights, pdf.text?.length ?? 0), [highlights, pdf]);
|
|
202
|
+
const activePage = pageOfHighlight(
|
|
203
|
+
highlights?.find((highlight) => highlight.id === activeHighlightId),
|
|
204
|
+
pdf.textIndex
|
|
205
|
+
);
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (activePage === void 0 || activePage < 1 || activePage > pdf.pageCount) return;
|
|
208
|
+
goToPage(activePage);
|
|
209
|
+
}, [activePage, pdf.pageCount, goToPage]);
|
|
210
|
+
return (
|
|
211
|
+
// No viewport of its own: `FileViewerContent` is the one scroll boundary
|
|
212
|
+
// (`viewer-components.md`), and this column is a child of it. The height is
|
|
213
|
+
// the whole document's, so the scrollbar is honest from the first frame
|
|
214
|
+
// rather than growing as pages arrive.
|
|
215
|
+
/* @__PURE__ */ jsx(
|
|
216
|
+
"div",
|
|
217
|
+
{
|
|
218
|
+
ref: listRef,
|
|
219
|
+
"data-slot": "pdf-pages",
|
|
220
|
+
className: cn("relative w-full", className),
|
|
221
|
+
style: { height: virtualizer.getTotalSize() },
|
|
222
|
+
children: virtualizer.getVirtualItems().map((item) => /* @__PURE__ */ jsx(
|
|
223
|
+
"div",
|
|
224
|
+
{
|
|
225
|
+
"data-index": item.index,
|
|
226
|
+
ref: virtualizer.measureElement,
|
|
227
|
+
className: "absolute inset-x-0 top-0",
|
|
228
|
+
style: {
|
|
229
|
+
paddingBottom: PAGE_GAP,
|
|
230
|
+
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`
|
|
231
|
+
},
|
|
232
|
+
children: /* @__PURE__ */ jsx(
|
|
233
|
+
PdfPageView,
|
|
234
|
+
{
|
|
235
|
+
handle: pdf.handle,
|
|
236
|
+
pageNumber: item.index + 1,
|
|
237
|
+
natural: naturalAt(item.index),
|
|
238
|
+
scale,
|
|
239
|
+
alt: source.alt,
|
|
240
|
+
highlights,
|
|
241
|
+
activeHighlightId,
|
|
242
|
+
marks,
|
|
243
|
+
pageStart: pdf.textIndex?.spans.find((span) => span.ref === item.index + 1)?.start
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
},
|
|
247
|
+
item.key
|
|
248
|
+
))
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
function PdfPageView({
|
|
254
|
+
handle,
|
|
255
|
+
pageNumber,
|
|
256
|
+
natural,
|
|
257
|
+
scale,
|
|
258
|
+
alt,
|
|
259
|
+
highlights,
|
|
260
|
+
activeHighlightId,
|
|
261
|
+
marks,
|
|
262
|
+
pageStart
|
|
263
|
+
}) {
|
|
264
|
+
const { t, formatNumber } = useLocale();
|
|
265
|
+
const [size, setSize] = useState(
|
|
266
|
+
natural && { width: natural.width * scale, height: natural.height * scale }
|
|
267
|
+
);
|
|
268
|
+
const [spans, setSpans] = useState([]);
|
|
269
|
+
const [items, setItems] = useState([]);
|
|
270
|
+
const [failed, setFailed] = useState();
|
|
271
|
+
const pageRef = useRef(null);
|
|
272
|
+
const canvasRef = useRef(null);
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
let cancelled = false;
|
|
275
|
+
let task;
|
|
276
|
+
let page;
|
|
277
|
+
void (async () => {
|
|
278
|
+
try {
|
|
279
|
+
page = await handle.getPage(pageNumber);
|
|
280
|
+
if (cancelled) return;
|
|
281
|
+
const ratio = typeof window === "undefined" ? 1 : window.devicePixelRatio ?? 1;
|
|
282
|
+
const viewport = page.getViewport({ scale: scale * ratio });
|
|
283
|
+
const cssViewport = page.getViewport({ scale });
|
|
284
|
+
const canvas = canvasRef.current;
|
|
285
|
+
const context = canvas?.getContext("2d");
|
|
286
|
+
if (!canvas || !context) return;
|
|
287
|
+
canvas.width = Math.floor(viewport.width);
|
|
288
|
+
canvas.height = Math.floor(viewport.height);
|
|
289
|
+
setSize({ width: cssViewport.width, height: cssViewport.height });
|
|
290
|
+
task = page.render({ canvasContext: context, viewport });
|
|
291
|
+
await task.promise;
|
|
292
|
+
if (cancelled) return;
|
|
293
|
+
const content = await page.getTextContent();
|
|
294
|
+
if (cancelled) return;
|
|
295
|
+
setItems(content.items);
|
|
296
|
+
setSpans(toTextSpans(content.items, cssViewport.height, scale));
|
|
297
|
+
setFailed(void 0);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (cancelled) return;
|
|
300
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
301
|
+
if (/cancel/i.test(message)) return;
|
|
302
|
+
setFailed(message);
|
|
303
|
+
}
|
|
304
|
+
})();
|
|
305
|
+
return () => {
|
|
306
|
+
cancelled = true;
|
|
307
|
+
task?.cancel();
|
|
308
|
+
page?.cleanup();
|
|
309
|
+
};
|
|
310
|
+
}, [handle, pageNumber, scale]);
|
|
311
|
+
const boxes = useMemo(() => {
|
|
312
|
+
if (!size) return [];
|
|
313
|
+
const geometry = (highlights ?? []).filter(
|
|
314
|
+
(highlight) => highlight.status === "resolved" && highlight.rects !== void 0 && (highlight.page ?? 1) === pageNumber
|
|
315
|
+
);
|
|
316
|
+
return [
|
|
317
|
+
...pageStart === void 0 ? [] : rangeBoxes(spans, pageTextChunk(items), pageStart, marks),
|
|
318
|
+
...rectBoxes(geometry, size)
|
|
319
|
+
];
|
|
320
|
+
}, [size, highlights, pageNumber, pageStart, spans, items, marks]);
|
|
321
|
+
useScrollActiveHighlightIntoView(pageRef, activeHighlightId, boxes);
|
|
322
|
+
if (failed) {
|
|
323
|
+
return /* @__PURE__ */ jsx(
|
|
324
|
+
"div",
|
|
325
|
+
{
|
|
326
|
+
className: "mx-auto flex w-fit items-center justify-center",
|
|
327
|
+
style: size ? { width: size.width, height: size.height } : void 0,
|
|
328
|
+
children: /* @__PURE__ */ jsx(
|
|
329
|
+
StatePanel,
|
|
330
|
+
{
|
|
331
|
+
kind: "error",
|
|
332
|
+
title: t("viewer.error.parseFailed"),
|
|
333
|
+
description: t("viewer.pdf.pageFailed", {
|
|
334
|
+
page: formatNumber(pageNumber)
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return /* @__PURE__ */ jsxs(
|
|
342
|
+
"div",
|
|
343
|
+
{
|
|
344
|
+
ref: pageRef,
|
|
345
|
+
"data-slot": "pdf-page",
|
|
346
|
+
"data-page": pageNumber,
|
|
347
|
+
className: "border-border relative mx-auto w-fit border shadow-sm",
|
|
348
|
+
style: size ? { width: size.width, height: size.height } : void 0,
|
|
349
|
+
children: [
|
|
350
|
+
/* @__PURE__ */ jsx(
|
|
351
|
+
"canvas",
|
|
352
|
+
{
|
|
353
|
+
ref: canvasRef,
|
|
354
|
+
role: "img",
|
|
355
|
+
"aria-label": alt ?? t("viewer.pdf.page", { page: formatNumber(pageNumber) }),
|
|
356
|
+
className: "block",
|
|
357
|
+
style: size ? { width: size.width, height: size.height } : void 0
|
|
358
|
+
}
|
|
359
|
+
),
|
|
360
|
+
/* @__PURE__ */ jsx("div", { "aria-hidden": spans.length === 0, className: "absolute inset-0 select-text", children: spans.map((span, index) => /* @__PURE__ */ jsx(
|
|
361
|
+
"span",
|
|
362
|
+
{
|
|
363
|
+
className: "absolute origin-top-left whitespace-pre text-transparent",
|
|
364
|
+
style: {
|
|
365
|
+
left: span.left,
|
|
366
|
+
top: span.top,
|
|
367
|
+
fontSize: span.fontSize,
|
|
368
|
+
lineHeight: 1
|
|
369
|
+
},
|
|
370
|
+
children: span.text
|
|
371
|
+
},
|
|
372
|
+
index
|
|
373
|
+
)) }),
|
|
374
|
+
/* @__PURE__ */ jsx("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0", children: boxes.map((box, index) => /* @__PURE__ */ jsx(
|
|
375
|
+
"div",
|
|
376
|
+
{
|
|
377
|
+
"data-slot": "highlight-rect",
|
|
378
|
+
"data-active": box.active ? "" : void 0,
|
|
379
|
+
className: cn(
|
|
380
|
+
"absolute rounded-xs",
|
|
381
|
+
box.active ? "bg-highlight-active/30 border-highlight-active border-2" : "bg-highlight/35 border-highlight border"
|
|
382
|
+
),
|
|
383
|
+
style: {
|
|
384
|
+
left: box.left,
|
|
385
|
+
top: box.top,
|
|
386
|
+
width: box.width,
|
|
387
|
+
height: box.height
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
index
|
|
391
|
+
)) })
|
|
392
|
+
]
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
function toTextSpans(items, pageHeight, scale) {
|
|
397
|
+
const spans = [];
|
|
398
|
+
items.forEach((item, index) => {
|
|
399
|
+
if (!item.str.trim()) return;
|
|
400
|
+
const [, , , d = 0, e = 0, f = 0] = item.transform;
|
|
401
|
+
const fontSize = Math.abs(d) * scale;
|
|
402
|
+
spans.push({
|
|
403
|
+
text: item.str,
|
|
404
|
+
left: e * scale,
|
|
405
|
+
top: pageHeight - f * scale - fontSize,
|
|
406
|
+
width: (item.width ?? 0) * scale,
|
|
407
|
+
fontSize,
|
|
408
|
+
item: index
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
return spans;
|
|
412
|
+
}
|
|
413
|
+
var adapterModule = {
|
|
414
|
+
manifest: pdfManifest,
|
|
415
|
+
create: () => new PdfAdapter(),
|
|
416
|
+
Renderer: PdfRenderer
|
|
417
|
+
};
|
|
418
|
+
var pdf_adapter_default = adapterModule;
|
|
419
|
+
export {
|
|
420
|
+
PDF_TEXT_PAGE_LIMIT,
|
|
421
|
+
pdf_adapter_default as default,
|
|
422
|
+
toTextSpans
|
|
423
|
+
};
|
|
424
|
+
//# sourceMappingURL=pdf-adapter-5PMKEXUD.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/adapters/pdf/pdf-adapter.tsx","../src/adapters/pdf/pdf-text.ts"],"sourcesContent":["\"use client\";\n\n/**\n * PDF adapter — the format nothing in this repo could open before.\n *\n * ## Why this one renders a canvas, and why that is not a rule break\n *\n * \"Adapters emit data, never HTML\" is about CHROME: an adapter may not smuggle\n * colours, fonts or borders past the token layer. A PDF page's pixels ARE the\n * document — a PDF is a description of marks on a page, and reproducing it is\n * the whole job. So the page is rasterized to a `<canvas>` in the file's own\n * colours, while everything AROUND it (the pager, the zoom control, the page\n * frame, the loading and error states) is brand-ui components on tokens.\n *\n * ## The text layer\n *\n * A canvas has no text, so a canvas-only PDF viewer is unusable: nothing to\n * select, copy, search or read aloud. pdf.js exposes the text with per-item\n * transforms, so we position transparent spans over the page. That is the same\n * approach pdf.js's own viewer takes, and it is what makes the page selectable\n * and reachable by a screen reader.\n *\n * ## Highlights are boxes, not marks\n *\n * Every other text adapter paints a `<mark>` around the cited characters. There\n * is no text to wrap here — the words are pixels — so a citation is a rectangle\n * over the raster, positioned from the page's own geometry (`pdf-text.ts`). The\n * same layer draws a `rect` address, which is why this is the one adapter that\n * can honour geometry a text pipeline never produced.\n *\n * ## The chrome is not here\n *\n * The pager and the zoom control used to live in this file, over `useState` no\n * one outside the canvas could reach. They are now shell parts over provider\n * state (ADR 0026): this renderer draws the page it is TOLD to, at the scale it\n * is told to, and reports back what a fit mode resolved to. It keeps its own\n * page only when mounted outside a provider.\n *\n * ## Pages scroll; they are not flipped\n *\n * The document is one continuous column of pages, virtualized over the shell's\n * viewport (`usePagedScroll`). A pager that replaced the canvas made a PDF the\n * only thing in the app you could not simply scroll, and it broke the two\n * gestures readers actually use — skimming, and dragging the scrollbar to\n * \"about two thirds in\". The page number is now a consequence of where the\n * reader is, and typing one is a scroll.\n */\n\nimport { cn, StatePanel, useLocale, type ResolvedFileSource } from \"@elabs-ai/components-ui\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { toViewerError } from \"../../core/errors\";\nimport type { ResolvedHighlight } from \"../../core/highlight\";\nimport { toMarkRanges, type MarkRanges } from \"../../core/highlight-marks\";\nimport { createTextIndexBuilder, type TextIndex } from \"../../core/text-index\";\nimport { useScrollActiveHighlightIntoView } from \"../../core/use-highlight-scroll\";\nimport { usePageControl } from \"../../core/use-page-control\";\nimport { usePagedScroll } from \"../../core/use-paged-scroll\";\nimport { useViewportSize } from \"../../core/use-viewport-size\";\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n PageSize,\n} from \"../../core/types\";\nimport { DEFAULT_ZOOM } from \"../../core/zoom\";\nimport {\n openPdfDocument,\n type PdfDocument,\n type PdfPage,\n type PdfSession,\n type PdfTextItem,\n} from \"./pdf-engine\";\nimport { pdfManifest } from \"./pdf-manifest\";\nimport {\n PDF_PAGE_SEPARATOR,\n pageOfHighlight,\n pageTextChunk,\n rangeBoxes,\n rectBoxes,\n type PdfHighlightBox,\n type PdfTextSpan,\n} from \"./pdf-text\";\n\n/**\n * Breathing room kept around a fitted page, in CSS pixels.\n *\n * Without it a fit-to-width page is exactly as wide as the viewport, and the\n * page frame's own 1px border tips it over into a horizontal scrollbar — which\n * narrows the viewport, which re-fits smaller, which removes the scrollbar.\n */\nconst FIT_GUTTER = 16;\n\n/** Sanity bounds on a fitted scale, so a degenerate measurement cannot blank the page. */\nconst MIN_SCALE = 0.1;\nconst MAX_SCALE = 10;\n\n/**\n * Pages whose text is extracted for the plain-text projection.\n *\n * Extraction costs one worker round-trip per page, so doing it for a 900-page\n * document at load time would stall the first paint for something most readers\n * never use. The pages a reader actually looks at get their text layer rendered\n * on demand regardless — this cap only bounds the eager `document.text`.\n */\nexport const PDF_TEXT_PAGE_LIMIT = 50;\n\nexport interface PdfViewerDocument extends AdapterDocument {\n kind: \"pdf\";\n /** The live pdf.js handle. Owned by the adapter — the renderer only reads it. */\n handle: PdfDocument;\n pageCount: number;\n /** Page number per stretch of the projection, so a citation can turn the page. */\n textIndex?: TextIndex<number>;\n}\n\n/** Vertical space between two pages in the scrolling column, in CSS pixels. */\nconst PAGE_GAP = 16;\n\n/** The page frame's own 1px border, top and bottom — part of the reserved height. */\nconst PAGE_BORDER = 2;\n\n/**\n * Height assumed for a page whose size was never measured, in CSS pixels.\n *\n * Only reached by a document with no extractable page at all; every real one\n * contributes at least page 1 to `pageSizes`. US Letter at 96dpi.\n */\nconst FALLBACK_PAGE_HEIGHT = 1056;\n\nclass PdfAdapter implements FileAdapter {\n #session?: PdfSession;\n\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<PdfViewerDocument> {\n let buffer: ArrayBuffer;\n try {\n buffer = await source.bytes(context.signal);\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n\n try {\n // pdf.js takes ownership of the buffer it is handed (it transfers it to\n // the worker), so the view is built here rather than reusing the source's\n // memoized copy in place.\n const session = await openPdfDocument(new Uint8Array(buffer), context.signal);\n this.#session = session;\n const handle = session.document;\n const { textIndex, pageSizes } = await extractText(handle, context.signal);\n return {\n kind: \"pdf\",\n handle,\n pageCount: handle.numPages,\n pageSizes,\n text: textIndex?.text,\n textIndex,\n // So a passage the shell cannot find in a 900-page report reads as \"past\n // the pages we extracted\" rather than \"not in this document\".\n textTruncated: handle.numPages > PDF_TEXT_PAGE_LIMIT || undefined,\n };\n } catch (error) {\n // A password-protected or corrupt file lands here; the engine failing to\n // resolve is caught upstream and becomes `parser-missing`.\n throw toViewerError(error, \"parse-failed\", { fileName: source.name });\n }\n }\n\n dispose(): void {\n // Tears down the worker and its transferred buffers. Skipping this leaks a\n // worker per document opened, which a file browser hits within minutes.\n // The teardown lives on the SESSION, not on the document — see `PdfSession`.\n void this.#session?.destroy();\n this.#session = undefined;\n }\n}\n\n/**\n * Best-effort plain text, plus the page each stretch of it came from — and each\n * page's size, which the same loop already has in hand.\n *\n * Never throws: a document whose text will not extract still renders — the\n * reader loses search and citations, not the file. Whatever was extracted before\n * the failure is kept, because half a projection still answers half the queries.\n *\n * The index is built with the SAME join rule the renderer re-applies per page\n * (`pageTextChunk`), so an offset in the projection and a box on the page can\n * never disagree about which item they mean.\n *\n * The sizes are collected here rather than in their own pass because this loop\n * has already paid for `getPage` — a second pass over a 900-page document would\n * be 900 worker round-trips for numbers we are holding.\n */\nasync function extractText(\n handle: PdfDocument,\n signal?: AbortSignal,\n): Promise<{ textIndex?: TextIndex<number>; pageSizes?: PageSize[] }> {\n const pages = Math.min(handle.numPages, PDF_TEXT_PAGE_LIMIT);\n const builder = createTextIndexBuilder<number>({ separator: PDF_PAGE_SEPARATOR });\n const pageSizes: PageSize[] = [];\n try {\n for (let pageNumber = 1; pageNumber <= pages; pageNumber += 1) {\n if (signal?.aborted) break;\n const page = await handle.getPage(pageNumber);\n const { width, height } = page.getViewport({ scale: 1 });\n pageSizes.push({ width, height });\n const content = await page.getTextContent();\n builder.push(pageTextChunk(content.items).text, pageNumber);\n }\n } catch {\n // Fall through: return what we have.\n }\n const index = builder.build();\n return {\n textIndex: index.text.length > 0 ? index : undefined,\n pageSizes: pageSizes.length > 0 ? pageSizes : undefined,\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Renderer */\n/* -------------------------------------------------------------------------- */\n\nfunction PdfRenderer({\n document: doc,\n source,\n className,\n highlights,\n activeHighlightId,\n pageNumber: pageNumberProp,\n onPageChange,\n zoom = DEFAULT_ZOOM,\n onZoomResolved,\n}: AdapterRendererProps) {\n const pdf = doc as PdfViewerDocument;\n\n const [pageNumber, goToPage] = usePageControl(pageNumberProp, onPageChange, pdf.pageCount);\n\n const listRef = useRef<HTMLDivElement>(null);\n const viewport = useViewportSize(listRef);\n\n /**\n * A page's size at scale 1.\n *\n * Falls back to the first page for anything past what the adapter measured\n * (`AdapterDocument.pageSizes` may be shorter than the page count). Uniform\n * page size is overwhelmingly the common case, and a wrong guess costs a\n * corrected scrollbar, not a wrong render.\n */\n const naturalAt = useCallback(\n (index: number): PageSize | undefined => pdf.pageSizes?.[index] ?? pdf.pageSizes?.[0],\n [pdf.pageSizes],\n );\n\n // A fit mode is a REQUEST the renderer resolves — it is the only party that\n // knows how wide its viewport is (`AdapterRendererProps.zoom`). Fitted to the\n // page the reader is ON, so a landscape page in a portrait document does not\n // re-fit the whole column.\n const natural = naturalAt(pageNumber - 1);\n const scale = useMemo(() => {\n if (typeof zoom === \"number\") return zoom;\n // Until the page and the pane have both been measured, 100% is a better\n // first frame than a guess that jumps.\n if (!natural || !viewport) return DEFAULT_ZOOM;\n const byWidth = (viewport.width - FIT_GUTTER) / natural.width;\n const fitted =\n zoom === \"fit-width\"\n ? byWidth\n : Math.min(byWidth, (viewport.height - FIT_GUTTER) / natural.height);\n return Math.min(MAX_SCALE, Math.max(MIN_SCALE, fitted));\n }, [zoom, natural, viewport]);\n\n // Report what the fit became, so the chrome can show a percentage and the\n // reader's next \"zoom in\" steps from what is on screen.\n useEffect(() => {\n onZoomResolved?.(scale);\n }, [scale, onZoomResolved]);\n\n const estimateSize = useCallback(\n (index: number) =>\n Math.round((naturalAt(index)?.height ?? FALLBACK_PAGE_HEIGHT) * scale) +\n PAGE_BORDER +\n PAGE_GAP,\n [naturalAt, scale],\n );\n\n const virtualizer = usePagedScroll({\n count: pdf.pageCount,\n listRef,\n pageNumber,\n goToPage,\n estimateSize,\n sizeKey: scale,\n });\n\n // LOCATE happened in the shell; the per-page views below do MAP and PAINT.\n // Computed once here because it is a function of the whole projection, not of\n // any one page.\n const marks = useMemo(() => toMarkRanges(highlights, pdf.text?.length ?? 0), [highlights, pdf]);\n\n // Scrolling to the cited page is the first half of \"take me there\"; the mark's\n // own `scrollIntoView`, inside the page view, is the second. Keyed on the page\n // NUMBER, so a reader who scrolls away while the same citation stays active is\n // not dragged back.\n const activePage = pageOfHighlight(\n highlights?.find((highlight) => highlight.id === activeHighlightId),\n pdf.textIndex,\n );\n useEffect(() => {\n if (activePage === undefined || activePage < 1 || activePage > pdf.pageCount) return;\n goToPage(activePage);\n }, [activePage, pdf.pageCount, goToPage]);\n\n return (\n // No viewport of its own: `FileViewerContent` is the one scroll boundary\n // (`viewer-components.md`), and this column is a child of it. The height is\n // the whole document's, so the scrollbar is honest from the first frame\n // rather than growing as pages arrive.\n <div\n ref={listRef}\n data-slot=\"pdf-pages\"\n className={cn(\"relative w-full\", className)}\n style={{ height: virtualizer.getTotalSize() }}\n >\n {virtualizer.getVirtualItems().map((item) => (\n <div\n key={item.key}\n data-index={item.index}\n ref={virtualizer.measureElement}\n className=\"absolute inset-x-0 top-0\"\n style={{\n paddingBottom: PAGE_GAP,\n transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,\n }}\n >\n <PdfPageView\n handle={pdf.handle}\n pageNumber={item.index + 1}\n natural={naturalAt(item.index)}\n scale={scale}\n alt={source.alt}\n highlights={highlights}\n activeHighlightId={activeHighlightId}\n marks={marks}\n pageStart={pdf.textIndex?.spans.find((span) => span.ref === item.index + 1)?.start}\n />\n </div>\n ))}\n </div>\n );\n}\n\ninterface PdfPageViewProps {\n handle: PdfDocument;\n /** 1-based, as everything on the wire is. */\n pageNumber: number;\n /** This page at scale 1, when the adapter measured it. Reserves the box. */\n natural?: PageSize;\n scale: number;\n alt?: string;\n highlights?: readonly ResolvedHighlight[];\n activeHighlightId?: string | null;\n marks: MarkRanges;\n /** Where this page's text begins in the projection, if it was extracted. */\n pageStart?: number;\n}\n\n/**\n * One page: the raster, the transparent text over it, and any citation boxes.\n *\n * A component per page rather than one effect over the visible range, so that\n * mounting and unmounting IS the lifecycle — a page scrolled out of the window\n * cancels its own render task and releases its own pdf.js page, with no\n * bookkeeping to get wrong.\n */\nfunction PdfPageView({\n handle,\n pageNumber,\n natural,\n scale,\n alt,\n highlights,\n activeHighlightId,\n marks,\n pageStart,\n}: PdfPageViewProps) {\n const { t, formatNumber } = useLocale();\n\n const [size, setSize] = useState<{ width: number; height: number } | undefined>(\n natural && { width: natural.width * scale, height: natural.height * scale },\n );\n const [spans, setSpans] = useState<PdfTextSpan[]>([]);\n const [items, setItems] = useState<PdfTextItem[]>([]);\n const [failed, setFailed] = useState<string>();\n\n const pageRef = useRef<HTMLDivElement>(null);\n const canvasRef = useRef<HTMLCanvasElement>(null);\n\n useEffect(() => {\n let cancelled = false;\n let task: ReturnType<PdfPage[\"render\"]> | undefined;\n let page: PdfPage | undefined;\n\n void (async () => {\n try {\n page = await handle.getPage(pageNumber);\n if (cancelled) return;\n\n // Rasterize at the device pixel ratio, then present at CSS size — a 1:1\n // canvas is visibly soft on every retina display.\n const ratio = typeof window === \"undefined\" ? 1 : (window.devicePixelRatio ?? 1);\n const viewport = page.getViewport({ scale: scale * ratio });\n const cssViewport = page.getViewport({ scale });\n const canvas = canvasRef.current;\n const context = canvas?.getContext(\"2d\");\n if (!canvas || !context) return;\n\n canvas.width = Math.floor(viewport.width);\n canvas.height = Math.floor(viewport.height);\n setSize({ width: cssViewport.width, height: cssViewport.height });\n\n task = page.render({ canvasContext: context, viewport });\n await task.promise;\n if (cancelled) return;\n\n const content = await page.getTextContent();\n if (cancelled) return;\n setItems(content.items);\n setSpans(toTextSpans(content.items, cssViewport.height, scale));\n setFailed(undefined);\n } catch (error) {\n // A cancelled render is the normal outcome of scrolling quickly; only a\n // settled failure is news (loading-states.md).\n if (cancelled) return;\n const message = error instanceof Error ? error.message : String(error);\n if (/cancel/i.test(message)) return;\n setFailed(message);\n }\n })();\n\n return () => {\n cancelled = true;\n task?.cancel();\n page?.cleanup();\n };\n }, [handle, pageNumber, scale]);\n\n // MAP + PAINT. Turning the shell's offsets into rectangles needs this page's\n // own items, which the effect above already fetched for the text layer.\n const boxes = useMemo<PdfHighlightBox[]>(() => {\n if (!size) return [];\n // A `rect` address is already geometry — it only has to be on this page.\n // `page` defaults to 1 so a single-page citation needs no page number.\n const geometry = (highlights ?? []).filter(\n (highlight) =>\n highlight.status === \"resolved\" &&\n highlight.rects !== undefined &&\n (highlight.page ?? 1) === pageNumber,\n );\n return [\n ...(pageStart === undefined ? [] : rangeBoxes(spans, pageTextChunk(items), pageStart, marks)),\n ...rectBoxes(geometry, size),\n ];\n }, [size, highlights, pageNumber, pageStart, spans, items, marks]);\n\n // The boxes arrive a tick after the page does (the text content is fetched\n // asynchronously), so the scroll waits for them rather than for the id.\n useScrollActiveHighlightIntoView(pageRef, activeHighlightId, boxes);\n\n if (failed) {\n return (\n <div\n className=\"mx-auto flex w-fit items-center justify-center\"\n style={size ? { width: size.width, height: size.height } : undefined}\n >\n <StatePanel\n kind=\"error\"\n title={t(\"viewer.error.parseFailed\")}\n description={t(\"viewer.pdf.pageFailed\", {\n page: formatNumber(pageNumber),\n })}\n />\n </div>\n );\n }\n\n return (\n <div\n ref={pageRef}\n data-slot=\"pdf-page\"\n data-page={pageNumber}\n className=\"border-border relative mx-auto w-fit border shadow-sm\"\n style={size ? { width: size.width, height: size.height } : undefined}\n >\n <canvas\n ref={canvasRef}\n // The page's own description, so a screen reader gets what the document\n // is rather than \"canvas\". The text layer below carries the words\n // themselves. Every page names its own number, which is what makes a\n // continuous column navigable by heading-less landmark browsing.\n role=\"img\"\n aria-label={alt ?? t(\"viewer.pdf.page\", { page: formatNumber(pageNumber) })}\n className=\"block\"\n style={size ? { width: size.width, height: size.height } : undefined}\n />\n {/* Transparent, selectable text over the raster. `select-text` and the\n transforms come from pdf.js's own item geometry. */}\n <div aria-hidden={spans.length === 0} className=\"absolute inset-0 select-text\">\n {spans.map((span, index) => (\n <span\n key={index}\n className=\"absolute origin-top-left whitespace-pre text-transparent\"\n style={{\n left: span.left,\n top: span.top,\n fontSize: span.fontSize,\n lineHeight: 1,\n }}\n >\n {span.text}\n </span>\n ))}\n </div>\n {/* Decorative: the words themselves are in the text layer above, and the\n shell's status line is what names the citation. A plate here would\n otherwise be read out as an unlabelled region.\n\n Translucent rather than a solid plate, because the \"ink\" it sits on is\n the raster below it — an opaque mark would delete the very sentence it\n is pointing at. The current box is not distinguished by colour alone\n (WCAG 1.4.1): it is also drawn twice as thick. */}\n <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n {boxes.map((box, index) => (\n <div\n key={index}\n data-slot=\"highlight-rect\"\n // Presence form, matching every other painter's `data-active`.\n data-active={box.active ? \"\" : undefined}\n className={cn(\n \"absolute rounded-xs\",\n box.active\n ? \"bg-highlight-active/30 border-highlight-active border-2\"\n : \"bg-highlight/35 border-highlight border\",\n )}\n style={{\n left: box.left,\n top: box.top,\n width: box.width,\n height: box.height,\n }}\n />\n ))}\n </div>\n </div>\n );\n}\n\n/**\n * pdf.js text geometry → CSS boxes.\n *\n * Item transforms are `[a, b, c, d, e, f]` in PDF user space, whose origin is\n * the BOTTOM-left of the page; CSS measures from the top. `d` carries the glyph\n * height, which is the font size once scaled.\n *\n * Whitespace-only items are dropped — they would be invisible clutter in the\n * text layer — so each span carries the index of the item it came from. That is\n * what lets a highlight box find its geometry after the filtering, without\n * either side counting positions.\n */\nexport function toTextSpans(\n items: { str: string; transform: number[]; width?: number }[],\n pageHeight: number,\n scale: number,\n): PdfTextSpan[] {\n const spans: PdfTextSpan[] = [];\n items.forEach((item, index) => {\n if (!item.str.trim()) return;\n const [, , , d = 0, e = 0, f = 0] = item.transform;\n const fontSize = Math.abs(d) * scale;\n spans.push({\n text: item.str,\n left: e * scale,\n top: pageHeight - f * scale - fontSize,\n width: (item.width ?? 0) * scale,\n fontSize,\n item: index,\n });\n });\n return spans;\n}\n\nconst adapterModule: AdapterModule = {\n manifest: pdfManifest,\n create: () => new PdfAdapter(),\n Renderer: PdfRenderer,\n};\n\nexport default adapterModule;\n","/**\n * PDF text ↔ page geometry — the MAP half of the highlight funnel for a raster.\n *\n * A PDF page is pixels. There is no DOM text to wrap in a `<mark>`, so the paint\n * step here is a layer of BOXES over the canvas, and this module is what turns\n * \"characters 812–847 of the projection\" into those boxes.\n *\n * ## Why the projection is rebuilt rather than remembered\n *\n * `AdapterDocument.text` is assembled at LOAD time, one page at a time; the\n * renderer re-fetches the current page's text content anyway (it needs the same\n * items to position the selectable text layer). So both sides call\n * {@link pageTextChunk}, and the projection and the geometry agree by\n * construction instead of by two hand-kept copies of the same join rule.\n *\n * ## Why the boxes are proportional, and what that costs\n *\n * pdf.js reports one box per text ITEM — a run of glyphs — and no per-character\n * geometry. When a range covers only part of an item, the box is cut\n * proportionally by character count. That is a linear approximation of a\n * proportional font, so it can be off by a glyph or two inside a long run; PDF\n * items are usually a word or a line fragment, where the error is invisible.\n *\n * The alternative — DOM ranges over the transparent text layer — would be WORSE\n * here, not better: that layer renders in a generic font at pdf.js's transform,\n * so its glyph advances do not match the raster underneath it at all. The item\n * box comes from the file itself and does.\n */\n\nimport type { DocumentRect } from \"@elabs-ai/components-ui\";\n\nimport type { ResolvedHighlight } from \"../../core/highlight\";\nimport { localizeRanges, type MarkRanges } from \"../../core/highlight-marks\";\nimport { spanAt, type TextIndex } from \"../../core/text-index\";\n\n/** Written between two text items of the same page. */\nexport const PDF_ITEM_SEPARATOR = \" \";\n\n/** Written between two pages of the projection. */\nexport const PDF_PAGE_SEPARATOR = \"\\n\\n\";\n\n/** The part of a pdf.js text item this module needs. */\nexport interface PdfTextItemLike {\n str: string;\n}\n\n/** Half-open `[start, end)` into the page's own chunk, one per item. */\nexport interface PdfItemSpan {\n start: number;\n end: number;\n}\n\n/** One page's contribution to the projection, plus where each item landed in it. */\nexport interface PdfPageChunk {\n text: string;\n /** Index-aligned with the items passed in — including the empty ones. */\n spans: readonly PdfItemSpan[];\n}\n\n/**\n * Join a page's text items the way the projection does, and record each item's\n * offsets.\n *\n * Empty items keep their place: they still consume a separator, so an item's\n * index into this array is the same index pdf.js handed back. Dropping them\n * would shift every following offset by one and put every mark a character off.\n */\nexport function pageTextChunk(items: readonly PdfTextItemLike[]): PdfPageChunk {\n const spans: PdfItemSpan[] = [];\n const parts: string[] = [];\n let cursor = 0;\n\n items.forEach((item, index) => {\n if (index > 0) cursor += PDF_ITEM_SEPARATOR.length;\n parts.push(item.str);\n spans.push({ start: cursor, end: cursor + item.str.length });\n cursor += item.str.length;\n });\n\n return { text: parts.join(PDF_ITEM_SEPARATOR), spans };\n}\n\n/** A positioned text item, in CSS pixels relative to the rendered page. */\nexport interface PdfTextSpan {\n text: string;\n left: number;\n top: number;\n /** The item's own advance width. `0` when pdf.js did not report one. */\n width: number;\n fontSize: number;\n /** Index into the page's original items — the seam back to the projection. */\n item: number;\n}\n\n/** A rectangle to paint over the page, in CSS pixels. */\nexport interface PdfHighlightBox {\n left: number;\n top: number;\n width: number;\n height: number;\n /** Whether this box is the highlight the viewer is currently pointed at. */\n active: boolean;\n}\n\n/**\n * Fallback advance per character when pdf.js reports no width.\n *\n * Half an em is the rough mean advance of Latin text; it only ever decides how\n * wide a box is drawn, never where the reader is taken.\n */\nconst FALLBACK_ADVANCE = 0.5;\n\n/**\n * Boxes for every marked range that falls on this page.\n *\n * `pageStart` is where this page's chunk begins in the projection, which is what\n * makes a whole-document offset comparable with a per-page item offset.\n */\nexport function rangeBoxes(\n spans: readonly PdfTextSpan[],\n chunk: PdfPageChunk,\n pageStart: number,\n marks: MarkRanges,\n): PdfHighlightBox[] {\n if (marks.ranges.length === 0) return [];\n\n const boxes: PdfHighlightBox[] = [];\n for (const span of spans) {\n const item = chunk.spans[span.item];\n if (!item || item.end === item.start) continue;\n\n const local = localizeRanges(marks, pageStart + item.start, pageStart + item.end);\n if (local.ranges.length === 0) continue;\n\n const length = item.end - item.start;\n const advance = span.width > 0 ? span.width / length : span.fontSize * FALLBACK_ADVANCE;\n local.ranges.forEach(([from, to], index) => {\n boxes.push({\n left: span.left + advance * from,\n top: span.top,\n width: advance * (to - from),\n height: span.fontSize,\n active: index === local.activeIndex,\n });\n });\n }\n return boxes;\n}\n\n/**\n * Boxes for `rect` addresses, whose geometry is already the answer.\n *\n * Fractions rather than pixels is what makes them survive zoom and a resize: the\n * same address is correct at 50% and at 300% because it is multiplied by the\n * page's CURRENT rendered size every time.\n */\nexport function rectBoxes(\n highlights: readonly ResolvedHighlight[],\n size: { width: number; height: number },\n): PdfHighlightBox[] {\n const boxes: PdfHighlightBox[] = [];\n for (const highlight of highlights) {\n for (const rect of highlight.rects ?? []) {\n boxes.push({\n left: clampFraction(rect.x) * size.width,\n top: clampFraction(rect.y) * size.height,\n width: clampFraction(rect.width) * size.width,\n height: clampFraction(rect.height) * size.height,\n active: highlight.active,\n });\n }\n }\n return boxes;\n}\n\n/** Keep a foreign document's geometry inside the page it claims to be on. */\nfunction clampFraction(value: DocumentRect[\"x\"]): number {\n if (!Number.isFinite(value)) return 0;\n return Math.max(0, Math.min(1, value));\n}\n\n/**\n * Which page a highlight is on, so activating it can turn to that page.\n *\n * A `rect` carries its own page — geometry has no range to look up. Everything\n * else is looked up through the document's own index, because the ref type is the\n * adapter's business and this adapter's is the page number. A caller's page HINT\n * is deliberately not consulted: it is the resolver's job to leave it off\n * (`ResolvedHighlight.page`), since a stale hint would turn to a page the mark is\n * not on. A range landing on a page separator, or past the extraction cap, has no\n * page and the pager stays put rather than jumping somewhere arbitrary.\n */\nexport function pageOfHighlight(\n highlight: ResolvedHighlight | undefined,\n index: TextIndex<unknown> | undefined,\n): number | undefined {\n if (!highlight || highlight.status !== \"resolved\") return undefined;\n if (highlight.page !== undefined) return highlight.page;\n if (!highlight.range || !index) return undefined;\n const span = spanAt(index, highlight.range[0]);\n return typeof span?.ref === \"number\" ? span.ref : undefined;\n}\n"],"mappings":"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","names":[]}
|