@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,172 @@
|
|
|
1
|
+
import type { FileCategory } from "@elabs-ai/components-ui";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { isViewerError, ViewerError } from "./errors";
|
|
5
|
+
import { createRegistry, scoreManifest } from "./registry";
|
|
6
|
+
import { PROTOCOL_VERSION, type AdapterManifest, type AdapterModule } from "./types";
|
|
7
|
+
|
|
8
|
+
const manifest = (over: Partial<AdapterManifest> & { id: string }): AdapterManifest => ({
|
|
9
|
+
protocol: PROTOCOL_VERSION,
|
|
10
|
+
...over,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const moduleFor = (m: AdapterManifest): AdapterModule => ({
|
|
14
|
+
manifest: m,
|
|
15
|
+
create: () => ({ load: () => Promise.resolve({ kind: m.id }) }),
|
|
16
|
+
Renderer: () => null,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const kind = (extension: string, mediaType: string, category: FileCategory = "text") => ({
|
|
20
|
+
extension,
|
|
21
|
+
mediaType,
|
|
22
|
+
category,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("scoreManifest", () => {
|
|
26
|
+
it("ranks extension above exact MIME above MIME prefix above category", () => {
|
|
27
|
+
const file = kind("csv", "text/csv", "data");
|
|
28
|
+
expect(scoreManifest(manifest({ id: "a", extensions: ["csv"] }), file)).toBeGreaterThan(
|
|
29
|
+
scoreManifest(manifest({ id: "b", mediaTypes: ["text/csv"] }), file),
|
|
30
|
+
);
|
|
31
|
+
expect(scoreManifest(manifest({ id: "b", mediaTypes: ["text/csv"] }), file)).toBeGreaterThan(
|
|
32
|
+
scoreManifest(manifest({ id: "c", mediaTypes: ["text/"] }), file),
|
|
33
|
+
);
|
|
34
|
+
expect(scoreManifest(manifest({ id: "c", mediaTypes: ["text/"] }), file)).toBeGreaterThan(
|
|
35
|
+
scoreManifest(manifest({ id: "d", categories: ["data"] }), file),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("scores zero when nothing matches", () => {
|
|
40
|
+
expect(scoreManifest(manifest({ id: "a", extensions: ["pdf"] }), kind("csv", "text/csv"))).toBe(
|
|
41
|
+
0,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("treats a trailing slash as a prefix and nothing else as exact", () => {
|
|
46
|
+
const png = kind("png", "image/png", "image");
|
|
47
|
+
expect(scoreManifest(manifest({ id: "a", mediaTypes: ["image/"] }), png)).toBeGreaterThan(0);
|
|
48
|
+
// "image" without the slash must NOT match "image/png".
|
|
49
|
+
expect(scoreManifest(manifest({ id: "b", mediaTypes: ["image"] }), png)).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("registry — registration", () => {
|
|
54
|
+
it("rejects an adapter built against another protocol", () => {
|
|
55
|
+
const registry = createRegistry();
|
|
56
|
+
expect(() =>
|
|
57
|
+
registry.register({ id: "old", protocol: PROTOCOL_VERSION + 1 }, () =>
|
|
58
|
+
Promise.resolve(moduleFor(manifest({ id: "old" }))),
|
|
59
|
+
),
|
|
60
|
+
).toThrow(ViewerError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("replaces an entry with the same id, and drops its cached module", async () => {
|
|
64
|
+
const registry = createRegistry();
|
|
65
|
+
const first = manifest({ id: "x", extensions: ["x"] });
|
|
66
|
+
registry.register(first, () => Promise.resolve(moduleFor(first)));
|
|
67
|
+
await registry.load("x");
|
|
68
|
+
|
|
69
|
+
const second = manifest({ id: "x", extensions: ["x"] });
|
|
70
|
+
const secondLoader = vi.fn().mockResolvedValue(moduleFor(second));
|
|
71
|
+
registry.register(second, secondLoader);
|
|
72
|
+
|
|
73
|
+
await registry.load("x");
|
|
74
|
+
expect(secondLoader).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(registry.manifests()).toHaveLength(1);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("registry — detect", () => {
|
|
80
|
+
it("picks the most specific claim at equal priority", () => {
|
|
81
|
+
const registry = createRegistry();
|
|
82
|
+
const broad = manifest({ id: "text", categories: ["data"] });
|
|
83
|
+
const exact = manifest({ id: "csv", extensions: ["csv"] });
|
|
84
|
+
registry.register(broad, () => Promise.resolve(moduleFor(broad)));
|
|
85
|
+
registry.register(exact, () => Promise.resolve(moduleFor(exact)));
|
|
86
|
+
|
|
87
|
+
expect(registry.detectByName("rows.csv")?.id).toBe("csv");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("lets a higher priority beat a more specific claim — that IS the override", () => {
|
|
91
|
+
const registry = createRegistry();
|
|
92
|
+
const builtin = manifest({ id: "csv", extensions: ["csv"] });
|
|
93
|
+
const override = manifest({ id: "mine", categories: ["data"], priority: 10 });
|
|
94
|
+
registry.register(builtin, () => Promise.resolve(moduleFor(builtin)));
|
|
95
|
+
registry.register(override, () => Promise.resolve(moduleFor(override)));
|
|
96
|
+
|
|
97
|
+
expect(registry.detectByName("rows.csv")?.id).toBe("mine");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns undefined when nothing claims the file", () => {
|
|
101
|
+
const registry = createRegistry();
|
|
102
|
+
const only = manifest({ id: "img", categories: ["image"] });
|
|
103
|
+
registry.register(only, () => Promise.resolve(moduleFor(only)));
|
|
104
|
+
expect(registry.detectByName("notes.txt")).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("registry — load", () => {
|
|
109
|
+
it("caches the module, not the instance, so documents cannot share state", async () => {
|
|
110
|
+
const registry = createRegistry();
|
|
111
|
+
const m = manifest({ id: "x" });
|
|
112
|
+
const loader = vi.fn().mockResolvedValue(moduleFor(m));
|
|
113
|
+
registry.register(m, loader);
|
|
114
|
+
|
|
115
|
+
const [a, b] = await Promise.all([registry.load("x"), registry.load("x")]);
|
|
116
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
117
|
+
expect(a).toBe(b);
|
|
118
|
+
expect(a.create()).not.toBe(b.create());
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("accepts a default export or a namespace export", async () => {
|
|
122
|
+
const registry = createRegistry();
|
|
123
|
+
const named = manifest({ id: "named" });
|
|
124
|
+
const defaulted = manifest({ id: "defaulted" });
|
|
125
|
+
registry.register(named, () => Promise.resolve(moduleFor(named)));
|
|
126
|
+
registry.register(defaulted, () => Promise.resolve({ default: moduleFor(defaulted) }));
|
|
127
|
+
|
|
128
|
+
expect((await registry.load("named")).manifest.id).toBe("named");
|
|
129
|
+
expect((await registry.load("defaulted")).manifest.id).toBe("defaulted");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("reports an unknown id as unsupported-format", async () => {
|
|
133
|
+
const registry = createRegistry();
|
|
134
|
+
await expect(registry.load("nope")).rejects.toMatchObject({ code: "unsupported-format" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("turns a missing optional peer into parser-missing, naming the packages", async () => {
|
|
138
|
+
const registry = createRegistry();
|
|
139
|
+
const m = manifest({ id: "csv", requires: ["papaparse"] });
|
|
140
|
+
registry.register(m, () => Promise.reject(new Error("Cannot find module 'papaparse'")));
|
|
141
|
+
|
|
142
|
+
await expect(registry.load("csv")).rejects.toMatchObject({
|
|
143
|
+
code: "parser-missing",
|
|
144
|
+
packages: ["papaparse"],
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("does not cache a failed load — installing the peer later must work", async () => {
|
|
149
|
+
const registry = createRegistry();
|
|
150
|
+
const m = manifest({ id: "csv", requires: ["papaparse"] });
|
|
151
|
+
const loader = vi
|
|
152
|
+
.fn()
|
|
153
|
+
.mockRejectedValueOnce(new Error("Cannot find module 'papaparse'"))
|
|
154
|
+
.mockResolvedValueOnce(moduleFor(m));
|
|
155
|
+
registry.register(m, loader);
|
|
156
|
+
|
|
157
|
+
await expect(registry.load("csv")).rejects.toBeDefined();
|
|
158
|
+
expect((await registry.load("csv")).manifest.id).toBe("csv");
|
|
159
|
+
expect(loader).toHaveBeenCalledTimes(2);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("rejects a module whose manifest disagrees with the registered protocol", async () => {
|
|
163
|
+
const registry = createRegistry();
|
|
164
|
+
const m = manifest({ id: "x" });
|
|
165
|
+
registry.register(m, () =>
|
|
166
|
+
Promise.resolve(moduleFor({ ...m, protocol: PROTOCOL_VERSION + 1 })),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const error = await registry.load("x").catch((e: unknown) => e);
|
|
170
|
+
expect(isViewerError(error) && error.code).toBe("protocol-mismatch");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter registry — how a file finds its renderer.
|
|
3
|
+
*
|
|
4
|
+
* Architecture adapted from [anyview](https://github.com/harshpreet931/anyview)
|
|
5
|
+
* (MIT). Four properties are what make it work, and all four are deliberate:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Eager manifests, lazy loaders.** Routing and capability questions are
|
|
8
|
+
* answered from plain data; the parser is fetched only when a file of that
|
|
9
|
+
* kind is actually opened.
|
|
10
|
+
* 2. **Priority override.** A consumer replaces a built-in by registering a
|
|
11
|
+
* higher priority — no fork, no patch.
|
|
12
|
+
* 3. **A fresh adapter per document.** The module (and its parser) is cached;
|
|
13
|
+
* the instance is not, so per-document state cannot leak between files.
|
|
14
|
+
* 4. **A protocol guard.** A mismatched adapter fails with a named error at
|
|
15
|
+
* registration, not with an undefined property at render.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { type FileKind, resolveFileKind } from "@elabs-ai/components-ui";
|
|
19
|
+
|
|
20
|
+
import { ViewerError, isModuleNotFound, parserMissingError, toViewerError } from "./errors";
|
|
21
|
+
import {
|
|
22
|
+
type AdapterLoader,
|
|
23
|
+
type AdapterManifest,
|
|
24
|
+
type AdapterModule,
|
|
25
|
+
PROTOCOL_VERSION,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How specifically a manifest claimed a file. Higher is more specific.
|
|
30
|
+
* A plain object rather than a `const enum` — the repo builds with
|
|
31
|
+
* `isolatedModules`, which cannot inline one.
|
|
32
|
+
*/
|
|
33
|
+
const MatchScore = {
|
|
34
|
+
None: 0,
|
|
35
|
+
Category: 1,
|
|
36
|
+
MediaTypePrefix: 2,
|
|
37
|
+
MediaType: 3,
|
|
38
|
+
Extension: 4,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
type MatchScore = (typeof MatchScore)[keyof typeof MatchScore];
|
|
42
|
+
|
|
43
|
+
interface Entry {
|
|
44
|
+
manifest: AdapterManifest;
|
|
45
|
+
loader: AdapterLoader;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A registry of file adapters. Create with {@link createRegistry}. */
|
|
49
|
+
export interface ViewerRegistry {
|
|
50
|
+
/**
|
|
51
|
+
* Add an adapter, or replace one with the same `id`.
|
|
52
|
+
*
|
|
53
|
+
* Replacement is by id and unconditional — the caller asked for it. Priority
|
|
54
|
+
* governs which of several DIFFERENT adapters wins a file, not whether a
|
|
55
|
+
* registration takes effect.
|
|
56
|
+
*
|
|
57
|
+
* @throws {ViewerError} `protocol-mismatch` if the manifest targets another protocol.
|
|
58
|
+
*/
|
|
59
|
+
register(manifest: AdapterManifest, loader: AdapterLoader): void;
|
|
60
|
+
/**
|
|
61
|
+
* Every registered manifest, highest priority first. Not the routing order —
|
|
62
|
+
* specificity is per-file, so only {@link detect} can rank for a given file.
|
|
63
|
+
*/
|
|
64
|
+
manifests(): AdapterManifest[];
|
|
65
|
+
/** The manifest that should open this file, or `undefined` if none claims it. */
|
|
66
|
+
detect(kind: FileKind): AdapterManifest | undefined;
|
|
67
|
+
/** Convenience over {@link detect} for a name + MIME. */
|
|
68
|
+
detectByName(name: string, mediaType?: string): AdapterManifest | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Fetch an adapter module by id, caching the MODULE (never an instance).
|
|
71
|
+
*
|
|
72
|
+
* @throws {ViewerError} `unsupported-format` when the id is unknown,
|
|
73
|
+
* `parser-missing` when the module's optional peer is not installed,
|
|
74
|
+
* `protocol-mismatch` when the loaded module disagrees with its manifest.
|
|
75
|
+
*/
|
|
76
|
+
load(id: string): Promise<AdapterModule>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How well `manifest` claims `kind`. `MatchScore.None` means "not mine". */
|
|
80
|
+
export function scoreManifest(manifest: AdapterManifest, kind: FileKind): MatchScore {
|
|
81
|
+
if (kind.extension && manifest.extensions?.includes(kind.extension)) {
|
|
82
|
+
return MatchScore.Extension;
|
|
83
|
+
}
|
|
84
|
+
for (const declared of manifest.mediaTypes ?? []) {
|
|
85
|
+
if (declared.endsWith("/")) {
|
|
86
|
+
if (kind.mediaType.startsWith(declared)) return MatchScore.MediaTypePrefix;
|
|
87
|
+
} else if (declared === kind.mediaType) {
|
|
88
|
+
return MatchScore.MediaType;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (manifest.categories?.includes(kind.category)) return MatchScore.Category;
|
|
92
|
+
return MatchScore.None;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Unwrap `export default` vs named exports, so an adapter can use either. */
|
|
96
|
+
function unwrap(loaded: AdapterModule | { default: AdapterModule }): AdapterModule {
|
|
97
|
+
return "default" in loaded && loaded.default ? loaded.default : (loaded as AdapterModule);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertProtocol(manifest: AdapterManifest, where: string): void {
|
|
101
|
+
if (manifest.protocol !== PROTOCOL_VERSION) {
|
|
102
|
+
throw new ViewerError(
|
|
103
|
+
"protocol-mismatch",
|
|
104
|
+
`Adapter "${manifest.id}" targets viewer protocol ${String(manifest.protocol)}, but this build speaks ${String(PROTOCOL_VERSION)} (${where}).`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create an empty registry.
|
|
111
|
+
*
|
|
112
|
+
* Empty on purpose: the built-ins live in `createDefaultRegistry()`
|
|
113
|
+
* (`src/adapters`), so a consumer who wants only their own adapters — or only
|
|
114
|
+
* images — never pulls in the rest.
|
|
115
|
+
*/
|
|
116
|
+
export function createRegistry(): ViewerRegistry {
|
|
117
|
+
const entries = new Map<string, Entry>();
|
|
118
|
+
const modules = new Map<string, Promise<AdapterModule>>();
|
|
119
|
+
|
|
120
|
+
function ordered(): Entry[] {
|
|
121
|
+
return [...entries.values()].sort(
|
|
122
|
+
(a, b) => (b.manifest.priority ?? 0) - (a.manifest.priority ?? 0),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function detect(kind: FileKind): AdapterManifest | undefined {
|
|
127
|
+
let best: { manifest: AdapterManifest; score: MatchScore; priority: number } | undefined;
|
|
128
|
+
for (const { manifest } of entries.values()) {
|
|
129
|
+
const score = scoreManifest(manifest, kind);
|
|
130
|
+
if (score === MatchScore.None) continue;
|
|
131
|
+
const priority = manifest.priority ?? 0;
|
|
132
|
+
// Priority is the consumer's explicit override, so it outranks how
|
|
133
|
+
// specifically an adapter happened to claim the file.
|
|
134
|
+
const wins =
|
|
135
|
+
!best || priority > best.priority || (priority === best.priority && score > best.score);
|
|
136
|
+
if (wins) best = { manifest, score, priority };
|
|
137
|
+
}
|
|
138
|
+
return best?.manifest;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
register(manifest, loader) {
|
|
143
|
+
assertProtocol(manifest, "registration");
|
|
144
|
+
entries.set(manifest.id, { manifest, loader });
|
|
145
|
+
// A replaced id must not keep serving the old module.
|
|
146
|
+
modules.delete(manifest.id);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
manifests() {
|
|
150
|
+
return ordered().map((entry) => entry.manifest);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
detect,
|
|
154
|
+
|
|
155
|
+
detectByName(name, mediaType) {
|
|
156
|
+
return detect(resolveFileKind(name, mediaType));
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async load(id) {
|
|
160
|
+
const entry = entries.get(id);
|
|
161
|
+
if (!entry) {
|
|
162
|
+
throw new ViewerError("unsupported-format", `No adapter is registered for "${id}".`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let pending = modules.get(id);
|
|
166
|
+
if (!pending) {
|
|
167
|
+
pending = entry
|
|
168
|
+
.loader()
|
|
169
|
+
.then((loaded) => {
|
|
170
|
+
const adapterModule = unwrap(loaded);
|
|
171
|
+
assertProtocol(adapterModule.manifest, `module "${id}"`);
|
|
172
|
+
return adapterModule;
|
|
173
|
+
})
|
|
174
|
+
.catch((error: unknown) => {
|
|
175
|
+
// Not cached: an optional peer installed later, or a transient
|
|
176
|
+
// chunk-load failure, must be retryable without a page reload.
|
|
177
|
+
modules.delete(id);
|
|
178
|
+
if (isModuleNotFound(error)) {
|
|
179
|
+
throw parserMissingError(id, entry.manifest.requires ?? [], { cause: error });
|
|
180
|
+
}
|
|
181
|
+
throw toViewerError(error, "parse-failed");
|
|
182
|
+
});
|
|
183
|
+
modules.set(id, pending);
|
|
184
|
+
}
|
|
185
|
+
return pending;
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the element a renderer actually scrolls inside.
|
|
3
|
+
*
|
|
4
|
+
* `FileViewerContent` is THE scroll boundary (`viewer-components.md`), and now
|
|
5
|
+
* that page and zoom chrome lives in the shell (ADR 0026) even the PDF adapter
|
|
6
|
+
* no longer has fixed controls of its own to justify a second viewport. But a
|
|
7
|
+
* renderer still has to MEASURE that box — a fit-to-width scale is a function of
|
|
8
|
+
* the viewport's width, and a virtualized page list is a function of its height.
|
|
9
|
+
*
|
|
10
|
+
* Resolved from the DOM rather than passed as a prop because a renderer may be
|
|
11
|
+
* mounted anywhere: inside the shell, inside a consumer's own frame, or bare in
|
|
12
|
+
* a test. Same shape as extend-hq/ui's `resolveScrollAreaViewport`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The `data-slot` the shell's scrolling pane carries. */
|
|
16
|
+
export const SCROLL_HOST_SELECTOR = '[data-slot="file-viewer-content"]';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The nearest scrolling ancestor of `node`.
|
|
20
|
+
*
|
|
21
|
+
* Prefers the shell's pane by its stable `data-slot`; falls back to the first
|
|
22
|
+
* ancestor whose computed overflow actually scrolls, so a renderer composed into
|
|
23
|
+
* someone else's frame still measures the right box. `null` when nothing
|
|
24
|
+
* scrolls — the caller should then fall back to its own element.
|
|
25
|
+
*/
|
|
26
|
+
export function findScrollHost(node: Element | null | undefined): HTMLElement | null {
|
|
27
|
+
if (!node) return null;
|
|
28
|
+
const shell = node.closest<HTMLElement>(SCROLL_HOST_SELECTOR);
|
|
29
|
+
if (shell) return shell;
|
|
30
|
+
|
|
31
|
+
const view = node.ownerDocument?.defaultView;
|
|
32
|
+
if (!view) return null;
|
|
33
|
+
let current = node.parentElement;
|
|
34
|
+
while (current) {
|
|
35
|
+
const { overflowY, overflowX } = view.getComputedStyle(current);
|
|
36
|
+
if (/(auto|scroll|overlay)/.test(overflowY) || /(auto|scroll|overlay)/.test(overflowX)) {
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
current = current.parentElement;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createTextIndexBuilder, spanAt, spansForRange } from "./text-index";
|
|
4
|
+
|
|
5
|
+
/** Three blocks, the shape every text-bearing adapter builds. */
|
|
6
|
+
function blocks() {
|
|
7
|
+
const builder = createTextIndexBuilder<number>();
|
|
8
|
+
builder.push("first block", 0);
|
|
9
|
+
builder.push("second", 1);
|
|
10
|
+
builder.push("third one", 2);
|
|
11
|
+
return builder.build();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("createTextIndexBuilder", () => {
|
|
15
|
+
it("produces the projection AND its map from the same pass", () => {
|
|
16
|
+
// The point of the builder: the text an adapter reports and the offsets it
|
|
17
|
+
// maps back cannot disagree, because one is built out of the other.
|
|
18
|
+
const index = blocks();
|
|
19
|
+
expect(index.text).toBe("first block\nsecond\nthird one");
|
|
20
|
+
for (const span of index.spans) {
|
|
21
|
+
expect(index.text.slice(span.start, span.end)).not.toBe("");
|
|
22
|
+
}
|
|
23
|
+
expect(index.text.slice(11, 12)).toBe("\n");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("gives the separator to nobody, so no highlight spans a joint", () => {
|
|
27
|
+
const index = blocks();
|
|
28
|
+
// The gap between block 0's end and block 1's start is the separator.
|
|
29
|
+
expect(index.spans[0]?.end).toBe(11);
|
|
30
|
+
expect(index.spans[1]?.start).toBe(12);
|
|
31
|
+
expect(spanAt(index, 11)).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("skips an empty chunk rather than emitting a span that matches nothing", () => {
|
|
35
|
+
const builder = createTextIndexBuilder<string>();
|
|
36
|
+
builder.push("a", "kept");
|
|
37
|
+
builder.push("", "dropped");
|
|
38
|
+
builder.push("b", "kept-too");
|
|
39
|
+
const index = builder.build();
|
|
40
|
+
expect(index.text).toBe("a\nb");
|
|
41
|
+
expect(index.spans.map((span) => span.ref)).toEqual(["kept", "kept-too"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("honours a custom separator", () => {
|
|
45
|
+
const builder = createTextIndexBuilder<number>({ separator: "\n\n" });
|
|
46
|
+
builder.push("a", 0);
|
|
47
|
+
builder.push("b", 1);
|
|
48
|
+
expect(builder.build().text).toBe("a\n\nb");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("builds an empty index from no chunks at all", () => {
|
|
52
|
+
expect(createTextIndexBuilder<number>().build()).toEqual({ text: "", spans: [] });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("spansForRange", () => {
|
|
57
|
+
it("clips the overlap to each chunk's OWN offsets", () => {
|
|
58
|
+
// What a renderer needs: not "812–847 of the document" but "characters 6–11
|
|
59
|
+
// of block 0", which is the only thing it can pass to its own markup.
|
|
60
|
+
const index = blocks();
|
|
61
|
+
expect(spansForRange(index, [6, 11])).toEqual([{ span: index.spans[0], start: 6, end: 11 }]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("splits a range that crosses a block boundary", () => {
|
|
65
|
+
const index = blocks();
|
|
66
|
+
const overlaps = spansForRange(index, [6, 15]);
|
|
67
|
+
expect(overlaps.map((overlap) => overlap.span.ref)).toEqual([0, 1]);
|
|
68
|
+
expect(overlaps[0]).toMatchObject({ start: 6, end: 11 });
|
|
69
|
+
// Block 1 starts at 12; the range reaches 15, so three of its characters.
|
|
70
|
+
expect(overlaps[1]).toMatchObject({ start: 0, end: 3 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns nothing for an empty or inverted range", () => {
|
|
74
|
+
const index = blocks();
|
|
75
|
+
expect(spansForRange(index, [5, 5])).toEqual([]);
|
|
76
|
+
expect(spansForRange(index, [9, 4])).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns nothing for a range past the end", () => {
|
|
80
|
+
expect(spansForRange(blocks(), [500, 600])).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("spanAt", () => {
|
|
85
|
+
it("finds the chunk an offset lands in", () => {
|
|
86
|
+
const index = blocks();
|
|
87
|
+
expect(spanAt(index, 0)?.ref).toBe(0);
|
|
88
|
+
expect(spanAt(index, 12)?.ref).toBe(1);
|
|
89
|
+
expect(spanAt(index, 27)?.ref).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("is undefined past the last chunk", () => {
|
|
93
|
+
expect(spanAt(blocks(), 999)).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The map from a document's flat text projection back into its own model.
|
|
3
|
+
*
|
|
4
|
+
* `AdapterDocument.text` is the address space every text-based highlight is
|
|
5
|
+
* expressed in — but a renderer does not draw a flat string. It draws Word
|
|
6
|
+
* blocks, PDF pages, spreadsheet cells, code lines. So "characters 812–847 of
|
|
7
|
+
* the projection" has to become "the second half of block 14" before anything
|
|
8
|
+
* can be painted, and only the adapter knows how.
|
|
9
|
+
*
|
|
10
|
+
* That translation is the same bookkeeping every time — running offsets, a
|
|
11
|
+
* separator between chunks, a lookup by position — so it is written once here
|
|
12
|
+
* and each adapter supplies only its own `ref` type: a block index, a page
|
|
13
|
+
* number, a `{ sheet, row, column }` triple. The builder is what guarantees the
|
|
14
|
+
* projection and the index can never disagree, because the projection is its
|
|
15
|
+
* output rather than a second thing assembled by hand.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { MatchRange } from "@elabs-ai/components-ui";
|
|
19
|
+
|
|
20
|
+
/** One chunk of the projection, and what it came from. */
|
|
21
|
+
export interface TextSpan<TRef> {
|
|
22
|
+
/** Half-open `[start, end)` into the projection's text. */
|
|
23
|
+
start: number;
|
|
24
|
+
end: number;
|
|
25
|
+
/** The adapter's own pointer back into its model. */
|
|
26
|
+
ref: TRef;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A flat text projection plus the way back into the model that produced it. */
|
|
30
|
+
export interface TextIndex<TRef> {
|
|
31
|
+
text: string;
|
|
32
|
+
/** Sorted by `start`, non-overlapping. Gaps are the separators between chunks. */
|
|
33
|
+
spans: readonly TextSpan<TRef>[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TextIndexBuilderOptions {
|
|
37
|
+
/**
|
|
38
|
+
* Written between chunks and owned by no span, so a highlight can never be
|
|
39
|
+
* reported as covering the joint between two blocks. `"\n"` by default.
|
|
40
|
+
*/
|
|
41
|
+
separator?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TextIndexBuilder<TRef> {
|
|
45
|
+
/**
|
|
46
|
+
* Append a chunk. Empty chunks are skipped — an empty span matches nothing.
|
|
47
|
+
*
|
|
48
|
+
* `separator` overrides the builder's default for THIS joint only, which is
|
|
49
|
+
* how a multi-level projection stays one index: a workbook joins cells with a
|
|
50
|
+
* tab, rows with a newline and sheets with a blank line, and stitching three
|
|
51
|
+
* separate indexes together afterwards is exactly the offset bookkeeping this
|
|
52
|
+
* module exists to do once.
|
|
53
|
+
*/
|
|
54
|
+
push(chunk: string, ref: TRef, separator?: string): void;
|
|
55
|
+
build(): TextIndex<TRef>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Assemble a projection and its index together, so they cannot drift apart. */
|
|
59
|
+
export function createTextIndexBuilder<TRef>(
|
|
60
|
+
options: TextIndexBuilderOptions = {},
|
|
61
|
+
): TextIndexBuilder<TRef> {
|
|
62
|
+
const fallback = options.separator ?? "\n";
|
|
63
|
+
const parts: string[] = [];
|
|
64
|
+
const spans: TextSpan<TRef>[] = [];
|
|
65
|
+
let cursor = 0;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
push(chunk, ref, separator = fallback) {
|
|
69
|
+
if (chunk.length === 0) return;
|
|
70
|
+
if (parts.length > 0) {
|
|
71
|
+
parts.push(separator);
|
|
72
|
+
cursor += separator.length;
|
|
73
|
+
}
|
|
74
|
+
parts.push(chunk);
|
|
75
|
+
spans.push({ start: cursor, end: cursor + chunk.length, ref });
|
|
76
|
+
cursor += chunk.length;
|
|
77
|
+
},
|
|
78
|
+
build() {
|
|
79
|
+
return { text: parts.join(""), spans };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A span a range touches, with the overlap expressed in the CHUNK's own offsets. */
|
|
85
|
+
export interface SpanOverlap<TRef> {
|
|
86
|
+
span: TextSpan<TRef>;
|
|
87
|
+
/** Offset into the chunk, not into the projection. */
|
|
88
|
+
start: number;
|
|
89
|
+
end: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Index of the first span ending after `offset`, or `spans.length`. */
|
|
93
|
+
function lowerBound<TRef>(spans: readonly TextSpan<TRef>[], offset: number): number {
|
|
94
|
+
let lo = 0;
|
|
95
|
+
let hi = spans.length;
|
|
96
|
+
while (lo < hi) {
|
|
97
|
+
const mid = (lo + hi) >> 1;
|
|
98
|
+
if ((spans[mid] as TextSpan<TRef>).end <= offset) lo = mid + 1;
|
|
99
|
+
else hi = mid;
|
|
100
|
+
}
|
|
101
|
+
return lo;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every chunk a projection range touches, clipped to that chunk.
|
|
106
|
+
*
|
|
107
|
+
* Binary search rather than a scan: a highlighted sentence in a 5,000-cell
|
|
108
|
+
* spreadsheet touches one or two spans, and walking all 5,000 to find them —
|
|
109
|
+
* once per highlight, on every render — is what makes a find-as-you-type box
|
|
110
|
+
* stutter.
|
|
111
|
+
*/
|
|
112
|
+
export function spansForRange<TRef>(
|
|
113
|
+
index: TextIndex<TRef>,
|
|
114
|
+
[start, end]: MatchRange,
|
|
115
|
+
): SpanOverlap<TRef>[] {
|
|
116
|
+
if (end <= start) return [];
|
|
117
|
+
const out: SpanOverlap<TRef>[] = [];
|
|
118
|
+
for (let i = lowerBound(index.spans, start); i < index.spans.length; i += 1) {
|
|
119
|
+
const span = index.spans[i] as TextSpan<TRef>;
|
|
120
|
+
if (span.start >= end) break;
|
|
121
|
+
out.push({
|
|
122
|
+
span,
|
|
123
|
+
start: Math.max(start, span.start) - span.start,
|
|
124
|
+
end: Math.min(end, span.end) - span.start,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Where the `index`-th part of a joined string begins, given where the join
|
|
132
|
+
* begins.
|
|
133
|
+
*
|
|
134
|
+
* The counterpart of pushing a whole row as one chunk: the REF can be the row
|
|
135
|
+
* while the MARK is still the character, because a cell's offset is the sum of
|
|
136
|
+
* the cells before it plus their separators. Used by every renderer that draws
|
|
137
|
+
* one chunk in several pieces — a Word table row, a spreadsheet row, a
|
|
138
|
+
* paragraph's styled runs. `undefined` in, `undefined` out, so a caller with no
|
|
139
|
+
* index does not need a branch.
|
|
140
|
+
*/
|
|
141
|
+
export function chunkOffset(
|
|
142
|
+
parts: readonly string[],
|
|
143
|
+
index: number,
|
|
144
|
+
start: number | undefined,
|
|
145
|
+
separator: string,
|
|
146
|
+
): number | undefined {
|
|
147
|
+
if (start === undefined) return undefined;
|
|
148
|
+
let offset = start;
|
|
149
|
+
for (let i = 0; i < index; i += 1) {
|
|
150
|
+
offset += (parts[i]?.length ?? 0) + separator.length;
|
|
151
|
+
}
|
|
152
|
+
return offset;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The chunk containing `offset`, or `undefined` when it lands on a separator. */
|
|
156
|
+
export function spanAt<TRef>(index: TextIndex<TRef>, offset: number): TextSpan<TRef> | undefined {
|
|
157
|
+
const span = index.spans[lowerBound(index.spans, offset)];
|
|
158
|
+
return span && span.start <= offset ? span : undefined;
|
|
159
|
+
}
|