@elabs-ai/components-viewer 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/chunk-2NQ4RSJ3.js +18 -0
- package/dist/chunk-2NQ4RSJ3.js.map +1 -0
- package/dist/chunk-3U3MESBU.js +21 -0
- package/dist/chunk-3U3MESBU.js.map +1 -0
- package/dist/chunk-4OFQYALG.js +147 -0
- package/dist/chunk-4OFQYALG.js.map +1 -0
- package/dist/chunk-5OMONA2G.js +87 -0
- package/dist/chunk-5OMONA2G.js.map +1 -0
- package/dist/chunk-AORNMH77.js +58 -0
- package/dist/chunk-AORNMH77.js.map +1 -0
- package/dist/chunk-BCF3RWRL.js +18 -0
- package/dist/chunk-BCF3RWRL.js.map +1 -0
- package/dist/chunk-FELIAGKI.js +22 -0
- package/dist/chunk-FELIAGKI.js.map +1 -0
- package/dist/chunk-GGR7BNW4.js +18 -0
- package/dist/chunk-GGR7BNW4.js.map +1 -0
- package/dist/chunk-H3OAON3D.js +22 -0
- package/dist/chunk-H3OAON3D.js.map +1 -0
- package/dist/chunk-HMG2ERXH.js +17 -0
- package/dist/chunk-HMG2ERXH.js.map +1 -0
- package/dist/chunk-KOMDRWOU.js +21 -0
- package/dist/chunk-KOMDRWOU.js.map +1 -0
- package/dist/chunk-NMA57QZ7.js +117 -0
- package/dist/chunk-NMA57QZ7.js.map +1 -0
- package/dist/chunk-RGWDIQJR.js +26 -0
- package/dist/chunk-RGWDIQJR.js.map +1 -0
- package/dist/chunk-SLXRUYT3.js +91 -0
- package/dist/chunk-SLXRUYT3.js.map +1 -0
- package/dist/chunk-UJIJECEF.js +21 -0
- package/dist/chunk-UJIJECEF.js.map +1 -0
- package/dist/chunk-UL43NGUG.js +68 -0
- package/dist/chunk-UL43NGUG.js.map +1 -0
- package/dist/chunk-VO2273Z2.js +55 -0
- package/dist/chunk-VO2273Z2.js.map +1 -0
- package/dist/code-adapter-ADZ4UOGN.js +226 -0
- package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
- package/dist/csv-adapter-6VU3FFVU.js +100 -0
- package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
- package/dist/docx-adapter-5CQDHWTD.js +383 -0
- package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
- package/dist/image-adapter-WOHZR24J.js +132 -0
- package/dist/image-adapter-WOHZR24J.js.map +1 -0
- package/dist/index.d.ts +1353 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/dist/json-adapter-ZUW5GQHE.js +90 -0
- package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
- package/dist/markdown-adapter-YC6WTBS4.js +214 -0
- package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
- package/dist/media-adapter-MCTB4GBH.js +78 -0
- package/dist/media-adapter-MCTB4GBH.js.map +1 -0
- package/dist/pdf-adapter-5PMKEXUD.js +424 -0
- package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
- package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
- package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
- package/dist/text-adapter-NFNWB5W3.js +81 -0
- package/dist/text-adapter-NFNWB5W3.js.map +1 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
- package/package.json +103 -0
- package/src/adapters/code/code-adapter.test.tsx +221 -0
- package/src/adapters/code/code-adapter.tsx +272 -0
- package/src/adapters/code/code-language.test.ts +45 -0
- package/src/adapters/code/code-language.ts +100 -0
- package/src/adapters/code/code-manifest.ts +19 -0
- package/src/adapters/code/code-theme.test.ts +46 -0
- package/src/adapters/code/code-theme.ts +99 -0
- package/src/adapters/csv/csv-adapter.test.tsx +169 -0
- package/src/adapters/csv/csv-adapter.tsx +146 -0
- package/src/adapters/csv/csv-manifest.ts +20 -0
- package/src/adapters/docx/docx-adapter.test.tsx +192 -0
- package/src/adapters/docx/docx-adapter.tsx +328 -0
- package/src/adapters/docx/docx-manifest.ts +18 -0
- package/src/adapters/docx/docx-model.test.ts +181 -0
- package/src/adapters/docx/docx-model.ts +294 -0
- package/src/adapters/image/image-adapter.test.tsx +141 -0
- package/src/adapters/image/image-adapter.tsx +191 -0
- package/src/adapters/image/image-manifest.ts +15 -0
- package/src/adapters/index.ts +64 -0
- package/src/adapters/json/json-adapter.test.tsx +77 -0
- package/src/adapters/json/json-adapter.tsx +141 -0
- package/src/adapters/json/json-manifest.ts +11 -0
- package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
- package/src/adapters/markdown/markdown-adapter.tsx +353 -0
- package/src/adapters/markdown/markdown-manifest.ts +21 -0
- package/src/adapters/markdown/markdown-marks.test.ts +85 -0
- package/src/adapters/markdown/markdown-marks.ts +119 -0
- package/src/adapters/media/media-adapter.test.tsx +79 -0
- package/src/adapters/media/media-adapter.tsx +123 -0
- package/src/adapters/media/media-manifest.ts +17 -0
- package/src/adapters/office-fixture.ts +239 -0
- package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
- package/src/adapters/pdf/pdf-adapter.tsx +599 -0
- package/src/adapters/pdf/pdf-engine.ts +171 -0
- package/src/adapters/pdf/pdf-fixture.ts +27 -0
- package/src/adapters/pdf/pdf-manifest.ts +30 -0
- package/src/adapters/pdf/pdf-text.test.ts +171 -0
- package/src/adapters/pdf/pdf-text.ts +202 -0
- package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
- package/src/adapters/pptx/pptx-adapter.tsx +341 -0
- package/src/adapters/pptx/pptx-manifest.ts +19 -0
- package/src/adapters/pptx/pptx-model.test.ts +219 -0
- package/src/adapters/pptx/pptx-model.ts +248 -0
- package/src/adapters/text/text-adapter.test.tsx +125 -0
- package/src/adapters/text/text-adapter.tsx +117 -0
- package/src/adapters/text/text-manifest.ts +19 -0
- package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
- package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
- package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
- package/src/components/grid-text.test.ts +68 -0
- package/src/components/grid-text.ts +77 -0
- package/src/components/marked-text.tsx +36 -0
- package/src/components/sheet-table.tsx +142 -0
- package/src/core/errors.ts +120 -0
- package/src/core/highlight-marks.test.ts +95 -0
- package/src/core/highlight-marks.ts +95 -0
- package/src/core/highlight-resolve.test.ts +208 -0
- package/src/core/highlight-resolve.ts +191 -0
- package/src/core/highlight.ts +125 -0
- package/src/core/registry.test.ts +172 -0
- package/src/core/registry.ts +188 -0
- package/src/core/scroll-host.ts +42 -0
- package/src/core/text-index.test.ts +95 -0
- package/src/core/text-index.ts +159 -0
- package/src/core/types.ts +285 -0
- package/src/core/use-highlight-scroll.ts +59 -0
- package/src/core/use-page-control.ts +47 -0
- package/src/core/use-paged-scroll.ts +194 -0
- package/src/core/use-viewport-size.ts +54 -0
- package/src/core/zoom.ts +56 -0
- package/src/file-viewer/file-viewer-context.tsx +239 -0
- package/src/file-viewer/file-viewer-find.tsx +204 -0
- package/src/file-viewer/file-viewer-pager.tsx +120 -0
- package/src/file-viewer/file-viewer-zoom.tsx +166 -0
- package/src/file-viewer/file-viewer.stories.tsx +947 -0
- package/src/file-viewer/file-viewer.test.tsx +947 -0
- package/src/file-viewer/file-viewer.tsx +1143 -0
- package/src/index.ts +157 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { normalizeQuoteTextWithOffsets, type DocumentAddress } from "@elabs-ai/components-ui";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import type { DocumentHighlight, HighlightSupport } from "./highlight";
|
|
5
|
+
import { locateQuote, resolveHighlights, type HighlightResolveContext } from "./highlight-resolve";
|
|
6
|
+
|
|
7
|
+
const DOCUMENT = "Revenue rose 12% in Q3.\nCosts fell.\nRevenue rose again in Q4.";
|
|
8
|
+
|
|
9
|
+
const ALL_KINDS: HighlightSupport = ["quote", "range", "rect"];
|
|
10
|
+
|
|
11
|
+
function context(overrides: Partial<HighlightResolveContext> = {}): HighlightResolveContext {
|
|
12
|
+
return {
|
|
13
|
+
normalized: normalizeQuoteTextWithOffsets(DOCUMENT),
|
|
14
|
+
textLength: DOCUMENT.length,
|
|
15
|
+
supported: ALL_KINDS,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cite(id: string, address: DocumentAddress): DocumentHighlight {
|
|
21
|
+
return { id, address };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("locateQuote", () => {
|
|
25
|
+
const normalized = normalizeQuoteTextWithOffsets(DOCUMENT);
|
|
26
|
+
|
|
27
|
+
it("finds a passage whose whitespace and case do not match the file", () => {
|
|
28
|
+
// What a model actually emits: re-wrapped, re-cased, re-typed.
|
|
29
|
+
const range = locateQuote(normalized, { kind: "quote", text: " COSTS\n fell " });
|
|
30
|
+
expect(range && DOCUMENT.slice(range[0], range[1])).toBe("Costs fell");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("finds a passage whose quote glyphs do not match the file", () => {
|
|
34
|
+
const file = normalizeQuoteTextWithOffsets(`He said "no" — twice`);
|
|
35
|
+
const range = locateQuote(file, { kind: "quote", text: "“no” – twice" });
|
|
36
|
+
expect(range && `He said "no" — twice`.slice(range[0], range[1])).toBe(`"no" — twice`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("takes the first occurrence by default", () => {
|
|
40
|
+
const range = locateQuote(normalized, { kind: "quote", text: "Revenue rose" });
|
|
41
|
+
expect(range?.[0]).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("takes the occurrence the caller asked for", () => {
|
|
45
|
+
const range = locateQuote(normalized, {
|
|
46
|
+
kind: "quote",
|
|
47
|
+
text: "Revenue rose",
|
|
48
|
+
occurrence: 2,
|
|
49
|
+
});
|
|
50
|
+
expect(range?.[0]).toBe(DOCUMENT.lastIndexOf("Revenue rose"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("misses rather than falling back when the occurrence does not exist", () => {
|
|
54
|
+
// Quietly marking the first would be a confidently wrong citation.
|
|
55
|
+
expect(
|
|
56
|
+
locateQuote(normalized, { kind: "quote", text: "Revenue rose", occurrence: 9 }),
|
|
57
|
+
).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("uses `near.offset` to pick between repeats", () => {
|
|
61
|
+
const range = locateQuote(normalized, {
|
|
62
|
+
kind: "quote",
|
|
63
|
+
text: "Revenue rose",
|
|
64
|
+
near: { offset: DOCUMENT.length },
|
|
65
|
+
});
|
|
66
|
+
expect(range?.[0]).toBe(DOCUMENT.lastIndexOf("Revenue rose"));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("still finds a passage that is nowhere near the hint", () => {
|
|
70
|
+
// `near` disambiguates; it does not restrict.
|
|
71
|
+
const range = locateQuote(normalized, {
|
|
72
|
+
kind: "quote",
|
|
73
|
+
text: "Costs fell",
|
|
74
|
+
near: { offset: 0 },
|
|
75
|
+
});
|
|
76
|
+
expect(range && DOCUMENT.slice(range[0], range[1])).toBe("Costs fell");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns nothing for an absent or empty passage", () => {
|
|
80
|
+
expect(locateQuote(normalized, { kind: "quote", text: "profit" })).toBeUndefined();
|
|
81
|
+
expect(locateQuote(normalized, { kind: "quote", text: " " })).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("resolveHighlights", () => {
|
|
86
|
+
it("reports a kind the adapter never declared as a capability gap", () => {
|
|
87
|
+
// Not a failure and not retryable — the file is fine, this build simply
|
|
88
|
+
// cannot draw a box on a Word document.
|
|
89
|
+
const [resolved] = resolveHighlights(
|
|
90
|
+
[cite("a", { kind: "rect", page: 1, rects: [{ x: 0, y: 0, width: 1, height: 1 }] })],
|
|
91
|
+
context({ supported: ["quote"] }),
|
|
92
|
+
);
|
|
93
|
+
expect(resolved?.status).toBe("unsupported");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("tells 'not in the document' apart from 'past what we previewed'", () => {
|
|
97
|
+
const address: DocumentAddress = { kind: "quote", text: "nowhere" };
|
|
98
|
+
expect(resolveHighlights([cite("a", address)], context())[0]).toMatchObject({
|
|
99
|
+
status: "not-found",
|
|
100
|
+
reason: "absent",
|
|
101
|
+
});
|
|
102
|
+
expect(resolveHighlights([cite("a", address)], context({ truncated: true }))[0]).toMatchObject({
|
|
103
|
+
status: "not-found",
|
|
104
|
+
reason: "truncated",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("clamps a range to the projection, and calls an emptied one a miss", () => {
|
|
109
|
+
const [clamped] = resolveHighlights(
|
|
110
|
+
[cite("a", { kind: "range", start: 5, end: 5000 })],
|
|
111
|
+
context(),
|
|
112
|
+
);
|
|
113
|
+
expect(clamped).toMatchObject({ status: "resolved", range: [5, DOCUMENT.length] });
|
|
114
|
+
|
|
115
|
+
// Offsets computed against a LONGER projection than the one we have: the
|
|
116
|
+
// clamp collapses them, and a zero-width mark would point at nothing.
|
|
117
|
+
const [collapsed] = resolveHighlights(
|
|
118
|
+
[cite("b", { kind: "range", start: 900, end: 950 })],
|
|
119
|
+
context(),
|
|
120
|
+
);
|
|
121
|
+
expect(collapsed?.status).toBe("not-found");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("never lets a producer's page HINT stand in for where the passage landed", () => {
|
|
125
|
+
// The regression this locks: `page` was copied off the address, and the PDF
|
|
126
|
+
// renderer prefers `ResolvedHighlight.page` over its own index — so a stale
|
|
127
|
+
// hint turned the reader to a blank page while the mark sat somewhere else.
|
|
128
|
+
// Both fields are documented as hints; only `rect` may say where it is.
|
|
129
|
+
const [byRange] = resolveHighlights(
|
|
130
|
+
[cite("a", { kind: "range", start: 0, end: 5, page: 99 })],
|
|
131
|
+
context(),
|
|
132
|
+
);
|
|
133
|
+
expect(byRange).toMatchObject({ status: "resolved", range: [0, 5] });
|
|
134
|
+
expect(byRange?.page).toBeUndefined();
|
|
135
|
+
|
|
136
|
+
const [byQuote] = resolveHighlights(
|
|
137
|
+
[cite("b", { kind: "quote", text: "Costs fell", near: { page: 99 } })],
|
|
138
|
+
context(),
|
|
139
|
+
);
|
|
140
|
+
expect(byQuote?.status).toBe("resolved");
|
|
141
|
+
expect(byQuote?.page).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("passes a rect straight through — geometry needs no text", () => {
|
|
145
|
+
const rects = [{ x: 0.1, y: 0.2, width: 0.3, height: 0.05 }];
|
|
146
|
+
const [resolved] = resolveHighlights(
|
|
147
|
+
[cite("a", { kind: "rect", page: 3, rects })],
|
|
148
|
+
context({ normalized: undefined, textLength: undefined }),
|
|
149
|
+
);
|
|
150
|
+
expect(resolved).toMatchObject({ status: "resolved", page: 3, rects });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("orders by position in the DOCUMENT, not by the order asked for", () => {
|
|
154
|
+
// "Next match" has to mean the next one down the page; an app listing
|
|
155
|
+
// citations by relevance would otherwise send the reader backwards.
|
|
156
|
+
const resolved = resolveHighlights(
|
|
157
|
+
[cite("late", { kind: "quote", text: "Q4" }), cite("early", { kind: "quote", text: "Q3" })],
|
|
158
|
+
context(),
|
|
159
|
+
);
|
|
160
|
+
expect(resolved.map((highlight) => highlight.id)).toEqual(["early", "late"]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("numbers per source, so the find count never includes citations", () => {
|
|
164
|
+
const resolved = resolveHighlights(
|
|
165
|
+
[
|
|
166
|
+
cite("c1", { kind: "quote", text: "Revenue" }),
|
|
167
|
+
{ id: "f1", source: "search", address: { kind: "range", start: 24, end: 29 } },
|
|
168
|
+
{ id: "f2", source: "search", address: { kind: "range", start: 35, end: 42 } },
|
|
169
|
+
],
|
|
170
|
+
context(),
|
|
171
|
+
);
|
|
172
|
+
const byId = new Map(resolved.map((highlight) => [highlight.id, highlight]));
|
|
173
|
+
expect(byId.get("c1")?.index).toBe(1);
|
|
174
|
+
expect(byId.get("f1")?.index).toBe(1);
|
|
175
|
+
expect(byId.get("f2")?.index).toBe(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("does not let a miss consume a number the reader is counting through", () => {
|
|
179
|
+
const resolved = resolveHighlights(
|
|
180
|
+
[
|
|
181
|
+
cite("hit", { kind: "quote", text: "Revenue rose 12%" }),
|
|
182
|
+
cite("miss", { kind: "quote", text: "profit" }),
|
|
183
|
+
cite("hit2", { kind: "quote", text: "Q4" }),
|
|
184
|
+
],
|
|
185
|
+
context(),
|
|
186
|
+
);
|
|
187
|
+
const byId = new Map(resolved.map((highlight) => [highlight.id, highlight]));
|
|
188
|
+
expect(byId.get("hit")?.index).toBe(1);
|
|
189
|
+
expect(byId.get("hit2")?.index).toBe(2);
|
|
190
|
+
expect(byId.get("miss")?.index).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("flags exactly the active one", () => {
|
|
194
|
+
const resolved = resolveHighlights(
|
|
195
|
+
[cite("a", { kind: "quote", text: "Q3" }), cite("b", { kind: "quote", text: "Q4" })],
|
|
196
|
+
context({ activeId: "b" }),
|
|
197
|
+
);
|
|
198
|
+
expect(resolved.filter((highlight) => highlight.active).map((h) => h.id)).toEqual(["b"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("keeps the request on a miss, so the chrome has something to name", () => {
|
|
202
|
+
const [resolved] = resolveHighlights(
|
|
203
|
+
[{ id: "a", label: "Q2 guidance", address: { kind: "quote", text: "nowhere" } }],
|
|
204
|
+
context(),
|
|
205
|
+
);
|
|
206
|
+
expect(resolved).toMatchObject({ label: "Q2 guidance", address: { kind: "quote" } });
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a request into a location — the LOCATE step, and the only step that
|
|
3
|
+
* runs outside the adapter.
|
|
4
|
+
*
|
|
5
|
+
* The funnel is three stages with three homes. **Locate** (here) answers "where
|
|
6
|
+
* in the text projection is this?" and produces character offsets. **Map** (the
|
|
7
|
+
* adapter's renderer) turns those offsets into its own model — block 14, page 3,
|
|
8
|
+
* cell B7. **Paint** (also the renderer) draws it and scrolls to it.
|
|
9
|
+
*
|
|
10
|
+
* Locate lives in the shell rather than in each adapter because its OUTCOME is
|
|
11
|
+
* chrome state, not pixels: "3 of 12", "we couldn't find that passage", "this
|
|
12
|
+
* build can't locate a box in a Word file". Every adapter would otherwise write
|
|
13
|
+
* that logic again, slightly differently, and the shell would have no way to
|
|
14
|
+
* count what it is showing. It is also pure — no DOM, no engine — so it is
|
|
15
|
+
* testable without jsdom.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
normalizeQuoteText,
|
|
20
|
+
type MatchRange,
|
|
21
|
+
type NormalizedText,
|
|
22
|
+
type QuoteAddress,
|
|
23
|
+
} from "@elabs-ai/components-ui";
|
|
24
|
+
|
|
25
|
+
import type { DocumentHighlight, HighlightSupport, ResolvedHighlight } from "./highlight";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find a quoted passage in a normalized projection and map it back to raw
|
|
29
|
+
* offsets.
|
|
30
|
+
*
|
|
31
|
+
* Both sides are folded first (whitespace, quote glyphs, case), because a
|
|
32
|
+
* citation is re-typed by a model or extracted by a different tool and will
|
|
33
|
+
* essentially never be byte-identical to what our parser produced.
|
|
34
|
+
*
|
|
35
|
+
* Ambiguity is resolved by the caller's own hints and never guessed at: an
|
|
36
|
+
* explicit `occurrence` wins, then proximity to `near.offset`, then the first
|
|
37
|
+
* match. Returning the first match silently would put a citation on the wrong
|
|
38
|
+
* paragraph of a document that repeats a heading.
|
|
39
|
+
*/
|
|
40
|
+
export function locateQuote(
|
|
41
|
+
normalized: NormalizedText,
|
|
42
|
+
address: QuoteAddress,
|
|
43
|
+
): MatchRange | undefined {
|
|
44
|
+
const needle = normalizeQuoteText(address.text);
|
|
45
|
+
if (needle.length === 0) return undefined;
|
|
46
|
+
|
|
47
|
+
const starts: number[] = [];
|
|
48
|
+
for (let at = normalized.text.indexOf(needle); at !== -1; ) {
|
|
49
|
+
starts.push(at);
|
|
50
|
+
// Step by one, not by the needle's length: overlapping occurrences of a
|
|
51
|
+
// repeated phrase ("na na na") are still distinct places in the document.
|
|
52
|
+
at = normalized.text.indexOf(needle, at + 1);
|
|
53
|
+
}
|
|
54
|
+
if (starts.length === 0) return undefined;
|
|
55
|
+
|
|
56
|
+
const toRange = (start: number): MatchRange => [
|
|
57
|
+
normalized.offsets[start] as number,
|
|
58
|
+
normalized.offsets[start + needle.length] as number,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
if (address.occurrence !== undefined) {
|
|
62
|
+
const chosen = starts[address.occurrence - 1];
|
|
63
|
+
// An out-of-range occurrence is a miss, not a fallback to the first: the
|
|
64
|
+
// caller asked for the fourth of three, and quietly marking the first would
|
|
65
|
+
// be a confidently wrong citation.
|
|
66
|
+
return chosen === undefined ? undefined : toRange(chosen);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const near = address.near?.offset;
|
|
70
|
+
if (near !== undefined) {
|
|
71
|
+
let best = starts[0] as number;
|
|
72
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
73
|
+
for (const start of starts) {
|
|
74
|
+
const distance = Math.abs((normalized.offsets[start] as number) - near);
|
|
75
|
+
if (distance < bestDistance) {
|
|
76
|
+
bestDistance = distance;
|
|
77
|
+
best = start;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return toRange(best);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return toRange(starts[0] as number);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface HighlightResolveContext {
|
|
87
|
+
/**
|
|
88
|
+
* The document's text projection, folded once with its offset map. Absent
|
|
89
|
+
* when the format has no text projection at all.
|
|
90
|
+
*
|
|
91
|
+
* Pre-folded rather than raw because find-in-document re-resolves on every
|
|
92
|
+
* keystroke, and folding a 2 MB projection per keystroke is what turns a
|
|
93
|
+
* search box into a stutter.
|
|
94
|
+
*/
|
|
95
|
+
normalized?: NormalizedText;
|
|
96
|
+
/** Length of the RAW projection, for clamping `range` addresses. */
|
|
97
|
+
textLength?: number;
|
|
98
|
+
/** Whether that projection is capped, which changes what a miss MEANS. */
|
|
99
|
+
truncated?: boolean;
|
|
100
|
+
/** Which address kinds this document's adapter honours. */
|
|
101
|
+
supported: HighlightSupport;
|
|
102
|
+
/** The highlight the viewer is currently pointed at. */
|
|
103
|
+
activeId?: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Sort key: where in the document this landed. Unresolved sorts last. */
|
|
107
|
+
function positionOf(highlight: ResolvedHighlight): number {
|
|
108
|
+
if (highlight.range) return highlight.range[0];
|
|
109
|
+
if (highlight.page !== undefined) return highlight.page;
|
|
110
|
+
return Number.POSITIVE_INFINITY;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveOne(
|
|
114
|
+
highlight: DocumentHighlight,
|
|
115
|
+
context: HighlightResolveContext,
|
|
116
|
+
): ResolvedHighlight {
|
|
117
|
+
const source = highlight.source ?? "citation";
|
|
118
|
+
const address = highlight.address;
|
|
119
|
+
const base = { id: highlight.id, label: highlight.label, source, address, active: false };
|
|
120
|
+
|
|
121
|
+
// The load-bearing contract: match only the kinds this adapter DECLARED, and
|
|
122
|
+
// report the rest. No exhaustive switch, no `never` fallthrough — that is
|
|
123
|
+
// what lets a fourth address kind be added later without breaking every
|
|
124
|
+
// consumer that predates it.
|
|
125
|
+
if (!context.supported.includes(address.kind)) {
|
|
126
|
+
return { ...base, status: "unsupported" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (address.kind === "rect") {
|
|
130
|
+
// Geometry needs no text and cannot fail to "locate" — whether the page
|
|
131
|
+
// exists is the renderer's business, since only it knows the page count.
|
|
132
|
+
return { ...base, status: "resolved", page: address.page, rects: address.rects };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const miss = (): ResolvedHighlight => ({
|
|
136
|
+
...base,
|
|
137
|
+
status: "not-found",
|
|
138
|
+
reason: context.truncated ? "truncated" : "absent",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (address.kind === "range") {
|
|
142
|
+
const length = context.textLength;
|
|
143
|
+
if (length === undefined) return miss();
|
|
144
|
+
const start = Math.max(0, Math.min(address.start, length));
|
|
145
|
+
const end = Math.max(start, Math.min(address.end, length));
|
|
146
|
+
// A range clamped to nothing is a miss, not a zero-width mark: the offsets
|
|
147
|
+
// were computed against a longer projection than the one we have.
|
|
148
|
+
if (end === start) return miss();
|
|
149
|
+
// No `page`. A producer's page number is a hint, and a wrong one used as an
|
|
150
|
+
// instruction turns the pager to a blank page while the mark sits elsewhere.
|
|
151
|
+
// Where the range actually landed is knowable from the adapter's own index,
|
|
152
|
+
// so that is what navigation uses; `page` stays authoritative only for
|
|
153
|
+
// `rect`, which has no range to derive it from.
|
|
154
|
+
return { ...base, status: "resolved", range: [start, end] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!context.normalized) return miss();
|
|
158
|
+
const range = locateQuote(context.normalized, address);
|
|
159
|
+
if (!range) return miss();
|
|
160
|
+
return { ...base, status: "resolved", range };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve every request, in document order, numbered per source.
|
|
165
|
+
*
|
|
166
|
+
* Document order rather than the caller's, because "next match" has to mean the
|
|
167
|
+
* next one down the page — an app listing citations in relevance order would
|
|
168
|
+
* otherwise send the reader jumping backwards. Numbering is per `source` so the
|
|
169
|
+
* find box's "3 of 12" counts search matches only, and never the citations
|
|
170
|
+
* painted beside them.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveHighlights(
|
|
173
|
+
highlights: readonly DocumentHighlight[],
|
|
174
|
+
context: HighlightResolveContext,
|
|
175
|
+
): ResolvedHighlight[] {
|
|
176
|
+
const resolved = highlights
|
|
177
|
+
.map((highlight) => resolveOne(highlight, context))
|
|
178
|
+
.sort((a, b) => positionOf(a) - positionOf(b));
|
|
179
|
+
|
|
180
|
+
const counters = new Map<string, number>();
|
|
181
|
+
return resolved.map((highlight) => {
|
|
182
|
+
const next =
|
|
183
|
+
highlight.status === "resolved" ? (counters.get(highlight.source) ?? 0) + 1 : undefined;
|
|
184
|
+
if (next !== undefined) counters.set(highlight.source, next);
|
|
185
|
+
return {
|
|
186
|
+
...highlight,
|
|
187
|
+
index: next,
|
|
188
|
+
active: context.activeId != null && context.activeId === highlight.id,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the viewer is pointed AT — the vocabulary shared by citations and
|
|
3
|
+
* find-in-document.
|
|
4
|
+
*
|
|
5
|
+
* A {@link DocumentHighlight} is what a caller asks for; a
|
|
6
|
+
* {@link ResolvedHighlight} is what the viewer worked out and what an adapter's
|
|
7
|
+
* `Renderer` is handed. Keeping the two apart is what lets "we could not find
|
|
8
|
+
* that passage" be a rendered STATE rather than a silent no-op: the request
|
|
9
|
+
* survives even when the location does not, so the chrome still has something
|
|
10
|
+
* to name.
|
|
11
|
+
*
|
|
12
|
+
* The address vocabulary itself lives in `@elabs-ai/components-ui`
|
|
13
|
+
* (`DocumentAddress`) because the producer of a citation and this consumer are
|
|
14
|
+
* sibling packages that may not import each other. Everything here is
|
|
15
|
+
* adapter-protocol detail and stays in this package.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
DocumentAddress,
|
|
20
|
+
DocumentAddressKind,
|
|
21
|
+
DocumentRect,
|
|
22
|
+
MatchRange,
|
|
23
|
+
} from "@elabs-ai/components-ui";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where a highlight came from. Both paint identically — only which one is
|
|
27
|
+
* ACTIVE differs — but the origin decides who owns the list: citations are a
|
|
28
|
+
* controlled prop the app supplies, search matches are the viewer's own.
|
|
29
|
+
*/
|
|
30
|
+
export type HighlightSource = "citation" | "search";
|
|
31
|
+
|
|
32
|
+
/** A request to point the viewer at part of the open document. */
|
|
33
|
+
export interface DocumentHighlight {
|
|
34
|
+
/**
|
|
35
|
+
* Stable per highlight. It is what `activeHighlightId` names and what React
|
|
36
|
+
* keys on, so a list that renumbers between renders must not renumber ids.
|
|
37
|
+
*/
|
|
38
|
+
id: string;
|
|
39
|
+
/** Which part of the document. */
|
|
40
|
+
address: DocumentAddress;
|
|
41
|
+
/**
|
|
42
|
+
* Short human label — the answer's claim, the source's title. Announced when
|
|
43
|
+
* this highlight becomes active, so prefer something a listener can act on
|
|
44
|
+
* over "Citation 3".
|
|
45
|
+
*/
|
|
46
|
+
label?: string;
|
|
47
|
+
/** Defaults to `"citation"`. */
|
|
48
|
+
source?: HighlightSource;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* How a request turned out.
|
|
53
|
+
*
|
|
54
|
+
* `unsupported` is a CAPABILITY GAP, not a failure — the same distinction the
|
|
55
|
+
* error panel already draws. "This build can't locate a rect in a Word file"
|
|
56
|
+
* is news about what we shipped; it is not the reader's mistake, and it is not
|
|
57
|
+
* retryable.
|
|
58
|
+
*/
|
|
59
|
+
export type HighlightStatus = "pending" | "resolved" | "not-found" | "unsupported";
|
|
60
|
+
|
|
61
|
+
/** Why a passage was not found — the two are genuinely different news. */
|
|
62
|
+
export type HighlightMissReason =
|
|
63
|
+
/** Searched the whole projection; the passage is not in it. */
|
|
64
|
+
| "absent"
|
|
65
|
+
/** The projection is capped, and the passage may lie past the cap. */
|
|
66
|
+
| "truncated";
|
|
67
|
+
|
|
68
|
+
/** A request, plus where it landed. What every adapter `Renderer` receives. */
|
|
69
|
+
export interface ResolvedHighlight {
|
|
70
|
+
id: string;
|
|
71
|
+
label?: string;
|
|
72
|
+
source: HighlightSource;
|
|
73
|
+
status: HighlightStatus;
|
|
74
|
+
/** The original request, so a renderer can honour a kind the shell cannot. */
|
|
75
|
+
address: DocumentAddress;
|
|
76
|
+
/** Whether the viewer is currently pointed at this one. */
|
|
77
|
+
active: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* 1-based position among the highlights of the same `source` that resolved —
|
|
80
|
+
* the "3" in "3 of 12". Absent when this one did not resolve, so a miss never
|
|
81
|
+
* silently consumes a number the reader is counting through.
|
|
82
|
+
*/
|
|
83
|
+
index?: number;
|
|
84
|
+
/** Offsets into `document.text`, once located. */
|
|
85
|
+
range?: MatchRange;
|
|
86
|
+
/**
|
|
87
|
+
* 1-based page, set only for a `rect` address — the one kind with no range to
|
|
88
|
+
* derive a position from.
|
|
89
|
+
*
|
|
90
|
+
* A `quote` or `range` deliberately leaves this empty even when the caller
|
|
91
|
+
* supplied a page hint: where the passage actually landed is knowable from the
|
|
92
|
+
* adapter's own index, and a stale hint used as an instruction would page the
|
|
93
|
+
* reader somewhere the mark is not.
|
|
94
|
+
*/
|
|
95
|
+
page?: number;
|
|
96
|
+
/** Geometry, for a `rect` address. */
|
|
97
|
+
rects?: readonly DocumentRect[];
|
|
98
|
+
/** Only on `not-found`. */
|
|
99
|
+
reason?: HighlightMissReason;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The most matches find-in-document will paint.
|
|
104
|
+
*
|
|
105
|
+
* A one-letter query against a 2 MB log matches hundreds of thousands of times;
|
|
106
|
+
* every one of those is a DOM element. The cap keeps typing responsive, and the
|
|
107
|
+
* chrome says so rather than quietly showing a wrong total.
|
|
108
|
+
*/
|
|
109
|
+
export const FIND_MATCH_LIMIT = 2000;
|
|
110
|
+
|
|
111
|
+
/** Prefix reserved for the viewer's own search matches. */
|
|
112
|
+
const FIND_ID_PREFIX = "find:";
|
|
113
|
+
|
|
114
|
+
/** The id for the nth (0-based) find match. */
|
|
115
|
+
export function findMatchId(index: number): string {
|
|
116
|
+
return `${FIND_ID_PREFIX}${String(index)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether an id belongs to find-in-document rather than to a caller. */
|
|
120
|
+
export function isFindMatchId(id: string): boolean {
|
|
121
|
+
return id.startsWith(FIND_ID_PREFIX);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Which address kinds an adapter honours. Absent means none — the safe default. */
|
|
125
|
+
export type HighlightSupport = readonly DocumentAddressKind[];
|