@elabs-ai/components-viewer 4.2.0 → 5.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/dist/{code-adapter-ADZ4UOGN.js → adapters/code/code-adapter.js} +15 -64
- package/dist/adapters/code/code-adapter.js.map +1 -0
- package/dist/adapters/code/code-language.js +10 -0
- package/dist/adapters/code/code-language.js.map +1 -0
- package/dist/adapters/code/code-manifest.js +10 -0
- package/dist/adapters/code/code-manifest.js.map +1 -0
- package/dist/adapters/code/code-theme.js +8 -0
- package/dist/adapters/code/code-theme.js.map +1 -0
- package/dist/{csv-adapter-7HOFZ5Z6.js → adapters/csv/csv-adapter.js} +13 -9
- package/dist/adapters/csv/csv-adapter.js.map +1 -0
- package/dist/adapters/csv/csv-manifest.js +9 -0
- package/dist/adapters/csv/csv-manifest.js.map +1 -0
- package/dist/{docx-adapter-5CQDHWTD.js → adapters/docx/docx-adapter.js} +17 -175
- package/dist/adapters/docx/docx-adapter.js.map +1 -0
- package/dist/adapters/docx/docx-manifest.js +9 -0
- package/dist/adapters/docx/docx-manifest.js.map +1 -0
- package/dist/adapters/docx/docx-model.js +21 -0
- package/dist/adapters/docx/docx-model.js.map +1 -0
- package/dist/{image-adapter-WOHZR24J.js → adapters/image/image-adapter.js} +4 -3
- package/dist/adapters/image/image-adapter.js.map +1 -0
- package/dist/adapters/image/image-manifest.js +9 -0
- package/dist/adapters/image/image-manifest.js.map +1 -0
- package/dist/adapters/index.js +56 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/{json-adapter-ZUW5GQHE.js → adapters/json/json-adapter.js} +4 -3
- package/dist/adapters/json/json-adapter.js.map +1 -0
- package/dist/adapters/json/json-manifest.js +9 -0
- package/dist/adapters/json/json-manifest.js.map +1 -0
- package/dist/{markdown-adapter-YC6WTBS4.js → adapters/markdown/markdown-adapter.js} +14 -36
- package/dist/adapters/markdown/markdown-adapter.js.map +1 -0
- package/dist/adapters/markdown/markdown-manifest.js +9 -0
- package/dist/adapters/markdown/markdown-manifest.js.map +1 -0
- package/dist/adapters/markdown/markdown-marks.js +10 -0
- package/dist/adapters/markdown/markdown-marks.js.map +1 -0
- package/dist/{media-adapter-MCTB4GBH.js → adapters/media/media-adapter.js} +4 -3
- package/dist/adapters/media/media-adapter.js.map +1 -0
- package/dist/adapters/media/media-manifest.js +9 -0
- package/dist/adapters/media/media-manifest.js.map +1 -0
- package/dist/{pdf-adapter-5PMKEXUD.js → adapters/pdf/pdf-adapter.js} +31 -81
- package/dist/adapters/pdf/pdf-adapter.js.map +1 -0
- package/dist/adapters/pdf/pdf-engine.js +14 -0
- package/dist/adapters/pdf/pdf-engine.js.map +1 -0
- package/dist/adapters/pdf/pdf-manifest.js +9 -0
- package/dist/adapters/pdf/pdf-manifest.js.map +1 -0
- package/dist/adapters/pdf/pdf-text.js +20 -0
- package/dist/adapters/pdf/pdf-text.js.map +1 -0
- package/dist/{pptx-adapter-BONWFHYT.js → adapters/pptx/pptx-adapter.js} +34 -135
- package/dist/adapters/pptx/pptx-adapter.js.map +1 -0
- package/dist/adapters/pptx/pptx-manifest.js +9 -0
- package/dist/adapters/pptx/pptx-manifest.js.map +1 -0
- package/dist/adapters/pptx/pptx-model.js +37 -0
- package/dist/adapters/pptx/pptx-model.js.map +1 -0
- package/dist/{text-adapter-NFNWB5W3.js → adapters/text/text-adapter.js} +8 -5
- package/dist/adapters/text/text-adapter.js.map +1 -0
- package/dist/adapters/text/text-manifest.js +9 -0
- package/dist/adapters/text/text-manifest.js.map +1 -0
- package/dist/{xlsx-adapter-O3XIVLFC.js → adapters/xlsx/xlsx-adapter.js} +12 -8
- package/dist/adapters/xlsx/xlsx-adapter.js.map +1 -0
- package/dist/adapters/xlsx/xlsx-manifest.js +9 -0
- package/dist/adapters/xlsx/xlsx-manifest.js.map +1 -0
- package/dist/chunk-3HKQFFZW.js +31 -0
- package/dist/chunk-3HKQFFZW.js.map +1 -0
- package/dist/chunk-5VJWILZ7.js +182 -0
- package/dist/chunk-5VJWILZ7.js.map +1 -0
- package/dist/{chunk-UL43NGUG.js → chunk-5YE2MUS2.js} +1 -1
- package/dist/{chunk-5OMONA2G.js → chunk-6FVJ4D7W.js} +3 -15
- package/dist/chunk-6FVJ4D7W.js.map +1 -0
- package/dist/{chunk-RGWDIQJR.js → chunk-7EASJBQM.js} +2 -2
- package/dist/chunk-7PGGKQVY.js +26 -0
- package/dist/chunk-7PGGKQVY.js.map +1 -0
- package/dist/{chunk-XBOR2YXS.js → chunk-AK7F3IZE.js} +9 -30
- package/dist/chunk-AK7F3IZE.js.map +1 -0
- package/dist/chunk-AOYEL7O3.js +34 -0
- package/dist/chunk-AOYEL7O3.js.map +1 -0
- package/dist/chunk-BDUFH66C.js +61 -0
- package/dist/chunk-BDUFH66C.js.map +1 -0
- package/dist/chunk-BWMXMHVZ.js +107 -0
- package/dist/chunk-BWMXMHVZ.js.map +1 -0
- package/dist/chunk-CIT2HHRI.js +9 -0
- package/dist/chunk-CIT2HHRI.js.map +1 -0
- package/dist/chunk-CLOI4HBA.js +18 -0
- package/dist/chunk-CLOI4HBA.js.map +1 -0
- package/dist/chunk-CMYBLW7D.js +21 -0
- package/dist/chunk-CMYBLW7D.js.map +1 -0
- package/dist/chunk-CO2VJRSU.js +18 -0
- package/dist/chunk-CO2VJRSU.js.map +1 -0
- package/dist/{chunk-3U3MESBU.js → chunk-EA72HA4I.js} +2 -2
- package/dist/{chunk-KOMDRWOU.js → chunk-FDT4QOIG.js} +2 -2
- package/dist/chunk-FQHZEQUH.js +90 -0
- package/dist/chunk-FQHZEQUH.js.map +1 -0
- package/dist/chunk-G27UJZWH.js +82 -0
- package/dist/chunk-G27UJZWH.js.map +1 -0
- package/dist/{chunk-BCF3RWRL.js → chunk-GH4UBYST.js} +2 -2
- package/dist/{chunk-2NQ4RSJ3.js → chunk-GNBD53AT.js} +2 -2
- package/dist/{chunk-VO2273Z2.js → chunk-HOQZ7OPH.js} +2 -18
- package/dist/chunk-HOQZ7OPH.js.map +1 -0
- package/dist/{chunk-GGR7BNW4.js → chunk-HUUJIFFT.js} +2 -2
- package/dist/{chunk-FELIAGKI.js → chunk-I5XD2NJ6.js} +2 -2
- package/dist/chunk-IB4N72FA.js +21 -0
- package/dist/chunk-IB4N72FA.js.map +1 -0
- package/dist/{chunk-4OFQYALG.js → chunk-IQ6Z4X3K.js} +8 -68
- package/dist/chunk-IQ6Z4X3K.js.map +1 -0
- package/dist/{chunk-AORNMH77.js → chunk-JHFC2KBI.js} +1 -5
- package/dist/chunk-JHFC2KBI.js.map +1 -0
- package/dist/chunk-LRPZ373O.js +93 -0
- package/dist/chunk-LRPZ373O.js.map +1 -0
- package/dist/chunk-M77ICVWP.js +24 -0
- package/dist/chunk-M77ICVWP.js.map +1 -0
- package/dist/chunk-MJUH32ZK.js +24 -0
- package/dist/chunk-MJUH32ZK.js.map +1 -0
- package/dist/chunk-PAIVK4N6.js +162 -0
- package/dist/chunk-PAIVK4N6.js.map +1 -0
- package/dist/chunk-PKYDKXCO.js +129 -0
- package/dist/chunk-PKYDKXCO.js.map +1 -0
- package/dist/chunk-QIPKBOZJ.js +59 -0
- package/dist/chunk-QIPKBOZJ.js.map +1 -0
- package/dist/chunk-SASY77ZL.js +29 -0
- package/dist/chunk-SASY77ZL.js.map +1 -0
- package/dist/{chunk-SLXRUYT3.js → chunk-SBG7WPGO.js} +2 -44
- package/dist/chunk-SBG7WPGO.js.map +1 -0
- package/dist/chunk-T4Y4YRHJ.js +733 -0
- package/dist/chunk-T4Y4YRHJ.js.map +1 -0
- package/dist/chunk-TLCNGQKL.js +37 -0
- package/dist/chunk-TLCNGQKL.js.map +1 -0
- package/dist/chunk-US5KT3TR.js +141 -0
- package/dist/chunk-US5KT3TR.js.map +1 -0
- package/dist/{chunk-HMG2ERXH.js → chunk-YA72QE57.js} +2 -2
- package/dist/{chunk-H3OAON3D.js → chunk-YWHFMCS4.js} +2 -2
- package/dist/{chunk-UJIJECEF.js → chunk-ZAVXZRIO.js} +2 -2
- package/dist/components/grid-text.js +19 -0
- package/dist/components/grid-text.js.map +1 -0
- package/dist/components/marked-text.js +10 -0
- package/dist/components/marked-text.js.map +1 -0
- package/dist/components/sheet-table.js +14 -0
- package/dist/components/sheet-table.js.map +1 -0
- package/dist/core/errors.js +18 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/highlight-marks.js +10 -0
- package/dist/core/highlight-marks.js.map +1 -0
- package/dist/core/highlight-resolve.js +10 -0
- package/dist/core/highlight-resolve.js.map +1 -0
- package/dist/core/highlight.js +12 -0
- package/dist/core/highlight.js.map +1 -0
- package/dist/core/registry.js +12 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/scroll-host.js +10 -0
- package/dist/core/scroll-host.js.map +1 -0
- package/dist/core/text-index.js +14 -0
- package/dist/core/text-index.js.map +1 -0
- package/dist/core/types.js +8 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/use-highlight-scroll.js +11 -0
- package/dist/core/use-highlight-scroll.js.map +1 -0
- package/dist/core/use-page-control.js +9 -0
- package/dist/core/use-page-control.js.map +1 -0
- package/dist/core/use-paged-scroll.js +10 -0
- package/dist/core/use-paged-scroll.js.map +1 -0
- package/dist/core/use-viewport-size.js +10 -0
- package/dist/core/use-viewport-size.js.map +1 -0
- package/dist/core/zoom.js +16 -0
- package/dist/core/zoom.js.map +1 -0
- package/dist/file-viewer/file-viewer-context.js +11 -0
- package/dist/file-viewer/file-viewer-context.js.map +1 -0
- package/dist/file-viewer/file-viewer-find.js +13 -0
- package/dist/file-viewer/file-viewer-find.js.map +1 -0
- package/dist/file-viewer/file-viewer-pager.js +10 -0
- package/dist/file-viewer/file-viewer-pager.js.map +1 -0
- package/dist/file-viewer/file-viewer-zoom.js +13 -0
- package/dist/file-viewer/file-viewer-zoom.js.map +1 -0
- package/dist/file-viewer/file-viewer.js +48 -0
- package/dist/file-viewer/file-viewer.js.map +1 -0
- package/dist/index.js +78 -1274
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/file-viewer/file-viewer-recipes.stories.tsx +406 -0
- package/src/file-viewer/file-viewer.stories.tsx +2 -2
- package/dist/chunk-4OFQYALG.js.map +0 -1
- package/dist/chunk-5OMONA2G.js.map +0 -1
- package/dist/chunk-AORNMH77.js.map +0 -1
- package/dist/chunk-SLXRUYT3.js.map +0 -1
- package/dist/chunk-VO2273Z2.js.map +0 -1
- package/dist/chunk-XBOR2YXS.js.map +0 -1
- package/dist/code-adapter-ADZ4UOGN.js.map +0 -1
- package/dist/csv-adapter-7HOFZ5Z6.js.map +0 -1
- package/dist/docx-adapter-5CQDHWTD.js.map +0 -1
- package/dist/image-adapter-WOHZR24J.js.map +0 -1
- package/dist/json-adapter-ZUW5GQHE.js.map +0 -1
- package/dist/markdown-adapter-YC6WTBS4.js.map +0 -1
- package/dist/media-adapter-MCTB4GBH.js.map +0 -1
- package/dist/pdf-adapter-5PMKEXUD.js.map +0 -1
- package/dist/pptx-adapter-BONWFHYT.js.map +0 -1
- package/dist/text-adapter-NFNWB5W3.js.map +0 -1
- package/dist/xlsx-adapter-O3XIVLFC.js.map +0 -1
- /package/dist/{chunk-UL43NGUG.js.map → chunk-5YE2MUS2.js.map} +0 -0
- /package/dist/{chunk-RGWDIQJR.js.map → chunk-7EASJBQM.js.map} +0 -0
- /package/dist/{chunk-3U3MESBU.js.map → chunk-EA72HA4I.js.map} +0 -0
- /package/dist/{chunk-KOMDRWOU.js.map → chunk-FDT4QOIG.js.map} +0 -0
- /package/dist/{chunk-BCF3RWRL.js.map → chunk-GH4UBYST.js.map} +0 -0
- /package/dist/{chunk-2NQ4RSJ3.js.map → chunk-GNBD53AT.js.map} +0 -0
- /package/dist/{chunk-GGR7BNW4.js.map → chunk-HUUJIFFT.js.map} +0 -0
- /package/dist/{chunk-FELIAGKI.js.map → chunk-I5XD2NJ6.js.map} +0 -0
- /package/dist/{chunk-HMG2ERXH.js.map → chunk-YA72QE57.js.map} +0 -0
- /package/dist/{chunk-H3OAON3D.js.map → chunk-YWHFMCS4.js.map} +0 -0
- /package/dist/{chunk-UJIJECEF.js.map → chunk-ZAVXZRIO.js.map} +0 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
fileIconFor,
|
|
10
|
+
type FileSource,
|
|
11
|
+
type ResolvedFileSource,
|
|
12
|
+
} from "@elabs-ai/components-ui";
|
|
13
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
14
|
+
import { createElement, useRef, useState } from "react";
|
|
15
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
16
|
+
|
|
17
|
+
import { createDefaultRegistry } from "../adapters";
|
|
18
|
+
import {
|
|
19
|
+
PROTOCOL_VERSION,
|
|
20
|
+
type AdapterDocument,
|
|
21
|
+
type AdapterModule,
|
|
22
|
+
type AdapterRendererProps,
|
|
23
|
+
} from "../core/types";
|
|
24
|
+
import {
|
|
25
|
+
FileViewer,
|
|
26
|
+
FileViewerContent,
|
|
27
|
+
FileViewerFrame,
|
|
28
|
+
FileViewerHighlightStatus,
|
|
29
|
+
FileViewerProvider,
|
|
30
|
+
FileViewerToolbar,
|
|
31
|
+
} from "./file-viewer";
|
|
32
|
+
import { FileViewerFind } from "./file-viewer-find";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The viewer in the places a product actually puts it: behind an attachment, beside an upload
|
|
36
|
+
* button, twice on one screen to compare two revisions, and with a format of your own.
|
|
37
|
+
* `Viewer/FileViewer` documents the component; these are the compositions people build with it.
|
|
38
|
+
*/
|
|
39
|
+
const meta = {
|
|
40
|
+
title: "Viewer/Recipes",
|
|
41
|
+
component: FileViewer,
|
|
42
|
+
tags: ["autodocs"],
|
|
43
|
+
parameters: {
|
|
44
|
+
layout: "padded",
|
|
45
|
+
docs: {
|
|
46
|
+
subtitle: "Attachments, uploads, side-by-side revisions and a custom format",
|
|
47
|
+
description: {
|
|
48
|
+
component:
|
|
49
|
+
"Four compositions built from the public parts of `@elabs-ai/components-viewer`. " +
|
|
50
|
+
"Each one is a complete, copyable answer to a product question — none of them " +
|
|
51
|
+
"reaches into an adapter or the provider's internals.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
} satisfies Meta<typeof FileViewer>;
|
|
56
|
+
|
|
57
|
+
export default meta;
|
|
58
|
+
type Story = StoryObj<typeof meta>;
|
|
59
|
+
|
|
60
|
+
/* -------------------------------------------------------------------------- */
|
|
61
|
+
/* Fixtures — fictional */
|
|
62
|
+
/* -------------------------------------------------------------------------- */
|
|
63
|
+
|
|
64
|
+
const INVOICE_CSV = `line,description,qty,unit_eur,total_eur
|
|
65
|
+
1,Trail 40 backpack moss,120,71.50,8580.00
|
|
66
|
+
2,Ridge 28 daypack slate,80,48.20,3856.00
|
|
67
|
+
3,Replacement hip belt M,40,9.80,392.00
|
|
68
|
+
4,Freight Venlo,1,640.00,640.00
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const PACKING_NOTE = `# Packing note PN-20418
|
|
72
|
+
|
|
73
|
+
Shipped 14 July 2025 from NB Packs GmbH to Northwind Outdoor, Venlo.
|
|
74
|
+
|
|
75
|
+
- 96 cartons, 4,800 units, batch **NB-0931**
|
|
76
|
+
- Thread lot TL-5520
|
|
77
|
+
- Carrier reference LX-88213-DE
|
|
78
|
+
|
|
79
|
+
Please inspect within five working days.
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
const WEBHOOK_JSON = `{
|
|
83
|
+
"event": "shipment.delivered",
|
|
84
|
+
"reference": "LX-88213-DE",
|
|
85
|
+
"deliveredAt": "2025-07-14T08:02:11Z",
|
|
86
|
+
"cartons": 96,
|
|
87
|
+
"signedBy": "dock 3"
|
|
88
|
+
}
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const ATTACHMENTS: Array<FileSource & { name: string }> = [
|
|
92
|
+
{ kind: "text", text: PACKING_NOTE, name: "packing-note-PN-20418.md" },
|
|
93
|
+
{ kind: "text", text: INVOICE_CSV, name: "invoice-2025-0714.csv" },
|
|
94
|
+
{ kind: "text", text: WEBHOOK_JSON, name: "carrier-webhook.json" },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/* -------------------------------------------------------------------------- */
|
|
98
|
+
/* 1 — An attachment opens in a dialog */
|
|
99
|
+
/* -------------------------------------------------------------------------- */
|
|
100
|
+
|
|
101
|
+
function AttachmentStrip() {
|
|
102
|
+
const [open, setOpen] = useState<(FileSource & { name: string }) | null>(null);
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex max-w-2xl flex-col gap-3 rounded-lg border border-border bg-card p-4 text-card-foreground">
|
|
105
|
+
<p className="text-body text-foreground">
|
|
106
|
+
The shipment arrived this morning. Packing note, invoice and the carrier's delivery event
|
|
107
|
+
are attached — can you check the carton count before we release the batch?
|
|
108
|
+
</p>
|
|
109
|
+
<ul aria-label="Attachments" className="flex flex-wrap gap-2">
|
|
110
|
+
{ATTACHMENTS.map((file) => (
|
|
111
|
+
<li key={file.name}>
|
|
112
|
+
<Button onClick={() => setOpen(file)} size="sm" variant="outline">
|
|
113
|
+
{createElement(fileIconFor(file.name), { "aria-hidden": true })}
|
|
114
|
+
{file.name}
|
|
115
|
+
</Button>
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
<Dialog onOpenChange={(next) => !next && setOpen(null)} open={open !== null}>
|
|
120
|
+
<DialogContent className="max-w-3xl">
|
|
121
|
+
<DialogHeader>
|
|
122
|
+
<DialogTitle>{open?.name ?? "Attachment"}</DialogTitle>
|
|
123
|
+
<DialogDescription>Attached to “Shipment LX-88213-DE arrived”.</DialogDescription>
|
|
124
|
+
</DialogHeader>
|
|
125
|
+
{/* One viewer for every attachment: the format is detected from the name. */}
|
|
126
|
+
{open ? <FileViewer className="h-[26rem]" source={open} /> : null}
|
|
127
|
+
</DialogContent>
|
|
128
|
+
</Dialog>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A message with three attachments of three formats. Every chip opens the SAME `FileViewer` in
|
|
135
|
+
* a dialog; the adapter for a format is fetched the first time a file of that kind is opened.
|
|
136
|
+
*/
|
|
137
|
+
export const AttachmentPreview: Story = {
|
|
138
|
+
render: () => <AttachmentStrip />,
|
|
139
|
+
play: async ({ canvasElement }) => {
|
|
140
|
+
const canvas = within(canvasElement);
|
|
141
|
+
await userEvent.click(canvas.getByRole("button", { name: /invoice-2025-0714\.csv/ }));
|
|
142
|
+
const dialog = await within(canvasElement.ownerDocument.body).findByRole("dialog");
|
|
143
|
+
// The CSV is a TABLE, not text: a header cell proves the csv adapter took it.
|
|
144
|
+
await waitFor(
|
|
145
|
+
() =>
|
|
146
|
+
expect(
|
|
147
|
+
within(dialog).getByRole("columnheader", { name: "description" }),
|
|
148
|
+
).toBeInTheDocument(),
|
|
149
|
+
{ timeout: 15_000 },
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/* -------------------------------------------------------------------------- */
|
|
155
|
+
/* 2 — The reader's own file */
|
|
156
|
+
/* -------------------------------------------------------------------------- */
|
|
157
|
+
|
|
158
|
+
function UploadPreview() {
|
|
159
|
+
const input = useRef<HTMLInputElement>(null);
|
|
160
|
+
const [file, setFile] = useState<File | null>(null);
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex max-w-3xl flex-col gap-3">
|
|
163
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
164
|
+
<Button onClick={() => input.current?.click()} variant="outline">
|
|
165
|
+
Choose a file…
|
|
166
|
+
</Button>
|
|
167
|
+
<input
|
|
168
|
+
aria-label="Choose a file to preview"
|
|
169
|
+
className="sr-only"
|
|
170
|
+
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
|
171
|
+
ref={input}
|
|
172
|
+
type="file"
|
|
173
|
+
/>
|
|
174
|
+
{file ? (
|
|
175
|
+
<Badge variant="outline">
|
|
176
|
+
{file.name} · {Math.max(1, Math.round(file.size / 1024))} KB
|
|
177
|
+
</Badge>
|
|
178
|
+
) : (
|
|
179
|
+
<span className="text-meta text-muted-foreground">
|
|
180
|
+
PDF, Word, Excel, PowerPoint, CSV, JSON, markdown, code, images, audio, video.
|
|
181
|
+
</span>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
{/* `undefined` is the empty state, not an error — nothing has gone wrong yet. */}
|
|
185
|
+
<FileViewer className="h-[26rem]" source={file ? { kind: "file", file } : undefined} />
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `{ kind: "file", file }` takes a `File` straight from an input or a drop: nothing is uploaded
|
|
192
|
+
* and nothing leaves the browser. Pick any file on your machine to try the adapters on real
|
|
193
|
+
* content; with nothing picked the viewer shows its empty state.
|
|
194
|
+
*/
|
|
195
|
+
export const UploadAndPreview: Story = {
|
|
196
|
+
render: () => <UploadPreview />,
|
|
197
|
+
play: async ({ canvasElement }) => {
|
|
198
|
+
const canvas = within(canvasElement);
|
|
199
|
+
const picked = new File([INVOICE_CSV], "invoice-2025-0714.csv", { type: "text/csv" });
|
|
200
|
+
await userEvent.upload(canvas.getByLabelText("Choose a file to preview"), picked);
|
|
201
|
+
await waitFor(
|
|
202
|
+
() => expect(canvas.getByRole("columnheader", { name: "qty" })).toBeInTheDocument(),
|
|
203
|
+
{ timeout: 15_000 },
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/* -------------------------------------------------------------------------- */
|
|
209
|
+
/* 3 — Two revisions side by side */
|
|
210
|
+
/* -------------------------------------------------------------------------- */
|
|
211
|
+
|
|
212
|
+
const POLICY_V6 = `# Refund policy — version 6
|
|
213
|
+
|
|
214
|
+
A customer can return an unused item within 14 days of delivery for a full refund.
|
|
215
|
+
|
|
216
|
+
A defect in material or workmanship is refunded in full within the 24-month warranty.
|
|
217
|
+
|
|
218
|
+
Instead of a refund an agent may offer store credit of the refund amount plus 5 %.
|
|
219
|
+
`;
|
|
220
|
+
|
|
221
|
+
const POLICY_V7 = `# Refund policy — version 7
|
|
222
|
+
|
|
223
|
+
A customer can return an unused item within 30 days of delivery for a full refund.
|
|
224
|
+
|
|
225
|
+
A defect in material or workmanship is refunded in full within the 24-month warranty.
|
|
226
|
+
|
|
227
|
+
A team lead may approve a refund up to 14 days after the window closes when the photographs show a manufacturing defect rather than wear.
|
|
228
|
+
|
|
229
|
+
Instead of a refund an agent may offer store credit of the refund amount plus 11 %.
|
|
230
|
+
`;
|
|
231
|
+
|
|
232
|
+
const REVISIONS = [
|
|
233
|
+
{
|
|
234
|
+
label: "Version 6 · in force until 31 December 2024",
|
|
235
|
+
source: { kind: "text", text: POLICY_V6, name: "refund-policy-v6.md" } as const,
|
|
236
|
+
changed: ["within 14 days of delivery", "refund amount plus 5 %"],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
label: "Version 7 · in force since 1 January 2025",
|
|
240
|
+
source: { kind: "text", text: POLICY_V7, name: "refund-policy-v7.md" } as const,
|
|
241
|
+
changed: [
|
|
242
|
+
"within 30 days of delivery",
|
|
243
|
+
"A team lead may approve a refund up to 14 days after the window closes",
|
|
244
|
+
"refund amount plus 11 %",
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Two providers, two documents, one screen. Each side marks the passages that differ from the
|
|
251
|
+
* other through the same highlight layer citations use, so "what changed" is a list of quotes —
|
|
252
|
+
* the output of any text diff — and not a second rendering mode.
|
|
253
|
+
*/
|
|
254
|
+
export const CompareRevisions: Story = {
|
|
255
|
+
render: () => (
|
|
256
|
+
<div className="grid max-w-5xl gap-4 md:grid-cols-2">
|
|
257
|
+
{REVISIONS.map((revision) => (
|
|
258
|
+
<section
|
|
259
|
+
aria-label={revision.label}
|
|
260
|
+
className="flex min-w-0 flex-col gap-2"
|
|
261
|
+
key={revision.label}
|
|
262
|
+
>
|
|
263
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
264
|
+
<h3 className="text-body-sm font-semibold text-foreground">{revision.label}</h3>
|
|
265
|
+
<Badge variant="outline">{revision.changed.length} changed passages</Badge>
|
|
266
|
+
</div>
|
|
267
|
+
<FileViewerProvider
|
|
268
|
+
defaultHighlights={revision.changed.map((text, index) => ({
|
|
269
|
+
id: `change-${index}`,
|
|
270
|
+
label: "Changed in this revision",
|
|
271
|
+
address: { kind: "quote" as const, text },
|
|
272
|
+
}))}
|
|
273
|
+
source={revision.source}
|
|
274
|
+
>
|
|
275
|
+
{/* Two viewers side by side means two of every landmark inside them. Their default
|
|
276
|
+
names ("File viewer", "File content") would be identical, which is exactly what
|
|
277
|
+
axe's `landmark-unique` is for: a screen-reader user listing landmarks would see
|
|
278
|
+
the same two entries twice with nothing to tell them apart. Name them after the
|
|
279
|
+
revision they show. */}
|
|
280
|
+
<FileViewerFrame aria-label={`${revision.label}, file viewer`} className="h-[24rem]">
|
|
281
|
+
<FileViewerToolbar />
|
|
282
|
+
<FileViewerFind />
|
|
283
|
+
<FileViewerHighlightStatus />
|
|
284
|
+
<FileViewerContent aria-label={`${revision.label}, file content`} />
|
|
285
|
+
</FileViewerFrame>
|
|
286
|
+
</FileViewerProvider>
|
|
287
|
+
</section>
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
),
|
|
291
|
+
play: async ({ canvasElement }) => {
|
|
292
|
+
await waitFor(
|
|
293
|
+
() =>
|
|
294
|
+
// Markdown plates the whole BLOCK a passage sits in: 2 on the left, 3 on the right.
|
|
295
|
+
expect(canvasElement.querySelectorAll('[data-slot="highlight-block"]')).toHaveLength(5),
|
|
296
|
+
{ timeout: 15_000 },
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/* -------------------------------------------------------------------------- */
|
|
302
|
+
/* 4 — A format of your own */
|
|
303
|
+
/* -------------------------------------------------------------------------- */
|
|
304
|
+
|
|
305
|
+
interface Cue {
|
|
306
|
+
index: number;
|
|
307
|
+
from: string;
|
|
308
|
+
to: string;
|
|
309
|
+
text: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface SubtitleDocument extends AdapterDocument {
|
|
313
|
+
kind: "subtitles";
|
|
314
|
+
cues: Cue[];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function parseSrt(raw: string): Cue[] {
|
|
318
|
+
return raw
|
|
319
|
+
.trim()
|
|
320
|
+
.split(/\r?\n\r?\n/)
|
|
321
|
+
.flatMap((block) => {
|
|
322
|
+
const [index, times, ...lines] = block.split(/\r?\n/);
|
|
323
|
+
const [from, to] = (times ?? "").split(" --> ");
|
|
324
|
+
return from && to ? [{ index: Number(index), from, to, text: lines.join(" ") }] : [];
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** A complete adapter: a manifest that claims `.srt`, a loader, and a renderer on tokens. */
|
|
329
|
+
const subtitleAdapter: AdapterModule = {
|
|
330
|
+
manifest: {
|
|
331
|
+
id: "subtitles",
|
|
332
|
+
protocol: PROTOCOL_VERSION,
|
|
333
|
+
// Above the built-in text adapter, which would otherwise claim an unknown text file.
|
|
334
|
+
priority: 10,
|
|
335
|
+
extensions: ["srt"],
|
|
336
|
+
capabilities: { text: true },
|
|
337
|
+
},
|
|
338
|
+
create: () => ({
|
|
339
|
+
async load(source: ResolvedFileSource, context): Promise<SubtitleDocument> {
|
|
340
|
+
const raw = await source.text(context.signal);
|
|
341
|
+
const cues = parseSrt(raw);
|
|
342
|
+
return { kind: "subtitles", cues, text: cues.map((cue) => cue.text).join("\n") };
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
Renderer: ({ document }: AdapterRendererProps) => (
|
|
346
|
+
<ol className="flex flex-col">
|
|
347
|
+
{(document as SubtitleDocument).cues.map((cue) => (
|
|
348
|
+
<li
|
|
349
|
+
className="grid grid-cols-[9rem_minmax(0,1fr)] gap-3 border-b border-border px-1 py-2 last:border-b-0"
|
|
350
|
+
key={cue.index}
|
|
351
|
+
>
|
|
352
|
+
<span className="text-meta tabular-nums text-muted-foreground">
|
|
353
|
+
{cue.from.slice(3, 8)} – {cue.to.slice(3, 8)}
|
|
354
|
+
</span>
|
|
355
|
+
<span className="text-body text-foreground">{cue.text}</span>
|
|
356
|
+
</li>
|
|
357
|
+
))}
|
|
358
|
+
</ol>
|
|
359
|
+
),
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const registryWithSubtitles = (() => {
|
|
363
|
+
const registry = createDefaultRegistry();
|
|
364
|
+
registry.register(subtitleAdapter.manifest, () => Promise.resolve(subtitleAdapter));
|
|
365
|
+
return registry;
|
|
366
|
+
})();
|
|
367
|
+
|
|
368
|
+
const STANDUP_SRT = `1
|
|
369
|
+
00:00:01,200 --> 00:00:05,900
|
|
370
|
+
Morning. Batch NB-0931 is on sales hold since yesterday.
|
|
371
|
+
|
|
372
|
+
2
|
|
373
|
+
00:00:06,100 --> 00:00:11,400
|
|
374
|
+
Quality measured twelve returned units. All of them failed below 220 newtons.
|
|
375
|
+
|
|
376
|
+
3
|
|
377
|
+
00:00:11,800 --> 00:00:17,000
|
|
378
|
+
Legal sends the written notice to the supplier today. The 30 days started on the sixth.
|
|
379
|
+
|
|
380
|
+
4
|
|
381
|
+
00:00:17,300 --> 00:00:21,500
|
|
382
|
+
Support keeps approving seam refunds without asking. Any questions?
|
|
383
|
+
`;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The registry is the extension point: this story adds a subtitle (`.srt`) adapter in forty
|
|
387
|
+
* lines and hands the registry to the viewer. The shell — identity row, download, loading,
|
|
388
|
+
* empty and error states — is the same one every built-in format gets, because the adapter
|
|
389
|
+
* supplies only `load` and a `Renderer`.
|
|
390
|
+
*/
|
|
391
|
+
export const CustomFormat: Story = {
|
|
392
|
+
args: {
|
|
393
|
+
registry: registryWithSubtitles,
|
|
394
|
+
source: { kind: "text", text: STANDUP_SRT, name: "standup-2025-10-07.srt" },
|
|
395
|
+
className: "h-[22rem] max-w-3xl",
|
|
396
|
+
},
|
|
397
|
+
play: async ({ canvasElement }) => {
|
|
398
|
+
const canvas = within(canvasElement);
|
|
399
|
+
await waitFor(
|
|
400
|
+
() => expect(canvas.getByText(/Batch NB-0931 is on sales hold/)).toBeInTheDocument(),
|
|
401
|
+
{ timeout: 15_000 },
|
|
402
|
+
);
|
|
403
|
+
// Drawn by the custom renderer (a list of cues), not by the text fallback (a <pre>).
|
|
404
|
+
await expect(canvasElement.querySelector("pre")).toBeNull();
|
|
405
|
+
},
|
|
406
|
+
};
|
|
@@ -596,7 +596,7 @@ export const ChromeInTheAppHeader: Story = {
|
|
|
596
596
|
// Prove the header really drives the document. Under continuous scroll page
|
|
597
597
|
// 2 is already MOUNTED, so its presence proves nothing — what has to follow
|
|
598
598
|
// the button outside the frame is the viewport.
|
|
599
|
-
await canvas.findByRole("img", { name: /Page 1/ }, { timeout:
|
|
599
|
+
await canvas.findByRole("img", { name: /Page 1/ }, { timeout: 40_000 });
|
|
600
600
|
const pane = canvasElement.querySelector<HTMLElement>('[data-slot="file-viewer-content"]');
|
|
601
601
|
expect(pane?.scrollTop).toBe(0);
|
|
602
602
|
await userEvent.click(canvas.getByRole("button", { name: "Next page" }));
|
|
@@ -618,7 +618,7 @@ export const ContinuousScroll: Story = {
|
|
|
618
618
|
args: { source: PDF_SOURCE },
|
|
619
619
|
play: async ({ canvasElement }) => {
|
|
620
620
|
const canvas = within(canvasElement);
|
|
621
|
-
await canvas.findByRole("img", { name: /Page 1/ }, { timeout:
|
|
621
|
+
await canvas.findByRole("img", { name: /Page 1/ }, { timeout: 40_000 });
|
|
622
622
|
const pane = canvasElement.querySelector<HTMLElement>('[data-slot="file-viewer-content"]');
|
|
623
623
|
const field = canvas.getByRole("textbox", { name: "Page number" });
|
|
624
624
|
expect(field).toHaveValue("1");
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core/use-page-control.ts","../src/core/use-paged-scroll.ts","../src/core/scroll-host.ts","../src/core/use-viewport-size.ts"],"sourcesContent":["\"use client\";\n\n/**\n * The page half of the renderer contract, as one hook.\n *\n * `AdapterRendererProps.pageNumber` is optional on purpose: inside a\n * `FileViewerProvider` the shell owns the page so a control can live anywhere,\n * but a `Renderer` mounted on its own — a test, an embed that wants nothing but\n * the canvas — must still be able to turn a page. That is the ordinary\n * controlled/uncontrolled trio (`component-api.md`), and both paginated adapters\n * need exactly the same six lines of it.\n */\n\nimport { useCallback, useState } from \"react\";\n\n/**\n * @param pageNumber The controlled page, or `undefined` for uncontrolled.\n * @param onPageChange Reported on every navigation, controlled or not.\n * @param pageCount Upper bound. `0` while the count is not known yet.\n * @returns The page to render, and the function that navigates to another.\n */\nexport function usePageControl(\n pageNumber: number | undefined,\n onPageChange: ((page: number) => void) | undefined,\n pageCount: number,\n): readonly [number, (page: number) => void] {\n const controlled = pageNumber !== undefined;\n const [own, setOwn] = useState(1);\n\n // Clamped on READ, not on write: a controlled owner that has not yet heard\n // about a shorter document would otherwise render page 40 of 12 and blank the\n // canvas, and clamping its prop back at it would fight the owner.\n const raw = controlled ? pageNumber : own;\n const current = pageCount > 0 ? Math.min(Math.max(1, raw), pageCount) : Math.max(1, raw);\n\n const goToPage = useCallback(\n (page: number) => {\n const next = pageCount > 0 ? Math.min(Math.max(1, page), pageCount) : Math.max(1, page);\n // Mirroring the platform: a controlled value is never written locally.\n if (!controlled) setOwn(next);\n onPageChange?.(next);\n },\n [controlled, onPageChange, pageCount],\n );\n\n return [current, goToPage] as const;\n}\n","\"use client\";\n\n/**\n * Continuous scrolling for a paginated document, over the shell's own viewport.\n *\n * ## Why the pages are virtualized\n *\n * A 900-page PDF is 900 canvases, 900 text layers and 900 rasterization jobs. A\n * plain stack would allocate all of them before the first page appeared. Only the\n * pages near the viewport are mounted, so the cost is a function of the WINDOW,\n * not of the document.\n *\n * ## Why the virtualizer scrolls someone else's element\n *\n * `FileViewerContent` is the one scroll boundary (`viewer-components.md`), so the\n * list is a CHILD of the scrolling box rather than being one. That is what\n * `scrollMargin` is for: every measurement is offset by where the list starts\n * inside the pane, which is the pane's own padding.\n *\n * ## The page number is a two-way binding, and that is the whole difficulty\n *\n * The shell owns `pageNumber` (ADR 0026), so it flows two ways: the reader\n * scrolls and the pager must follow; the reader types \"7\" and the document must\n * scroll. Naively wiring both makes them fight — a scroll reports page 3, the\n * prop becomes 3, and the \"the prop changed, scroll to it\" effect snaps the\n * reader back to the top of page 3 mid-gesture. Each direction therefore\n * remembers what it last said, and ignores an echo of its own words.\n */\n\nimport { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport { useEffect, useLayoutEffect, useRef, useState, type RefObject } from \"react\";\n\nimport { findScrollHost } from \"./scroll-host\";\n\n/**\n * Slack under the viewport's top edge, in CSS pixels.\n *\n * Without it the page whose last pixel row is still visible counts as the page\n * being read, so the pager flickers back a page at every boundary.\n */\nconst EDGE_SLACK = 8;\n\nexport interface PagedScrollOptions {\n /** Pages in the document. `0` disables the virtualizer entirely. */\n count: number;\n /**\n * The element the pages are laid out in. Must be a descendant of the scroll\n * host — its `offsetTop` is what `scrollMargin` is derived from.\n */\n listRef: RefObject<HTMLElement | null>;\n /** The page the shell wants shown, 1-based. */\n pageNumber: number;\n /** Report the page the reader scrolled to, 1-based. */\n goToPage: (page: number) => void;\n /** Height of page `index` at the current scale, in CSS pixels. */\n estimateSize: (index: number) => number;\n /**\n * A value that changes whenever EVERY page's size changes — the scale, in\n * practice. Every cached measurement is thrown away and the reader is put back\n * where they were reading, at the new size.\n */\n sizeKey?: unknown;\n}\n\n/**\n * @returns The virtualizer. Render `getVirtualItems()` inside `listRef`'s\n * element, sized to `getTotalSize()`, translated by `start - scrollMargin`.\n */\nexport function usePagedScroll({\n count,\n listRef,\n pageNumber,\n goToPage,\n estimateSize,\n sizeKey,\n}: PagedScrollOptions): Virtualizer<HTMLElement, Element> {\n // Resolved after mount rather than passed in: a renderer may be composed into\n // the shell, into a consumer's own frame, or mounted bare in a test, and only\n // the DOM knows which. State (not a ref) so the virtualizer re-reads it.\n const [host, setHost] = useState<HTMLElement | null>(null);\n const [scrollMargin, setScrollMargin] = useState(0);\n useEffect(() => {\n const list = listRef.current;\n const found = findScrollHost(list) ?? list?.parentElement ?? null;\n setHost(found);\n if (!list || !found) return;\n // Deliberately geometric rather than `offsetTop`: `offsetTop` is measured\n // from the nearest POSITIONED ancestor, and the scrolling pane is not one —\n // so it would report the distance to some outer frame and put every page at\n // the wrong offset. This is the list's true start inside the scrolled\n // content, which is the pane's own padding.\n setScrollMargin(\n list.getBoundingClientRect().top - found.getBoundingClientRect().top + found.scrollTop,\n );\n }, [listRef]);\n\n const virtualizer = useVirtualizer({\n count,\n getScrollElement: () => host,\n estimateSize,\n overscan: 1,\n scrollMargin,\n measureElement: (element, entry, instance) => {\n const measured =\n entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height;\n // A hidden pane measures zero, and so does a layout-less environment.\n // Recording that would collapse the page to nothing and — because the\n // measurement is cached — leave it collapsed after the pane is shown\n // again. Keeping the estimate is the same guard `useViewportSize` carries,\n // for the same reason.\n if (measured > 0) return measured;\n const index = Number(element.getAttribute(\"data-index\"));\n return Number.isNaN(index) ? 0 : instance.options.estimateSize(index);\n },\n });\n\n const items = virtualizer.getVirtualItems();\n const scrollOffset = virtualizer.scrollOffset ?? 0;\n\n // The page under the viewport's top edge — the one a reader would name if you\n // asked what page they are on.\n const top = items.find((item) => item.start + item.size > scrollOffset + EDGE_SLACK);\n const active = top ? top.index + 1 : 1;\n\n // Where the reader is WITHIN that page, so a scale change can put them back.\n // Recorded on every scroll rather than captured at the moment of the change:\n // by then the measurements have already been reset.\n const anchor = useRef({ index: 0, fraction: 0 });\n useEffect(() => {\n if (!top || top.size <= 0) return;\n // Deliberately NOT clamped at the low end. At the very top of a document the\n // reader is ABOVE page 1's start — the pane's own padding sits between them —\n // and flooring that to 0 would scroll the padding away the first time the\n // scale resolves, so simply opening a file nudges the view.\n const fraction = (scrollOffset - top.start) / top.size;\n anchor.current = { index: top.index, fraction: Math.min(fraction, 1) };\n }, [top, scrollOffset]);\n\n // ── The page number, in both directions ──────────────────────────────────\n // Both start at 0 — a page number that cannot occur — so the FIRST value of\n // `pageNumber` still counts as something the shell asked for. A shell that\n // mounts already pointing at page 140 (a deep link, a citation restored from a\n // URL) has made a request that nothing has honoured yet.\n const reported = useRef(0);\n const requested = useRef(0);\n\n const lastActive = useRef(active);\n useEffect(() => {\n // Only a CHANGE in the page under the top edge is news. Reporting the\n // initial value would answer the deep link above with \"you are on page 1\"\n // before the scroll that honours it has even been attempted.\n if (count === 0 || active === lastActive.current) return;\n lastActive.current = active;\n if (active === pageNumber) return;\n reported.current = active;\n goToPage(active);\n }, [active, pageNumber, count, goToPage]);\n\n useEffect(() => {\n // `host` is resolved in an effect, so the first pass through here has\n // nothing to scroll — and virtual-core silently drops a scroll with no\n // element. Waiting for it is what makes the mount-time request land.\n if (!host || count === 0 || requested.current === pageNumber) return;\n requested.current = pageNumber;\n // Our own report coming back around. Scrolling here would fight the gesture\n // that produced it.\n if (pageNumber === reported.current || pageNumber === active) return;\n virtualizer.scrollToIndex(pageNumber - 1, { align: \"start\" });\n }, [host, pageNumber, active, count, virtualizer]);\n\n // ── Re-anchor after a scale change ───────────────────────────────────────\n const lastSizeKey = useRef(sizeKey);\n useLayoutEffect(() => {\n if (lastSizeKey.current === sizeKey) return;\n lastSizeKey.current = sizeKey;\n if (count === 0) return;\n\n // Measured heights describe the OLD scale, so keeping them would place every\n // page at the wrong offset until it happened to be re-measured.\n virtualizer.measure();\n\n const { index, fraction } = anchor.current;\n // At the very top there is no reading position to preserve, and scrolling to\n // page 1's own start would scroll the pane's top padding away — so simply\n // opening a file and letting its fit resolve would nudge the view.\n if (index === 0 && fraction <= 0) return;\n const start = virtualizer.getOffsetForIndex(index, \"start\")?.[0];\n if (start === undefined) return;\n const size = virtualizer.measurementsCache[index]?.size ?? 0;\n virtualizer.scrollToOffset(start + fraction * size, { align: \"start\" });\n }, [sizeKey, count, virtualizer]);\n\n return virtualizer;\n}\n","/**\n * Finding the element a renderer actually scrolls inside.\n *\n * `FileViewerContent` is THE scroll boundary (`viewer-components.md`), and now\n * that page and zoom chrome lives in the shell (ADR 0026) even the PDF adapter\n * no longer has fixed controls of its own to justify a second viewport. But a\n * renderer still has to MEASURE that box — a fit-to-width scale is a function of\n * the viewport's width, and a virtualized page list is a function of its height.\n *\n * Resolved from the DOM rather than passed as a prop because a renderer may be\n * mounted anywhere: inside the shell, inside a consumer's own frame, or bare in\n * a test. Same shape as extend-hq/ui's `resolveScrollAreaViewport`.\n */\n\n/** The `data-slot` the shell's scrolling pane carries. */\nexport const SCROLL_HOST_SELECTOR = '[data-slot=\"file-viewer-content\"]';\n\n/**\n * The nearest scrolling ancestor of `node`.\n *\n * Prefers the shell's pane by its stable `data-slot`; falls back to the first\n * ancestor whose computed overflow actually scrolls, so a renderer composed into\n * someone else's frame still measures the right box. `null` when nothing\n * scrolls — the caller should then fall back to its own element.\n */\nexport function findScrollHost(node: Element | null | undefined): HTMLElement | null {\n if (!node) return null;\n const shell = node.closest<HTMLElement>(SCROLL_HOST_SELECTOR);\n if (shell) return shell;\n\n const view = node.ownerDocument?.defaultView;\n if (!view) return null;\n let current = node.parentElement;\n while (current) {\n const { overflowY, overflowX } = view.getComputedStyle(current);\n if (/(auto|scroll|overlay)/.test(overflowY) || /(auto|scroll|overlay)/.test(overflowX)) {\n return current;\n }\n current = current.parentElement;\n }\n return null;\n}\n","\"use client\";\n\n/**\n * The size of the box a renderer scrolls inside, kept current.\n *\n * Needed because a fit-to-width scale is a function of the VIEWPORT, not of the\n * document: only the renderer can compute it, and only by measuring. Measures\n * the container rather than the window (`window.innerWidth` would be wrong the\n * moment a viewer sits in a resizable pane, a dialog or a split workspace).\n */\n\nimport { useEffect, useState, type RefObject } from \"react\";\n\nimport { findScrollHost } from \"./scroll-host\";\n\nexport interface ViewportSize {\n width: number;\n height: number;\n}\n\n/**\n * @param ref An element inside the scrolling box — typically the renderer's root.\n * @returns The scroll host's content size, or `undefined` until it is measured.\n */\nexport function useViewportSize(ref: RefObject<HTMLElement | null>): ViewportSize | undefined {\n const [size, setSize] = useState<ViewportSize>();\n\n useEffect(() => {\n const host = findScrollHost(ref.current) ?? ref.current?.parentElement ?? ref.current;\n if (!host || typeof ResizeObserver === \"undefined\") return;\n\n const measure = () => {\n const { clientWidth: width, clientHeight: height } = host;\n // A hidden pane measures zero. Re-fitting to a zero-width viewport would\n // rasterize a zero-width page and clear the canvas — and nothing would\n // measure again when the pane is shown, because its size did not change\n // from the observer's point of view. Ignoring the reading keeps the last\n // good one. (The guard extend-hq/ui's `useElementWidth` carries too.)\n if (width === 0 || height === 0) return;\n setSize((current) =>\n current && Math.abs(current.width - width) < 1 && Math.abs(current.height - height) < 1\n ? current\n : { width, height },\n );\n };\n\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(host);\n return () => observer.disconnect();\n }, [ref]);\n\n return size;\n}\n"],"mappings":";;;AAaA,SAAS,aAAa,gBAAgB;AAQ/B,SAAS,eACd,YACA,cACA,WAC2C;AAC3C,QAAM,aAAa,eAAe;AAClC,QAAM,CAAC,KAAK,MAAM,IAAI,SAAS,CAAC;AAKhC,QAAM,MAAM,aAAa,aAAa;AACtC,QAAM,UAAU,YAAY,IAAI,KAAK,IAAI,KAAK,IAAI,GAAG,GAAG,GAAG,SAAS,IAAI,KAAK,IAAI,GAAG,GAAG;AAEvF,QAAM,WAAW;AAAA,IACf,CAAC,SAAiB;AAChB,YAAM,OAAO,YAAY,IAAI,KAAK,IAAI,KAAK,IAAI,GAAG,IAAI,GAAG,SAAS,IAAI,KAAK,IAAI,GAAG,IAAI;AAEtF,UAAI,CAAC,WAAY,QAAO,IAAI;AAC5B,qBAAe,IAAI;AAAA,IACrB;AAAA,IACA,CAAC,YAAY,cAAc,SAAS;AAAA,EACtC;AAEA,SAAO,CAAC,SAAS,QAAQ;AAC3B;;;ACjBA,SAAS,sBAAwC;AACjD,SAAS,WAAW,iBAAiB,QAAQ,YAAAA,iBAAgC;;;ACftE,IAAM,uBAAuB;AAU7B,SAAS,eAAe,MAAsD;AACnF,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,QAAQ,KAAK,QAAqB,oBAAoB;AAC5D,MAAI,MAAO,QAAO;AAElB,QAAM,OAAO,KAAK,eAAe;AACjC,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,UAAU,KAAK;AACnB,SAAO,SAAS;AACd,UAAM,EAAE,WAAW,UAAU,IAAI,KAAK,iBAAiB,OAAO;AAC9D,QAAI,wBAAwB,KAAK,SAAS,KAAK,wBAAwB,KAAK,SAAS,GAAG;AACtF,aAAO;AAAA,IACT;AACA,cAAU,QAAQ;AAAA,EACpB;AACA,SAAO;AACT;;;ADDA,IAAM,aAAa;AA4BZ,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA0D;AAIxD,QAAM,CAAC,MAAM,OAAO,IAAIC,UAA6B,IAAI;AACzD,QAAM,CAAC,cAAc,eAAe,IAAIA,UAAS,CAAC;AAClD,YAAU,MAAM;AACd,UAAM,OAAO,QAAQ;AACrB,UAAM,QAAQ,eAAe,IAAI,KAAK,MAAM,iBAAiB;AAC7D,YAAQ,KAAK;AACb,QAAI,CAAC,QAAQ,CAAC,MAAO;AAMrB;AAAA,MACE,KAAK,sBAAsB,EAAE,MAAM,MAAM,sBAAsB,EAAE,MAAM,MAAM;AAAA,IAC/E;AAAA,EACF,GAAG,CAAC,OAAO,CAAC;AAEZ,QAAM,cAAc,eAAe;AAAA,IACjC;AAAA,IACA,kBAAkB,MAAM;AAAA,IACxB;AAAA,IACA,UAAU;AAAA,IACV;AAAA,IACA,gBAAgB,CAAC,SAAS,OAAO,aAAa;AAC5C,YAAM,WACJ,OAAO,gBAAgB,CAAC,GAAG,aAAa,QAAQ,sBAAsB,EAAE;AAM1E,UAAI,WAAW,EAAG,QAAO;AACzB,YAAM,QAAQ,OAAO,QAAQ,aAAa,YAAY,CAAC;AACvD,aAAO,OAAO,MAAM,KAAK,IAAI,IAAI,SAAS,QAAQ,aAAa,KAAK;AAAA,IACtE;AAAA,EACF,CAAC;AAED,QAAM,QAAQ,YAAY,gBAAgB;AAC1C,QAAM,eAAe,YAAY,gBAAgB;AAIjD,QAAM,MAAM,MAAM,KAAK,CAAC,SAAS,KAAK,QAAQ,KAAK,OAAO,eAAe,UAAU;AACnF,QAAM,SAAS,MAAM,IAAI,QAAQ,IAAI;AAKrC,QAAM,SAAS,OAAO,EAAE,OAAO,GAAG,UAAU,EAAE,CAAC;AAC/C,YAAU,MAAM;AACd,QAAI,CAAC,OAAO,IAAI,QAAQ,EAAG;AAK3B,UAAM,YAAY,eAAe,IAAI,SAAS,IAAI;AAClD,WAAO,UAAU,EAAE,OAAO,IAAI,OAAO,UAAU,KAAK,IAAI,UAAU,CAAC,EAAE;AAAA,EACvE,GAAG,CAAC,KAAK,YAAY,CAAC;AAOtB,QAAM,WAAW,OAAO,CAAC;AACzB,QAAM,YAAY,OAAO,CAAC;AAE1B,QAAM,aAAa,OAAO,MAAM;AAChC,YAAU,MAAM;AAId,QAAI,UAAU,KAAK,WAAW,WAAW,QAAS;AAClD,eAAW,UAAU;AACrB,QAAI,WAAW,WAAY;AAC3B,aAAS,UAAU;AACnB,aAAS,MAAM;AAAA,EACjB,GAAG,CAAC,QAAQ,YAAY,OAAO,QAAQ,CAAC;AAExC,YAAU,MAAM;AAId,QAAI,CAAC,QAAQ,UAAU,KAAK,UAAU,YAAY,WAAY;AAC9D,cAAU,UAAU;AAGpB,QAAI,eAAe,SAAS,WAAW,eAAe,OAAQ;AAC9D,gBAAY,cAAc,aAAa,GAAG,EAAE,OAAO,QAAQ,CAAC;AAAA,EAC9D,GAAG,CAAC,MAAM,YAAY,QAAQ,OAAO,WAAW,CAAC;AAGjD,QAAM,cAAc,OAAO,OAAO;AAClC,kBAAgB,MAAM;AACpB,QAAI,YAAY,YAAY,QAAS;AACrC,gBAAY,UAAU;AACtB,QAAI,UAAU,EAAG;AAIjB,gBAAY,QAAQ;AAEpB,UAAM,EAAE,OAAO,SAAS,IAAI,OAAO;AAInC,QAAI,UAAU,KAAK,YAAY,EAAG;AAClC,UAAM,QAAQ,YAAY,kBAAkB,OAAO,OAAO,IAAI,CAAC;AAC/D,QAAI,UAAU,OAAW;AACzB,UAAM,OAAO,YAAY,kBAAkB,KAAK,GAAG,QAAQ;AAC3D,gBAAY,eAAe,QAAQ,WAAW,MAAM,EAAE,OAAO,QAAQ,CAAC;AAAA,EACxE,GAAG,CAAC,SAAS,OAAO,WAAW,CAAC;AAEhC,SAAO;AACT;;;AEtLA,SAAS,aAAAC,YAAW,YAAAC,iBAAgC;AAa7C,SAAS,gBAAgB,KAA8D;AAC5F,QAAM,CAAC,MAAM,OAAO,IAAIC,UAAuB;AAE/C,EAAAC,WAAU,MAAM;AACd,UAAM,OAAO,eAAe,IAAI,OAAO,KAAK,IAAI,SAAS,iBAAiB,IAAI;AAC9E,QAAI,CAAC,QAAQ,OAAO,mBAAmB,YAAa;AAEpD,UAAM,UAAU,MAAM;AACpB,YAAM,EAAE,aAAa,OAAO,cAAc,OAAO,IAAI;AAMrD,UAAI,UAAU,KAAK,WAAW,EAAG;AACjC;AAAA,QAAQ,CAAC,YACP,WAAW,KAAK,IAAI,QAAQ,QAAQ,KAAK,IAAI,KAAK,KAAK,IAAI,QAAQ,SAAS,MAAM,IAAI,IAClF,UACA,EAAE,OAAO,OAAO;AAAA,MACtB;AAAA,IACF;AAEA,YAAQ;AACR,UAAM,WAAW,IAAI,eAAe,OAAO;AAC3C,aAAS,QAAQ,IAAI;AACrB,WAAO,MAAM,SAAS,WAAW;AAAA,EACnC,GAAG,CAAC,GAAG,CAAC;AAER,SAAO;AACT;","names":["useState","useState","useEffect","useState","useState","useEffect"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/adapters/code/code-language.ts","../src/adapters/code/code-manifest.ts"],"sourcesContent":["/**\n * Extension → Shiki language id.\n *\n * A pure data module on purpose: `code-manifest.ts` derives the extensions it\n * claims from this map, so the list the registry matches on and the list the\n * highlighter can actually tokenize can never drift apart. Adding a language is\n * one line here.\n *\n * What is deliberately ABSENT is as load-bearing as what is present. `json`,\n * `csv`, `md` and `svg` are claimed by their own adapters — a JSON file gets a\n * collapsible tree, not a coloured wall of text — and an extension listed in two\n * manifests would be decided by registration order rather than by design.\n */\n\nimport type { BundledLanguage } from \"shiki\";\n\n/**\n * Shiki's own language ids.\n *\n * `satisfies Record<string, BundledLanguage>` is the point of the annotation: a\n * typo'd grammar name fails typecheck HERE, rather than at runtime as a file\n * that silently refuses to highlight. Type-only import, so this stays a pure\n * data module with no edge to the engine.\n */\nconst LANGUAGE_BY_EXTENSION = {\n bash: \"shellscript\",\n c: \"c\",\n cc: \"cpp\",\n cjs: \"javascript\",\n cpp: \"cpp\",\n cs: \"csharp\",\n css: \"css\",\n cts: \"typescript\",\n dart: \"dart\",\n diff: \"diff\",\n dockerfile: \"docker\",\n go: \"go\",\n gql: \"graphql\",\n graphql: \"graphql\",\n groovy: \"groovy\",\n h: \"c\",\n hcl: \"hcl\",\n hpp: \"cpp\",\n htm: \"html\",\n html: \"html\",\n ini: \"ini\",\n java: \"java\",\n js: \"javascript\",\n jsx: \"jsx\",\n kt: \"kotlin\",\n kts: \"kotlin\",\n less: \"less\",\n lua: \"lua\",\n makefile: \"make\",\n mjs: \"javascript\",\n mts: \"typescript\",\n patch: \"diff\",\n php: \"php\",\n pl: \"perl\",\n proto: \"proto\",\n ps1: \"powershell\",\n py: \"python\",\n r: \"r\",\n rb: \"ruby\",\n rs: \"rust\",\n sass: \"sass\",\n scala: \"scala\",\n scss: \"scss\",\n sh: \"shellscript\",\n sql: \"sql\",\n svelte: \"svelte\",\n swift: \"swift\",\n tf: \"terraform\",\n toml: \"toml\",\n ts: \"typescript\",\n tsx: \"tsx\",\n vue: \"vue\",\n xml: \"xml\",\n yaml: \"yaml\",\n yml: \"yaml\",\n zsh: \"shellscript\",\n} as const satisfies Record<string, BundledLanguage>;\n\n/** Every extension the code adapter claims, lowercase and without the dot. */\nexport const CODE_EXTENSIONS = Object.keys(LANGUAGE_BY_EXTENSION);\n\n/**\n * The Shiki language for a file name, or `undefined` when nothing matches.\n *\n * Matches the extension first, then the whole (lowercased) file name — that is\n * what catches `Dockerfile` and `Makefile`, which carry their language in the\n * name and have no extension at all.\n */\nexport function languageFor(fileName: string): BundledLanguage | undefined {\n const name = fileName.toLowerCase();\n const dot = name.lastIndexOf(\".\");\n const extension = dot > 0 ? name.slice(dot + 1) : name;\n const map: Record<string, BundledLanguage> = LANGUAGE_BY_EXTENSION;\n return map[extension] ?? map[name];\n}\n","import type { AdapterManifest } from \"../../core/types\";\nimport { PROTOCOL_VERSION } from \"../../core/types\";\nimport { CODE_EXTENSIONS } from \"./code-language\";\n\n/**\n * Eager, data-only (see `image-manifest.ts`).\n *\n * Claims EXTENSIONS only — never the `code` category. That is what keeps the\n * plain-text adapter as the backstop: a `.log`, a `.env` or an unknown\n * extension still opens as text instead of failing to find a grammar, and a\n * consumer who never installs Shiki loses highlighting rather than the file.\n */\nexport const codeManifest: AdapterManifest = {\n id: \"code\",\n protocol: PROTOCOL_VERSION,\n extensions: CODE_EXTENSIONS,\n capabilities: { text: true, search: true, highlight: [\"quote\", \"range\"] },\n requires: [\"shiki\"],\n};\n"],"mappings":";;;;;;AAwBA,IAAM,wBAAwB;AAAA,EAC5B,MAAM;AAAA,EACN,GAAG;AAAA,EACH,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,KAAK;AAAA,EACL,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,KAAK;AAAA,EACL,MAAM;AAAA,EACN,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,GAAG;AAAA,EACH,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,MAAM;AAAA,EACN,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,UAAU;AAAA,EACV,KAAK;AAAA,EACL,KAAK;AAAA,EACL,OAAO;AAAA,EACP,KAAK;AAAA,EACL,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,KAAK;AAAA,EACL,IAAI;AAAA,EACJ,GAAG;AAAA,EACH,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,OAAO;AAAA,EACP,MAAM;AAAA,EACN,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,IAAI;AAAA,EACJ,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AAAA,EACL,MAAM;AAAA,EACN,KAAK;AAAA,EACL,KAAK;AACP;AAGO,IAAM,kBAAkB,OAAO,KAAK,qBAAqB;AASzD,SAAS,YAAY,UAA+C;AACzE,QAAM,OAAO,SAAS,YAAY;AAClC,QAAM,MAAM,KAAK,YAAY,GAAG;AAChC,QAAM,YAAY,MAAM,IAAI,KAAK,MAAM,MAAM,CAAC,IAAI;AAClD,QAAM,MAAuC;AAC7C,SAAO,IAAI,SAAS,KAAK,IAAI,IAAI;AACnC;;;ACvFO,IAAM,eAAgC;AAAA,EAC3C,IAAI;AAAA,EACJ,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,cAAc,EAAE,MAAM,MAAM,QAAQ,MAAM,WAAW,CAAC,SAAS,OAAO,EAAE;AAAA,EACxE,UAAU,CAAC,OAAO;AACpB;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core/types.ts","../src/core/errors.ts"],"sourcesContent":["/**\n * The adapter protocol — what a format plugin declares, what it produces, and\n * what renders it.\n *\n * ## The two halves, and why they are split\n *\n * A registry entry is an eager {@link AdapterManifest} plus a lazy loader. The\n * manifest is plain data available the moment the registry is created, so the\n * chrome can decide which controls to show and whether a file is openable at all\n * **without downloading a parser**. The loader is a `() => import(…)` that pulls\n * the parser AND its renderer together, only once a file of that kind is opened.\n *\n * ## Adapters emit DATA, not HTML (ADR 0024 §3)\n *\n * An adapter's `load()` returns an {@link AdapterDocument} — a model — and ships\n * a `Renderer` that draws it with brand-ui components. No adapter returns an\n * HTML string, so no adapter can smuggle a colour, a font or a border past the\n * token layer. This is the single decision that keeps the viewer themeable.\n */\n\nimport type {\n DocumentAddressKind,\n FileCategory,\n ProseHeadingLevel,\n ResolvedFileSource,\n} from \"@elabs-ai/components-ui\";\nimport type { ComponentType } from \"react\";\n\nimport type { ResolvedHighlight } from \"./highlight\";\nimport type { TextIndex } from \"./text-index\";\n\n/**\n * The adapter protocol version. Bumped only when the shapes below change in a\n * way an existing adapter would not survive; an adapter declaring a different\n * version is rejected with a named error rather than crashing at render.\n */\nexport const PROTOCOL_VERSION = 1;\n\n/**\n * A quarter-turn clockwise rotation applied to the whole document.\n *\n * Document-level, not per-page: turning one page of a scan and leaving the rest\n * is an AUTHORING act (it changes the file), and this package reads files.\n */\nexport type DocumentRotation = 0 | 90 | 180 | 270;\n\n/** Scale the content to a dimension of the viewport instead of to a fixed number. */\nexport type ZoomFit = \"fit-width\" | \"fit-page\";\n\n/** One page's size at scale 1, in CSS pixels. */\nexport interface PageSize {\n width: number;\n height: number;\n}\n\n/**\n * What the chrome asks a renderer to draw at.\n *\n * A fit mode is a REQUEST, not a scale: only the renderer knows how wide its own\n * viewport is and how big the page is, so it resolves the request and reports the\n * number back through {@link AdapterRendererProps.onZoomResolved}. Resolving it\n * in the provider would mean the shell measuring a box it does not own.\n */\nexport type ZoomLevel = number | ZoomFit;\n\n/** What the chrome may offer for a format, known before any parser is fetched. */\nexport interface AdapterCapabilities {\n /** The document is paginated — show the pager. */\n pages?: boolean;\n /** The rendered content can be scaled — show the zoom control. */\n zoom?: boolean;\n /** The rendered content can be rotated. */\n rotate?: boolean;\n /**\n * The document exposes text that can be searched.\n *\n * An OVERRIDE, not the source of truth: the shell offers find-in-document\n * whenever a format has both a text projection and a text-based\n * {@link AdapterCapabilities.highlight} kind, and reads this only when an\n * adapter says otherwise. Left as the source of truth it would deny the find\n * box to PDF — the format readers most expect it on — purely because that\n * manifest predates the feature and never declared the flag.\n *\n * It overrides in the OFF direction only, so a declared `true` is inert: it\n * cannot grant find to a format with no text or no range-painting renderer,\n * because the box would then have nothing to show for a match.\n */\n search?: boolean;\n /**\n * Which {@link DocumentAddress} kinds this adapter's `Renderer` can actually\n * paint. Absent means none.\n *\n * On the EAGER manifest on purpose: an app can decide whether to show a\n * \"jump to the cited passage\" affordance before it downloads pdf.js.\n *\n * It is a promise the renderer has to keep — `pnpm viewer-highlight:check`\n * fails a manifest that declares a kind its adapter never exercises.\n */\n highlight?: readonly DocumentAddressKind[];\n /** The document has an outline/table of contents for the sidebar. */\n outline?: boolean;\n /** The document can produce page thumbnails for the sidebar. */\n thumbnails?: boolean;\n /** A plain-text projection exists — enables the raw/source toggle and copy. */\n text?: boolean;\n}\n\n/**\n * What an adapter can open. At least one of `extensions` / `mediaTypes` /\n * `categories` must be present, or nothing will ever route to it.\n */\nexport interface AdapterManifest {\n /**\n * Stable identity and the OVERRIDE key: registering a different loader under\n * an existing id replaces it, which is how a consumer swaps a built-in.\n */\n id: string;\n /** Must equal {@link PROTOCOL_VERSION}. */\n protocol: number;\n /**\n * Tie-break when several adapters claim the same file. Built-ins are `0`;\n * register above it to win, below it to act only as a fallback.\n */\n priority?: number;\n /** Extensions claimed, lowercase and without the dot. The most specific match. */\n extensions?: string[];\n /** MIME types claimed. A trailing slash makes it a prefix (`\"image/\"`). */\n mediaTypes?: string[];\n /** Coarse categories claimed. The broadest match — a catch-all fallback. */\n categories?: FileCategory[];\n /** Which chrome controls apply. Absent keys are `false`. */\n capabilities?: AdapterCapabilities;\n /**\n * Optional peer packages this adapter dynamically imports. Purely\n * informational to the registry — it is what the \"install this to open that\"\n * message names when the import fails to resolve.\n */\n requires?: string[];\n}\n\n/**\n * An adapter's parsed output. Adapters EXTEND this with their own payload; the\n * fields here are the parts the shell itself understands, so it can drive the\n * pager, the search box and the raw toggle without knowing the format.\n */\nexport interface AdapterDocument {\n /** Adapter-defined discriminant, matched by that adapter's own `Renderer`. */\n readonly kind: string;\n /** Page count for a paginated document. Omit for single-surface formats. */\n readonly pageCount?: number;\n /**\n * Each page's size at scale 1, in document order.\n *\n * What lets a continuously-scrolling renderer reserve the right height for a\n * page it has not drawn yet, so the scrollbar means something from the first\n * frame instead of growing as pages arrive. May be SHORTER than\n * {@link AdapterDocument.pageCount} — measuring 900 pages up front costs 900\n * round-trips to a worker, so an adapter is free to describe the pages it\n * already touched and let the renderer assume the rest match.\n */\n readonly pageSizes?: readonly PageSize[];\n /** Plain-text projection, when the format has one. Powers search, copy and the raw view. */\n readonly text?: string;\n /**\n * Whether {@link AdapterDocument.text} is CAPPED rather than complete.\n *\n * Changes what \"we couldn't find that passage\" means: absent from the whole\n * document, or simply past the part we previewed. Those deserve different\n * sentences, and only the adapter knows which one is true.\n */\n readonly textTruncated?: boolean;\n /**\n * The map from {@link AdapterDocument.text} back into this adapter's own\n * model, so a character range can become a block, a page or a cell.\n *\n * The `ref` type is the adapter's business — its own `Renderer` is the only\n * thing that reads it, which is why it is `unknown` here.\n */\n readonly textIndex?: TextIndex<unknown>;\n}\n\n/** Everything an adapter is given while loading a document. */\nexport interface AdapterLoadContext {\n /** Cancels the load — always honour it; a stale parse must not win a race. */\n signal?: AbortSignal;\n /** Report determinate progress in `0..1` where the format allows it. */\n onProgress?: (fraction: number) => void;\n}\n\n/** A parser instance. One per document — adapters may hold per-document state. */\nexport interface FileAdapter {\n load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<AdapterDocument>;\n /** Release anything the document holds (workers, object URLs, decoded buffers). */\n dispose?(): void;\n}\n\n/** Props every adapter `Renderer` receives. */\nexport interface AdapterRendererProps {\n /** The value this adapter's own `load()` returned — narrow it by `kind`. */\n document: AdapterDocument;\n /** The source, for identity, `alt` text and download actions. */\n source: ResolvedFileSource;\n className?: string;\n /**\n * The heading rung a document's own top-level heading renders at.\n *\n * A viewed file carries its OWN heading tree, and that tree has to be\n * relative to the page hosting it: a README's `#` inside an app that already\n * has an `<h1>` would otherwise put two `h1`s in a screen reader's flat\n * heading list. `FileViewerContent` passes the provider's value; an adapter\n * that renders headings offsets by it (`clampHeadingLevel(own + base - 1)`),\n * and one that renders none ignores it. Same seam as\n * `@elabs-ai/components-ai`'s `MarkdownView baseHeadingLevel`.\n *\n * Optional, so adding it is not a `PROTOCOL_VERSION` change.\n */\n baseHeadingLevel?: ProseHeadingLevel;\n /**\n * The parts of the document to mark, already located — citations from the\n * app and the viewer's own find-in-document matches, in document order.\n *\n * The shell has done LOCATE (a quote is now a character range); the renderer\n * does MAP and PAINT, because only it knows that offsets 812–847 are the\n * second half of block 14. An adapter renders only the entries whose `status`\n * is `\"resolved\"` and whose `address.kind` it declared, and ignores the rest\n * — the shell is what tells the reader about a miss.\n *\n * Optional, so adding it is not a `PROTOCOL_VERSION` change.\n */\n highlights?: readonly ResolvedHighlight[];\n /**\n * Which highlight the viewer is pointed at. The renderer scrolls it into\n * view, pages to it if the format paginates, and draws it distinctly.\n *\n * Also present as `active` on the entry itself; this is the shortcut for a\n * renderer that only needs to know whether anything changed.\n */\n activeHighlightId?: string | null;\n /**\n * Which page the reader is on, 1-based — the provider's, not the renderer's.\n *\n * Page, zoom and rotation are PROVIDER state (ADR 0026) so a control can live\n * anywhere: the shell's own toolbar, an app's page header, a deep link. While\n * these were `useState` inside each renderer nothing outside the canvas could\n * read or drive them.\n *\n * Uncontrolled when absent — a renderer used outside a `FileViewerProvider`\n * keeps its own page, so it still works standalone. Optional, so adding it is\n * not a `PROTOCOL_VERSION` change.\n */\n pageNumber?: number;\n /**\n * Report a page the RENDERER navigated to on its own — turning to a cited\n * page, or (once pages scroll continuously) the page scrolled into view. The\n * provider is what keeps the pager's number honest.\n */\n onPageChange?: (page: number) => void;\n /** The scale, or a fit mode to resolve. Absent means the renderer's own default. */\n zoom?: ZoomLevel;\n /**\n * Report the scale a {@link ZoomFit} actually resolved to.\n *\n * Only a renderer knows this — it is the one measuring its viewport. Without\n * it the chrome cannot show a percentage for a fitted page, and the reader's\n * next \"zoom in\" would step from the last fixed stop rather than from what is\n * on screen. A renderer that never resolves a fit mode never calls it.\n */\n onZoomResolved?: (scale: number) => void;\n /** Quarter-turns clockwise. Ignored by renderers whose manifest omits `rotate`. */\n rotation?: DocumentRotation;\n}\n\n/**\n * What an adapter module exports. `manifest` is re-declared here so the registry\n * can verify the loaded module is the one the eager manifest promised.\n */\nexport interface AdapterModule {\n manifest: AdapterManifest;\n /** Fresh instance per document — never a shared singleton. */\n create(): FileAdapter;\n Renderer: ComponentType<AdapterRendererProps>;\n}\n\n/** A lazy adapter loader. Always `() => import(\"…\")` so nothing heavy is eager. */\nexport type AdapterLoader = () => Promise<AdapterModule | { default: AdapterModule }>;\n","/**\n * Typed failures, so the UI can decide what to SHOW without string-matching a\n * message.\n *\n * The `message` on these errors is developer-facing — it goes to the console and\n * to bug reports. Everything the user reads is looked up from {@link ViewerErrorCode}\n * through `t()` (ADR 0017), which is why the code, not the prose, is the contract.\n */\n\n/** Why a file could not be shown. */\nexport type ViewerErrorCode =\n /** No registered adapter claims this file. */\n | \"unsupported-format\"\n /** An adapter was built against a different registry protocol. */\n | \"protocol-mismatch\"\n /** The adapter exists but its optional peer dependency is not installed. */\n | \"parser-missing\"\n /** The bytes could not be obtained (network, permissions, a revoked blob). */\n | \"read-failed\"\n /** The bytes arrived but the parser rejected them. */\n | \"parse-failed\"\n /** The load was cancelled — a new source, or an unmounted view. */\n | \"aborted\";\n\nexport interface ViewerErrorOptions extends ErrorOptions {\n /** The file this is about, for the developer-facing message. */\n fileName?: string;\n /** For `parser-missing`: the packages a consumer has to install. */\n packages?: string[];\n}\n\n/** A failure a `FileViewer` can render, carrying a machine-readable {@link ViewerErrorCode}. */\nexport class ViewerError extends Error {\n readonly code: ViewerErrorCode;\n readonly fileName?: string;\n readonly packages?: string[];\n\n constructor(code: ViewerErrorCode, message: string, options: ViewerErrorOptions = {}) {\n super(message, { cause: options.cause });\n this.name = \"ViewerError\";\n this.code = code;\n this.fileName = options.fileName;\n this.packages = options.packages;\n }\n}\n\n/** True for a {@link ViewerError}, without relying on `instanceof` across bundles. */\nexport function isViewerError(value: unknown): value is ViewerError {\n return value instanceof ViewerError || (value instanceof Error && value.name === \"ViewerError\");\n}\n\n/** True when the failure is a cancellation rather than something to report. */\nexport function isAbort(value: unknown): boolean {\n if (isViewerError(value)) return value.code === \"aborted\";\n return value instanceof Error && (value.name === \"AbortError\" || value.name === \"TimeoutError\");\n}\n\n/**\n * Wrap an unknown throw as a {@link ViewerError}, preserving an existing one and\n * mapping an abort to the `aborted` code so callers do not report cancellations\n * as failures.\n */\nexport function toViewerError(\n value: unknown,\n fallbackCode: ViewerErrorCode,\n options: ViewerErrorOptions = {},\n): ViewerError {\n if (isViewerError(value)) return value;\n if (isAbort(value)) {\n return new ViewerError(\"aborted\", \"The file load was cancelled.\", { ...options, cause: value });\n }\n const message = value instanceof Error ? value.message : String(value);\n return new ViewerError(fallbackCode, message, { ...options, cause: value });\n}\n\n/**\n * A dynamic `import()` of an OPTIONAL peer that is not installed.\n *\n * Bundlers and Node report this differently (`ERR_MODULE_NOT_FOUND`,\n * `Cannot find module`, `Failed to resolve module specifier`, Vite's\n * `Failed to fetch dynamically imported module`), so match on the shapes rather\n * than one runtime's wording. A false positive here is cheap — the user is told\n * to install a package instead of seeing a raw parse error — and a false\n * negative is the anyview failure mode this exists to avoid: an unhelpful\n * module-resolution stack trace where a \"install papaparse to open CSV files\"\n * message belongs.\n */\nexport function isModuleNotFound(value: unknown): boolean {\n if (!(value instanceof Error)) return false;\n if (\"code\" in value && value.code === \"ERR_MODULE_NOT_FOUND\") return true;\n return /cannot find module|failed to resolve|dynamically imported module|module not found/i.test(\n value.message,\n );\n}\n\n/**\n * The `parser-missing` error, worded the same wherever the peer turns out to be\n * absent.\n *\n * There are TWO such places, which is why this is a function and not a literal\n * at one call site. The registry catches it when the adapter MODULE cannot be\n * fetched; `FileViewerProvider` catches it when the module loaded fine and the\n * adapter's own `await import(\"mammoth\")` — inside `load()`, where every parser\n * engine is actually reached — rejects. Without the second, a consumer who\n * skipped an optional peer was told their file was damaged and offered a retry\n * that could never work.\n */\nexport function parserMissingError(\n adapterId: string,\n packages: string[],\n options: ViewerErrorOptions = {},\n): ViewerError {\n return new ViewerError(\n \"parser-missing\",\n packages.length > 0\n ? `The \"${adapterId}\" adapter needs ${packages.join(\", \")}, which is not installed.`\n : `The \"${adapterId}\" adapter could not be loaded.`,\n { ...options, packages },\n );\n}\n"],"mappings":";;;AAoCO,IAAM,mBAAmB;;;ACJzB,IAAM,cAAN,cAA0B,MAAM;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EAET,YAAY,MAAuB,SAAiB,UAA8B,CAAC,GAAG;AACpF,UAAM,SAAS,EAAE,OAAO,QAAQ,MAAM,CAAC;AACvC,SAAK,OAAO;AACZ,SAAK,OAAO;AACZ,SAAK,WAAW,QAAQ;AACxB,SAAK,WAAW,QAAQ;AAAA,EAC1B;AACF;AAGO,SAAS,cAAc,OAAsC;AAClE,SAAO,iBAAiB,eAAgB,iBAAiB,SAAS,MAAM,SAAS;AACnF;AAGO,SAAS,QAAQ,OAAyB;AAC/C,MAAI,cAAc,KAAK,EAAG,QAAO,MAAM,SAAS;AAChD,SAAO,iBAAiB,UAAU,MAAM,SAAS,gBAAgB,MAAM,SAAS;AAClF;AAOO,SAAS,cACd,OACA,cACA,UAA8B,CAAC,GAClB;AACb,MAAI,cAAc,KAAK,EAAG,QAAO;AACjC,MAAI,QAAQ,KAAK,GAAG;AAClB,WAAO,IAAI,YAAY,WAAW,gCAAgC,EAAE,GAAG,SAAS,OAAO,MAAM,CAAC;AAAA,EAChG;AACA,QAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACrE,SAAO,IAAI,YAAY,cAAc,SAAS,EAAE,GAAG,SAAS,OAAO,MAAM,CAAC;AAC5E;AAcO,SAAS,iBAAiB,OAAyB;AACxD,MAAI,EAAE,iBAAiB,OAAQ,QAAO;AACtC,MAAI,UAAU,SAAS,MAAM,SAAS,uBAAwB,QAAO;AACrE,SAAO,qFAAqF;AAAA,IAC1F,MAAM;AAAA,EACR;AACF;AAcO,SAAS,mBACd,WACA,UACA,UAA8B,CAAC,GAClB;AACb,SAAO,IAAI;AAAA,IACT;AAAA,IACA,SAAS,SAAS,IACd,QAAQ,SAAS,mBAAmB,SAAS,KAAK,IAAI,CAAC,8BACvD,QAAQ,SAAS;AAAA,IACrB,EAAE,GAAG,SAAS,SAAS;AAAA,EACzB;AACF;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/adapters/pdf/pdf-manifest.ts","../src/core/zoom.ts","../src/adapters/pdf/pdf-engine.ts"],"sourcesContent":["import type { AdapterManifest } from \"../../core/types\";\nimport { PROTOCOL_VERSION } from \"../../core/types\";\n\n/**\n * Eager, data-only. Split from the adapter module so `createDefaultRegistry`\n * can answer \"can this be opened, and what controls apply\" without pulling\n * pdf.js — a ~1 MB engine — into the entry chunk.\n *\n * `requires` is what the \"install this\" message names when `pdfjs-dist` is not\n * present: the adapter is registered either way, so the reader is told what is\n * missing instead of being told the file type is unsupported.\n *\n * The only adapter that declares `rect`: a PDF page has a fixed coordinate\n * space, so a producer that knows where a passage sits on the page — an OCR\n * pipeline, a layout-aware chunker — can address it geometrically and skip the\n * text projection entirely.\n */\nexport const pdfManifest: AdapterManifest = {\n id: \"pdf\",\n protocol: PROTOCOL_VERSION,\n extensions: [\"pdf\"],\n mediaTypes: [\"application/pdf\"],\n capabilities: {\n pages: true,\n zoom: true,\n text: true,\n highlight: [\"quote\", \"range\", \"rect\"],\n },\n requires: [\"pdfjs-dist\"],\n};\n","/**\n * The zoom vocabulary — the stops, and how the chrome moves between them.\n *\n * Lives in `core/` rather than in the PDF adapter (where the stops started)\n * because zoom is now provider state: the shell's zoom control, the provider's\n * `zoomIn`/`zoomOut` and every adapter that scales its content all have to agree\n * on the same ladder, or \"125%\" in the toolbar and the scale the page rendered\n * at drift apart.\n */\n\nimport type { ZoomFit, ZoomLevel } from \"./types\";\n\n/**\n * Zoom stops, in document scale.\n *\n * Discrete rather than continuous: a slider is imprecise with a pointer and\n * impossible with a keyboard at this size, and \"somewhere near 137%\" is not a\n * state a reader ever wants to be in. The ladder is the one from the original\n * PDF pager, unchanged, so existing screens land on the same numbers.\n */\nexport const VIEWER_ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3] as const;\n\n/** 100% — the scale a document opens at. */\nexport const DEFAULT_ZOOM = 1;\n\n/** Float slack, so `1.0000000000000002 > 1` does not skip a stop. */\nconst EPSILON = 1e-6;\n\n/** Whether a zoom level is a fit mode rather than a fixed scale. */\nexport function isZoomFit(zoom: ZoomLevel): zoom is ZoomFit {\n return typeof zoom === \"string\";\n}\n\n/**\n * The next stop above or below `from`.\n *\n * Takes an arbitrary scale, not an index, because a fit mode resolves to\n * whatever the viewport made it (1.37, say) and the reader's next \"zoom in\" has\n * to land on a real stop above that — an index-based pager would jump back to\n * wherever the ladder was last parked.\n */\nexport function stepZoom(from: number, delta: 1 | -1): number {\n const steps = VIEWER_ZOOM_STEPS;\n const first = steps[0];\n const last = steps[steps.length - 1] as number;\n if (delta === 1) return steps.find((step) => step > from + EPSILON) ?? last;\n return [...steps].reverse().find((step) => step < from - EPSILON) ?? (first as number);\n}\n\n/** Whether there is a stop left in that direction — what disables the button. */\nexport function canStepZoom(from: number, delta: 1 | -1): boolean {\n const steps = VIEWER_ZOOM_STEPS;\n return delta === 1\n ? from < (steps[steps.length - 1] as number) - EPSILON\n : from > (steps[0] as number) + EPSILON;\n}\n","/**\n * The pdf.js seam — one place that knows the engine exists.\n *\n * Everything here is deliberately NOT React. Splitting the engine wiring from\n * the renderer keeps two things true: the dynamic `import(\"pdfjs-dist\")` has a\n * single call site (so `heavy-deps:check` has one edge to police), and an app\n * can configure the worker before any file is ever opened.\n *\n * ## Why a worker is not optional\n *\n * pdf.js parses and rasterizes on a worker thread; without one it falls back to\n * doing that on the main thread, and a 40-page document freezes the tab. The\n * default below resolves the worker that ships INSIDE `pdfjs-dist`, via\n * `new URL(…, import.meta.url)` — the form every modern bundler understands, so\n * it works with no configuration in Vite/webpack/Next.\n *\n * ## Why an app may still have to configure it\n *\n * A CSP without `worker-src blob:` — or a bundler that cannot see through the\n * `new URL` form — needs the worker served as a real asset instead. Hence\n * {@link configurePdfEngine}: set `workerSrc` to a URL your app serves, and the\n * default is never consulted. `cMapUrl` / `standardFontDataUrl` are the same\n * story for CJK text and non-embedded fonts, which pdf.js fetches on demand.\n */\n\n/** Engine wiring an app can override. Every field is optional. */\nexport interface PdfEngineConfig {\n /** URL of `pdf.worker.min.mjs`. Defaults to the copy inside `pdfjs-dist`. */\n workerSrc?: string;\n /** Directory of pdf.js `cmaps/`, for documents with CJK text. */\n cMapUrl?: string;\n /** Directory of pdf.js `standard_fonts/`, for documents that embed no fonts. */\n standardFontDataUrl?: string;\n}\n\nlet config: PdfEngineConfig = {};\n\n/**\n * Point the PDF adapter at your own copies of the pdf.js assets.\n *\n * Call once at app start, before a PDF is opened. Calling it later is harmless\n * but only affects documents opened afterwards — pdf.js reads the worker\n * setting when a document is created.\n */\nexport function configurePdfEngine(next: PdfEngineConfig): void {\n config = { ...config, ...next };\n}\n\n/** What the adapter will actually use, after any {@link configurePdfEngine} call. */\nexport function getPdfEngineConfig(): Readonly<PdfEngineConfig> {\n return config;\n}\n\n/**\n * The pdf.js surface this adapter uses, declared structurally.\n *\n * `pdfjs-dist` is an OPTIONAL peer, so its types are not guaranteed to be\n * installed — importing them would make this package's typecheck depend on a\n * package a consumer may not have. Declaring the handful of members used here\n * keeps the build honest without that coupling, and a shape change in pdf.js\n * surfaces as a runtime failure the `parse-failed` panel already handles.\n */\nexport interface PdfPageViewport {\n width: number;\n height: number;\n}\n\nexport interface PdfTextItem {\n str: string;\n transform: number[];\n width: number;\n height: number;\n fontName?: string;\n}\n\nexport interface PdfPage {\n getViewport(options: { scale: number; rotation?: number }): PdfPageViewport;\n render(options: { canvasContext: CanvasRenderingContext2D; viewport: PdfPageViewport }): {\n promise: Promise<void>;\n cancel(): void;\n };\n getTextContent(): Promise<{ items: PdfTextItem[] }>;\n cleanup(): void;\n}\n\nexport interface PdfDocument {\n numPages: number;\n getPage(pageNumber: number): Promise<PdfPage>;\n}\n\n/**\n * An open document plus the one call that releases its worker.\n *\n * The two are separate because **the worker belongs to the LOADING TASK, not to\n * the document**: pdf.js removed `PDFDocumentProxy.destroy()` in v5, so\n * `document.destroy()` is not a function on the engine this package installs. It\n * type-checked anyway — the structural `PdfDocument` interface here declared a\n * method the real proxy does not have — and threw a `TypeError` on every unmount,\n * leaking exactly the worker per document that `dispose()` exists to prevent.\n * Keeping the teardown on this object rather than on `document` is what stops the\n * mistake from being expressible.\n */\nexport interface PdfSession {\n document: PdfDocument;\n destroy(): Promise<void>;\n}\n\ninterface PdfJsModule {\n GlobalWorkerOptions: { workerSrc: string };\n getDocument(options: Record<string, unknown>): {\n promise: Promise<PdfDocument>;\n destroy(): Promise<void>;\n };\n}\n\nlet enginePromise: Promise<PdfJsModule> | undefined;\n\n/**\n * Load pdf.js once per page load and wire its worker.\n *\n * The result is cached: opening a second PDF must not re-fetch a megabyte of\n * engine. A FAILED load is not cached, so a transient network error can be\n * retried by the viewer's own retry button.\n */\nexport async function loadPdfEngine(): Promise<PdfJsModule> {\n enginePromise ??= (async () => {\n // The ONLY edge to the optional peer. A static import here would break\n // every consumer that did not install pdfjs-dist (heavy-deps:check).\n const pdfjs = (await import(\"pdfjs-dist\")) as unknown as PdfJsModule;\n pdfjs.GlobalWorkerOptions.workerSrc = config.workerSrc ?? defaultWorkerSrc();\n return pdfjs;\n })().catch((error: unknown) => {\n enginePromise = undefined;\n throw error;\n });\n return enginePromise;\n}\n\n/**\n * The worker that ships with the installed `pdfjs-dist`.\n *\n * `new URL(…, import.meta.url)` is a *bundler instruction*, not a runtime path\n * lookup: Vite, webpack 5 and Turbopack all rewrite it to an emitted asset URL.\n * It is wrapped because a runtime without `import.meta.url` (a CJS test) throws\n * here, and a missing worker should surface as \"configure the worker\", not as an\n * unexplained crash.\n */\nfunction defaultWorkerSrc(): string {\n return new URL(\"pdfjs-dist/build/pdf.worker.min.mjs\", import.meta.url).href;\n}\n\n/** Open a document from bytes. The caller owns `destroy()` — see the adapter's `dispose`. */\nexport async function openPdfDocument(\n bytes: Uint8Array,\n signal?: AbortSignal,\n): Promise<PdfSession> {\n const pdfjs = await loadPdfEngine();\n const task = pdfjs.getDocument({\n data: bytes,\n // Defence in depth: pdf.js can evaluate font programs, and a viewer opens\n // files the app did not write. The cost is a rare, slightly slower font\n // path; the benefit is that a malicious PDF has no `eval` to reach for.\n isEvalSupported: false,\n cMapUrl: config.cMapUrl,\n cMapPacked: true,\n standardFontDataUrl: config.standardFontDataUrl,\n });\n signal?.addEventListener(\"abort\", () => void task.destroy(), { once: true });\n const document = await task.promise;\n return { document, destroy: () => task.destroy() };\n}\n"],"mappings":";;;;;;AAiBO,IAAM,cAA+B;AAAA,EAC1C,IAAI;AAAA,EACJ,UAAU;AAAA,EACV,YAAY,CAAC,KAAK;AAAA,EAClB,YAAY,CAAC,iBAAiB;AAAA,EAC9B,cAAc;AAAA,IACZ,OAAO;AAAA,IACP,MAAM;AAAA,IACN,MAAM;AAAA,IACN,WAAW,CAAC,SAAS,SAAS,MAAM;AAAA,EACtC;AAAA,EACA,UAAU,CAAC,YAAY;AACzB;;;ACTO,IAAM,oBAAoB,CAAC,KAAK,MAAM,GAAG,MAAM,KAAK,GAAG,CAAC;AAGxD,IAAM,eAAe;AAG5B,IAAM,UAAU;AAGT,SAAS,UAAU,MAAkC;AAC1D,SAAO,OAAO,SAAS;AACzB;AAUO,SAAS,SAAS,MAAc,OAAuB;AAC5D,QAAM,QAAQ;AACd,QAAM,QAAQ,MAAM,CAAC;AACrB,QAAM,OAAO,MAAM,MAAM,SAAS,CAAC;AACnC,MAAI,UAAU,EAAG,QAAO,MAAM,KAAK,CAAC,SAAS,OAAO,OAAO,OAAO,KAAK;AACvE,SAAO,CAAC,GAAG,KAAK,EAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,OAAO,OAAO,OAAO,KAAM;AACxE;AAGO,SAAS,YAAY,MAAc,OAAwB;AAChE,QAAM,QAAQ;AACd,SAAO,UAAU,IACb,OAAQ,MAAM,MAAM,SAAS,CAAC,IAAe,UAC7C,OAAQ,MAAM,CAAC,IAAe;AACpC;;;ACpBA,IAAI,SAA0B,CAAC;AASxB,SAAS,mBAAmB,MAA6B;AAC9D,WAAS,EAAE,GAAG,QAAQ,GAAG,KAAK;AAChC;AAGO,SAAS,qBAAgD;AAC9D,SAAO;AACT;AAgEA,IAAI;AASJ,eAAsB,gBAAsC;AAC1D,qBAAmB,YAAY;AAG7B,UAAM,QAAS,MAAM,OAAO,YAAY;AACxC,UAAM,oBAAoB,YAAY,OAAO,aAAa,iBAAiB;AAC3E,WAAO;AAAA,EACT,GAAG,EAAE,MAAM,CAAC,UAAmB;AAC7B,oBAAgB;AAChB,UAAM;AAAA,EACR,CAAC;AACD,SAAO;AACT;AAWA,SAAS,mBAA2B;AAClC,SAAO,IAAI,IAAI,uCAAuC,YAAY,GAAG,EAAE;AACzE;AAGA,eAAsB,gBACpB,OACA,QACqB;AACrB,QAAM,QAAQ,MAAM,cAAc;AAClC,QAAM,OAAO,MAAM,YAAY;AAAA,IAC7B,MAAM;AAAA;AAAA;AAAA;AAAA,IAIN,iBAAiB;AAAA,IACjB,SAAS,OAAO;AAAA,IAChB,YAAY;AAAA,IACZ,qBAAqB,OAAO;AAAA,EAC9B,CAAC;AACD,UAAQ,iBAAiB,SAAS,MAAM,KAAK,KAAK,QAAQ,GAAG,EAAE,MAAM,KAAK,CAAC;AAC3E,QAAM,WAAW,MAAM,KAAK;AAC5B,SAAO,EAAE,UAAU,SAAS,MAAM,KAAK,QAAQ,EAAE;AACnD;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core/highlight-marks.ts","../src/core/use-highlight-scroll.ts"],"sourcesContent":["/**\n * The MAP + PAINT half of the highlight funnel, for adapters whose document IS\n * its own text projection — plain text, code, and anything else that renders\n * `document.text` verbatim.\n *\n * The shell has already done LOCATE (every quote is now a character range). All\n * that is left is to hand those ranges to `MatchHighlight` and say which one is\n * current. An adapter with a richer model (pages, blocks, cells) maps the same\n * ranges through its own `textIndex` instead and does not use this.\n */\n\nimport { normalizeRanges, type MatchRange } from \"@elabs-ai/components-ui\";\n\nimport type { ResolvedHighlight } from \"./highlight\";\n\n/** What `MatchHighlight` takes: merged ranges, plus which merged mark is current. */\nexport interface MarkRanges {\n /** Clamped, sorted and merged — the same ranges `MatchHighlight` will paint. */\n readonly ranges: readonly MatchRange[];\n /** Index into {@link MarkRanges.ranges}, or `-1` when none is current. */\n readonly activeIndex: number;\n}\n\nconst EMPTY: MarkRanges = { ranges: [], activeIndex: -1 };\n\n/**\n * Fold resolved highlights into the ranges a `<mark>` layer paints.\n *\n * Merging happens HERE rather than being left to `MatchHighlight`, because the\n * two would otherwise disagree about what \"the third mark\" is: overlapping\n * citations collapse into one mark, and an `activeIndex` counted against the\n * unmerged list would then point at the wrong one — or past the end. The active\n * entry is located by which merged range CONTAINS its start, so it stays right\n * no matter how many requests collapsed into that mark.\n *\n * Non-`range` addresses and unresolved entries are skipped: an adapter paints\n * what it can and the shell is what tells the reader about the rest.\n */\nexport function toMarkRanges(\n highlights: readonly ResolvedHighlight[] | undefined,\n textLength: number,\n): MarkRanges {\n if (!highlights || highlights.length === 0) return EMPTY;\n\n const located = highlights.filter(\n (highlight): highlight is ResolvedHighlight & { range: MatchRange } =>\n highlight.status === \"resolved\" && highlight.range !== undefined,\n );\n if (located.length === 0) return EMPTY;\n\n const ranges = normalizeRanges(\n located.map((highlight) => highlight.range),\n textLength,\n );\n\n const active = located.find((highlight) => highlight.active);\n const activeStart = active?.range[0];\n const activeIndex =\n activeStart === undefined\n ? -1\n : ranges.findIndex(([start, end]) => activeStart >= start && activeStart < end);\n\n return { ranges, activeIndex };\n}\n\n/**\n * Re-express document ranges relative to one slice of the document — a code\n * token, a table cell, a block of prose.\n *\n * A renderer that draws its text in pieces cannot hand whole-document offsets to\n * a `<mark>` layer that only knows about the piece in front of it. Ranges are\n * clipped to the slice and rebased to 0; the ones that miss it entirely drop\n * out, which is also how a caller learns there is nothing to mark here (an empty\n * `ranges`). A range spanning several slices survives in each of them, so a\n * citation crossing a line break stays one visual run.\n */\nexport function localizeRanges(\n { ranges, activeIndex }: MarkRanges,\n start: number,\n end: number,\n): MarkRanges {\n if (ranges.length === 0 || start >= end) return EMPTY;\n\n const local: MatchRange[] = [];\n let localActive = -1;\n ranges.forEach(([from, to], index) => {\n const clippedFrom = Math.max(from, start);\n const clippedTo = Math.min(to, end);\n if (clippedFrom >= clippedTo) return;\n if (index === activeIndex) localActive = local.length;\n local.push([clippedFrom - start, clippedTo - start]);\n });\n\n return local.length === 0 ? EMPTY : { ranges: local, activeIndex: localActive };\n}\n","\"use client\";\n\n/**\n * Bring the current highlight into view.\n *\n * Pointing at a passage is only half the job — a mark 4,000 lines down that\n * nobody scrolls to is the same as no mark at all. Every text-ish renderer needs\n * this, so it is written once here rather than per adapter.\n *\n * The current highlight is found by the stable `data-slot` selector its painter\n * emits, not by a ref: both painters produce their elements from a list — marks\n * inside `MatchHighlight`, boxes inside a page overlay — so the renderer has no\n * handle on the individual element and should not grow one just for scrolling.\n */\n\nimport { useEffect, type RefObject } from \"react\";\n\nimport { useReducedMotion } from \"@elabs-ai/components-tokens\";\n\n/**\n * How the current highlight identifies itself in the DOM, whichever way it was\n * painted: a `<mark>` in flowing text, a box over a page raster, or a whole\n * block plated because the format can only be addressed that coarsely.\n */\nexport const ACTIVE_HIGHLIGHT_SELECTOR =\n '[data-slot=\"match-highlight-mark\"][data-active],[data-slot=\"highlight-rect\"][data-active],' +\n '[data-slot=\"highlight-block\"][data-active]';\n\n/**\n * Scroll the current highlight inside `containerRef` into view whenever the\n * viewer is pointed somewhere new.\n *\n * `block: \"center\"` rather than `\"nearest\"`: a cited passage should land where a\n * reader looks, with its surrounding context visible, not flush against the top\n * edge of the pane. Smooth scrolling is skipped under a reduced-motion\n * preference — an imperative scroll is invisible to the CSS media query, so the\n * check has to be explicit.\n *\n * `paintKey` is anything whose change means the highlights were repainted — a\n * page's box list, a re-tokenized document. A renderer that paints\n * asynchronously (a PDF fetches the page's text after the raster) has no element\n * to scroll to at the moment the active id changes, so it passes the thing it\n * was waiting for and the scroll runs once that arrives.\n */\nexport function useScrollActiveHighlightIntoView(\n containerRef: RefObject<HTMLElement | null>,\n activeHighlightId: string | null | undefined,\n paintKey?: unknown,\n): void {\n const reducedMotion = useReducedMotion();\n\n useEffect(() => {\n if (!activeHighlightId) return;\n const target = containerRef.current?.querySelector(ACTIVE_HIGHLIGHT_SELECTOR);\n // jsdom ships no `scrollIntoView`; a viewer must not throw in a test run.\n if (!(target instanceof HTMLElement) || typeof target.scrollIntoView !== \"function\") return;\n target.scrollIntoView({ block: \"center\", behavior: reducedMotion ? \"auto\" : \"smooth\" });\n }, [containerRef, activeHighlightId, paintKey, reducedMotion]);\n}\n"],"mappings":";;;AAWA,SAAS,uBAAwC;AAYjD,IAAM,QAAoB,EAAE,QAAQ,CAAC,GAAG,aAAa,GAAG;AAejD,SAAS,aACd,YACA,YACY;AACZ,MAAI,CAAC,cAAc,WAAW,WAAW,EAAG,QAAO;AAEnD,QAAM,UAAU,WAAW;AAAA,IACzB,CAAC,cACC,UAAU,WAAW,cAAc,UAAU,UAAU;AAAA,EAC3D;AACA,MAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,QAAM,SAAS;AAAA,IACb,QAAQ,IAAI,CAAC,cAAc,UAAU,KAAK;AAAA,IAC1C;AAAA,EACF;AAEA,QAAM,SAAS,QAAQ,KAAK,CAAC,cAAc,UAAU,MAAM;AAC3D,QAAM,cAAc,QAAQ,MAAM,CAAC;AACnC,QAAM,cACJ,gBAAgB,SACZ,KACA,OAAO,UAAU,CAAC,CAAC,OAAO,GAAG,MAAM,eAAe,SAAS,cAAc,GAAG;AAElF,SAAO,EAAE,QAAQ,YAAY;AAC/B;AAaO,SAAS,eACd,EAAE,QAAQ,YAAY,GACtB,OACA,KACY;AACZ,MAAI,OAAO,WAAW,KAAK,SAAS,IAAK,QAAO;AAEhD,QAAM,QAAsB,CAAC;AAC7B,MAAI,cAAc;AAClB,SAAO,QAAQ,CAAC,CAAC,MAAM,EAAE,GAAG,UAAU;AACpC,UAAM,cAAc,KAAK,IAAI,MAAM,KAAK;AACxC,UAAM,YAAY,KAAK,IAAI,IAAI,GAAG;AAClC,QAAI,eAAe,UAAW;AAC9B,QAAI,UAAU,YAAa,eAAc,MAAM;AAC/C,UAAM,KAAK,CAAC,cAAc,OAAO,YAAY,KAAK,CAAC;AAAA,EACrD,CAAC;AAED,SAAO,MAAM,WAAW,IAAI,QAAQ,EAAE,QAAQ,OAAO,aAAa,YAAY;AAChF;;;AC/EA,SAAS,iBAAiC;AAE1C,SAAS,wBAAwB;AAO1B,IAAM,4BACX;AAmBK,SAAS,iCACd,cACA,mBACA,UACM;AACN,QAAM,gBAAgB,iBAAiB;AAEvC,YAAU,MAAM;AACd,QAAI,CAAC,kBAAmB;AACxB,UAAM,SAAS,aAAa,SAAS,cAAc,yBAAyB;AAE5E,QAAI,EAAE,kBAAkB,gBAAgB,OAAO,OAAO,mBAAmB,WAAY;AACrF,WAAO,eAAe,EAAE,OAAO,UAAU,UAAU,gBAAgB,SAAS,SAAS,CAAC;AAAA,EACxF,GAAG,CAAC,cAAc,mBAAmB,UAAU,aAAa,CAAC;AAC/D;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/components/grid-text.ts","../src/components/sheet-table.tsx"],"sourcesContent":["/**\n * The one text projection every tabular format is addressed against.\n *\n * CSV and XLSX arrive through different parsers and render into the same\n * `SheetTable`; they should also be *addressable* the same way, or a citation\n * that resolves in a workbook would miss in the CSV export of the same data.\n * So the projection is written once here and both adapters call it.\n *\n * Shape — a sheet's name (when it has one), then its header row, then its body\n * rows; cells joined by a tab, rows by a newline, sheets by a blank line:\n *\n * ```\n * Q3\n * Region\\tRevenue\n * EMEA\\t4.2M\n *\n * Q4\n * …\n * ```\n *\n * **Rows, not cells, are the finest ref** — the same trade the Word adapter\n * makes. A cell-granular index would need a span per cell and a separator per\n * level; a row-granular one needs neither, because a cell's offset is the sum of\n * the cells before it ({@link chunkOffset}). The granularity of the REF is the\n * row; the granularity of the MARK is still the character.\n */\n\nimport { createTextIndexBuilder, type TextIndex } from \"../core/text-index\";\n\n/** Between two cells of the same row. */\nexport const GRID_CELL_SEPARATOR = \"\\t\";\n/** Between two rows of the same sheet. */\nexport const GRID_ROW_SEPARATOR = \"\\n\";\n/** Between two sheets of the same workbook. */\nexport const GRID_SHEET_SEPARATOR = \"\\n\\n\";\n\n/** The `row` of a sheet's own name line. */\nexport const GRID_NAME_ROW = -2;\n/** The `row` of a sheet's header row. */\nexport const GRID_HEAD_ROW = -1;\n\n/** Where a stretch of the projection came from in the grid. */\nexport interface GridRef {\n /** Index into the sheets. `0` for a single-sheet format like CSV. */\n sheet: number;\n /** Body row index, or {@link GRID_HEAD_ROW} / {@link GRID_NAME_ROW}. */\n row: number;\n}\n\n/** What {@link gridToText} needs from a sheet — the shape both adapters already hold. */\nexport interface GridSheetInput {\n /** The tab's name. Absent for a single-sheet format, which has no tab. */\n name?: string;\n columns: readonly string[];\n rows: readonly (readonly string[])[];\n}\n\n/** Project a workbook (or a one-sheet CSV) to text, with the map back to it. */\nexport function gridToText(sheets: readonly GridSheetInput[]): TextIndex<GridRef> {\n const builder = createTextIndexBuilder<GridRef>({ separator: GRID_ROW_SEPARATOR });\n sheets.forEach((sheet, index) => {\n // The blank line goes before whatever this sheet's FIRST line turns out to\n // be — its name, or its header when it has no name.\n let separator = index === 0 ? undefined : GRID_SHEET_SEPARATOR;\n const push = (chunk: string, row: number) => {\n builder.push(chunk, { sheet: index, row }, separator);\n if (chunk.length > 0) separator = undefined;\n };\n\n if (sheet.name) push(sheet.name, GRID_NAME_ROW);\n push(sheet.columns.join(GRID_CELL_SEPARATOR), GRID_HEAD_ROW);\n sheet.rows.forEach((row, rowIndex) => {\n push(row.join(GRID_CELL_SEPARATOR), rowIndex);\n });\n });\n return builder.build();\n}\n","\"use client\";\n\n/**\n * The one grid every tabular format renders into.\n *\n * CSV and XLSX arrive through completely different parsers and end up wanting\n * exactly the same thing: a header row, body rows, a truncation notice and an\n * `sr-only` caption. That is a PATTERN, not a coincidence, so it is named once\n * here rather than copied a second time (`.claude/rules/design-first.md` —\n * patterns over instances).\n *\n * It stays deliberately dumb: no sorting, no filtering, no virtualization. A\n * preview pane is for looking; past a few thousand rows the right component is\n * `DataTable` in `@elabs-ai/components-data`, which virtualizes.\n */\n\nimport {\n cn,\n Table,\n TableBody,\n TableCaption,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n useLocale,\n} from \"@elabs-ai/components-ui\";\nimport { useRef } from \"react\";\n\nimport { type MarkRanges } from \"../core/highlight-marks\";\nimport { chunkOffset } from \"../core/text-index\";\nimport { useScrollActiveHighlightIntoView } from \"../core/use-highlight-scroll\";\nimport { GRID_CELL_SEPARATOR, GRID_HEAD_ROW } from \"./grid-text\";\nimport { MarkedText } from \"./marked-text\";\n\nexport interface SheetTableProps {\n /** Header cells. May be empty — the grid still renders its body. */\n columns: string[];\n /** Body rows, already capped by the caller. */\n rows: string[][];\n /** Total body rows in the file, when more exist than `rows` holds. */\n totalRows?: number;\n className?: string;\n /** Marks to paint, in projection offsets. */\n marks?: MarkRanges;\n /**\n * Where a row's first cell begins in the projection — {@link GRID_HEAD_ROW}\n * for the header. A function rather than an array so a workbook can answer\n * for the sheet being drawn without slicing an index per tab.\n */\n rowStart?: (row: number) => number | undefined;\n /** Scrolls the current mark into this grid's own viewport when it changes. */\n activeHighlightId?: string | null;\n}\n\nexport function SheetTable({\n columns,\n rows,\n totalRows,\n className,\n marks,\n rowStart,\n activeHighlightId,\n}: SheetTableProps) {\n const { t, formatNumber } = useLocale();\n const viewport = useRef<HTMLDivElement>(null);\n\n useScrollActiveHighlightIntoView(viewport, activeHighlightId);\n\n return (\n <div className={cn(\"flex h-full min-h-0 flex-col gap-2\", className)}>\n {totalRows !== undefined && (\n // A status, not an error: the file is fine, we are simply showing part\n // of it (loading-states.md — a capability bound is news, not an alarm).\n <p role=\"status\" className=\"text-meta text-muted-foreground shrink-0\">\n {t(\"viewer.table.truncated\", { count: formatNumber(rows.length) })}\n </p>\n )}\n {/* The grid keeps its OWN viewport, unlike the flowing formats: a workbook\n puts a sheet-tab bar above it that must not scroll away with the rows.\n A table cell is not focusable, so a scrollable region wrapping one is\n unreachable from a keyboard (WCAG 2.1.1) — `tabIndex={0}` makes it a\n real stop that arrow keys and Page Up/Down drive. `group`, not\n `region`, so a sheet does not mint a second landmark inside the\n viewer's content region. */}\n <div\n ref={viewport}\n tabIndex={0}\n role=\"group\"\n aria-label={t(\"viewer.content\")}\n className=\"focus-ring min-h-0 flex-1 overflow-auto\"\n >\n <Table>\n <TableCaption className=\"sr-only\">\n {t(\"viewer.table.caption\", {\n rows: formatNumber(totalRows ?? rows.length),\n columns: formatNumber(columns.length),\n })}\n </TableCaption>\n <TableHeader>\n <TableRow>\n {columns.map((column, index) => (\n // The file's own header text is the only identity a column has;\n // an empty one still needs a cell so the grid stays aligned.\n <TableHead key={`${column}-${String(index)}`} scope=\"col\">\n <MarkedText\n text={column}\n marks={marks}\n start={chunkOffset(\n columns,\n index,\n rowStart?.(GRID_HEAD_ROW),\n GRID_CELL_SEPARATOR,\n )}\n />\n </TableHead>\n ))}\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row, rowIndex) => {\n const start = rowStart?.(rowIndex);\n return (\n <TableRow key={rowIndex}>\n {columns.map((_, columnIndex) => (\n <TableCell key={columnIndex} className=\"whitespace-pre-wrap\">\n <MarkedText\n text={row[columnIndex] ?? \"\"}\n marks={marks}\n start={chunkOffset(row, columnIndex, start, GRID_CELL_SEPARATOR)}\n />\n </TableCell>\n ))}\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n </div>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;;;AA8BO,IAAM,sBAAsB;AAE5B,IAAM,qBAAqB;AAE3B,IAAM,uBAAuB;AAG7B,IAAM,gBAAgB;AAEtB,IAAM,gBAAgB;AAmBtB,SAAS,WAAW,QAAuD;AAChF,QAAM,UAAU,uBAAgC,EAAE,WAAW,mBAAmB,CAAC;AACjF,SAAO,QAAQ,CAAC,OAAO,UAAU;AAG/B,QAAI,YAAY,UAAU,IAAI,SAAY;AAC1C,UAAM,OAAO,CAAC,OAAe,QAAgB;AAC3C,cAAQ,KAAK,OAAO,EAAE,OAAO,OAAO,IAAI,GAAG,SAAS;AACpD,UAAI,MAAM,SAAS,EAAG,aAAY;AAAA,IACpC;AAEA,QAAI,MAAM,KAAM,MAAK,MAAM,MAAM,aAAa;AAC9C,SAAK,MAAM,QAAQ,KAAK,mBAAmB,GAAG,aAAa;AAC3D,UAAM,KAAK,QAAQ,CAAC,KAAK,aAAa;AACpC,WAAK,IAAI,KAAK,mBAAmB,GAAG,QAAQ;AAAA,IAC9C,CAAC;AAAA,EACH,CAAC;AACD,SAAO,QAAQ,MAAM;AACvB;;;AC5DA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,cAAc;AA+Cf,cAkBA,YAlBA;AAnBD,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAAoB;AAClB,QAAM,EAAE,GAAG,aAAa,IAAI,UAAU;AACtC,QAAM,WAAW,OAAuB,IAAI;AAE5C,mCAAiC,UAAU,iBAAiB;AAE5D,SACE,qBAAC,SAAI,WAAW,GAAG,sCAAsC,SAAS,GAC/D;AAAA,kBAAc;AAAA;AAAA,IAGb,oBAAC,OAAE,MAAK,UAAS,WAAU,4CACxB,YAAE,0BAA0B,EAAE,OAAO,aAAa,KAAK,MAAM,EAAE,CAAC,GACnE;AAAA,IASF;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACL,UAAU;AAAA,QACV,MAAK;AAAA,QACL,cAAY,EAAE,gBAAgB;AAAA,QAC9B,WAAU;AAAA,QAEV,+BAAC,SACC;AAAA,8BAAC,gBAAa,WAAU,WACrB,YAAE,wBAAwB;AAAA,YACzB,MAAM,aAAa,aAAa,KAAK,MAAM;AAAA,YAC3C,SAAS,aAAa,QAAQ,MAAM;AAAA,UACtC,CAAC,GACH;AAAA,UACA,oBAAC,eACC,8BAAC,YACE,kBAAQ,IAAI,CAAC,QAAQ;AAAA;AAAA;AAAA,YAGpB,oBAAC,aAA6C,OAAM,OAClD;AAAA,cAAC;AAAA;AAAA,gBACC,MAAM;AAAA,gBACN;AAAA,gBACA,OAAO;AAAA,kBACL;AAAA,kBACA;AAAA,kBACA,WAAW,aAAa;AAAA,kBACxB;AAAA,gBACF;AAAA;AAAA,YACF,KAVc,GAAG,MAAM,IAAI,OAAO,KAAK,CAAC,EAW1C;AAAA,WACD,GACH,GACF;AAAA,UACA,oBAAC,aACE,eAAK,IAAI,CAAC,KAAK,aAAa;AAC3B,kBAAM,QAAQ,WAAW,QAAQ;AACjC,mBACE,oBAAC,YACE,kBAAQ,IAAI,CAAC,GAAG,gBACf,oBAAC,aAA4B,WAAU,uBACrC;AAAA,cAAC;AAAA;AAAA,gBACC,MAAM,IAAI,WAAW,KAAK;AAAA,gBAC1B;AAAA,gBACA,OAAO,YAAY,KAAK,aAAa,OAAO,mBAAmB;AAAA;AAAA,YACjE,KALc,WAMhB,CACD,KATY,QAUf;AAAA,UAEJ,CAAC,GACH;AAAA,WACF;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;","names":[]}
|