@papyrus-sdk/ui-react-native 0.2.35-beta.9 → 0.2.36
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/index.d.mts +12 -2
- package/dist/index.d.ts +12 -2
- package/dist/index.js +829 -236
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +843 -249
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -8
- package/runtime/epubAnnotations.js +276 -8
- package/runtime/epubReader.js +58 -0
- package/runtime/index.html +505 -84
- package/runtime/runtime.js +505 -84
- package/runtime/syncComicRuntime.mjs +6 -3
- package/runtime/comicRuntime.test.ts +0 -241
- package/runtime/documentLoadRuntime.test.ts +0 -172
- package/runtime/epubAnnotations.test.ts +0 -31
- package/runtime/epubScrollDiagnostics.test.ts +0 -107
- package/runtime/epubSelectionMenu.test.ts +0 -39
|
@@ -24,11 +24,13 @@ const runtimeBodyStart = runtime.indexOf("\n(function () {", runtime.indexOf(com
|
|
|
24
24
|
if (runtimeBodyStart < 0) {
|
|
25
25
|
throw new Error("Main runtime body not found");
|
|
26
26
|
}
|
|
27
|
-
const
|
|
27
|
+
const readerSource=await readFile(resolve(runtimeDirectory,"epubReader.js"),"utf8");
|
|
28
|
+
const readerRuntime=replaceMarkedSource(runtime,"/* @papyrus-epub-reader:start */","/* @papyrus-epub-reader:end */",`\n${readerSource}\n`);
|
|
29
|
+
const runtimeBody = readerRuntime.slice(readerRuntime.indexOf("\n(function () {",readerRuntime.indexOf(comicRuntimeEnd))+1).trim();
|
|
28
30
|
await writeFile(
|
|
29
31
|
runtimePath,
|
|
30
32
|
replaceMarkedSource(
|
|
31
|
-
|
|
33
|
+
readerRuntime,
|
|
32
34
|
"/* @papyrus-comic-runtime:start */",
|
|
33
35
|
"/* @papyrus-comic-runtime:end */",
|
|
34
36
|
`\n${source.trim()}\n`
|
|
@@ -37,12 +39,13 @@ await writeFile(
|
|
|
37
39
|
|
|
38
40
|
const htmlPath = resolve(runtimeDirectory, "index.html");
|
|
39
41
|
const html = await readFile(htmlPath, "utf8");
|
|
40
|
-
|
|
42
|
+
let htmlWithComicRuntime = replaceMarkedSource(
|
|
41
43
|
html,
|
|
42
44
|
"<!-- @papyrus-comic-runtime:start -->",
|
|
43
45
|
"<!-- @papyrus-comic-runtime:end -->",
|
|
44
46
|
`\n <script>\n${source}\n </script>\n`
|
|
45
47
|
);
|
|
48
|
+
htmlWithComicRuntime=htmlWithComicRuntime.replace(/<!-- @papyrus-epub-reader:start -->[\s\S]*?<!-- @papyrus-epub-reader:end -->\n?/g,"");
|
|
46
49
|
const htmlComicRuntimeEnd = "<!-- @papyrus-comic-runtime:end -->";
|
|
47
50
|
const htmlBodyStart = htmlWithComicRuntime.indexOf(
|
|
48
51
|
"<script>",
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
getComicPreviewSize,
|
|
6
|
-
getComicPageAspectRatio,
|
|
7
|
-
getProtectedComicPageIndexes,
|
|
8
|
-
isCurrentComicPageLoad,
|
|
9
|
-
isComicImageName,
|
|
10
|
-
patchCbrWorkerSource,
|
|
11
|
-
sortComicPageNames,
|
|
12
|
-
} from "./comicRuntime";
|
|
13
|
-
|
|
14
|
-
describe("mobile comic runtime helpers", () => {
|
|
15
|
-
it("keeps only supported comic image entries", () => {
|
|
16
|
-
expect(isComicImageName("pages/001.jpg")).toBe(true);
|
|
17
|
-
expect(isComicImageName("pages/002.webp")).toBe(true);
|
|
18
|
-
expect(isComicImageName("pages/readme.txt")).toBe(false);
|
|
19
|
-
expect(isComicImageName("pages/")).toBe(false);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("sorts page names naturally", () => {
|
|
23
|
-
expect(
|
|
24
|
-
sortComicPageNames(["page-10.jpg", "page-2.jpg", "page-1.jpg"])
|
|
25
|
-
).toEqual(["page-1.jpg", "page-2.jpg", "page-10.jpg"]);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("patches the shipped CBR worker to use the packaged WASM asset", () => {
|
|
29
|
-
const worker = readFileSync(
|
|
30
|
-
resolve(
|
|
31
|
-
process.cwd(),
|
|
32
|
-
"packages/engine-cbr-mobile/runtime/worker-bundle.js.txt"
|
|
33
|
-
),
|
|
34
|
-
"utf8"
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
const patched = patchCbrWorkerSource(worker, "/assets/libarchive.wasm");
|
|
38
|
-
|
|
39
|
-
expect(patched).not.toContain(
|
|
40
|
-
'new URL("libarchive.wasm",import.meta.url).href'
|
|
41
|
-
);
|
|
42
|
-
expect(patched).toContain(JSON.stringify("/assets/libarchive.wasm"));
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("caps comic previews without upscaling them", () => {
|
|
46
|
-
expect(getComicPreviewSize(1600, 2400)).toEqual({ width: 240, height: 360 });
|
|
47
|
-
expect(getComicPreviewSize(120, 180)).toEqual({ width: 120, height: 180 });
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("keeps the page geometry when a cached image URL is evicted", () => {
|
|
51
|
-
expect(getComicPageAspectRatio(1200, 1800)).toBe("1200 / 1800");
|
|
52
|
-
expect(getComicPageAspectRatio(0, 0)).toBe("1 / 1");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("protects visible and pending pages from URL eviction", () => {
|
|
56
|
-
expect(
|
|
57
|
-
Array.from(getProtectedComicPageIndexes(1, [3, 4], [5])).sort(
|
|
58
|
-
(left, right) => left - right
|
|
59
|
-
)
|
|
60
|
-
).toEqual([-1, 0, 1, 3, 4, 5]);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("rejects page extraction completions from an older document", () => {
|
|
64
|
-
const entry = { name: "old.jpg" };
|
|
65
|
-
const currentEntry = { name: "new.jpg" };
|
|
66
|
-
|
|
67
|
-
expect(isCurrentComicPageLoad(2, 3, entry, currentEntry)).toBe(false);
|
|
68
|
-
expect(isCurrentComicPageLoad(3, 3, entry, currentEntry)).toBe(false);
|
|
69
|
-
expect(isCurrentComicPageLoad(3, 3, entry, entry)).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("embeds the tested helpers in the shipped runtimes", () => {
|
|
73
|
-
const runtime = readFileSync(
|
|
74
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
75
|
-
"utf8"
|
|
76
|
-
);
|
|
77
|
-
const html = readFileSync(
|
|
78
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/index.html"),
|
|
79
|
-
"utf8"
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
expect(runtime.match(/function getComicPageAspectRatio/g)).toHaveLength(1);
|
|
83
|
-
expect(html.match(/function getComicPageAspectRatio/g)).toHaveLength(1);
|
|
84
|
-
expect(html).toContain("root.PapyrusComicRuntime");
|
|
85
|
-
expect(runtime).toContain("getProtectedComicPageIndexes");
|
|
86
|
-
expect(html).toContain("getProtectedComicPageIndexes");
|
|
87
|
-
expect(runtime).toContain("file-chunk-request");
|
|
88
|
-
expect(html).toContain("file-chunk-request");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("loads local EPUB files through the native chunk bridge", () => {
|
|
92
|
-
const runtime = readFileSync(
|
|
93
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
94
|
-
"utf8"
|
|
95
|
-
);
|
|
96
|
-
const html = readFileSync(
|
|
97
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/index.html"),
|
|
98
|
-
"utf8"
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
expect(runtime).toContain("sourceToArrayBuffer(source),");
|
|
102
|
-
expect(html).toContain("sourceToArrayBuffer(source),");
|
|
103
|
-
expect(runtime).not.toContain("data = source.uri;");
|
|
104
|
-
expect(html).not.toContain("data = source.uri;");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("passes an exact ArrayBuffer to epub.js for base64 sources", () => {
|
|
108
|
-
const runtime = readFileSync(
|
|
109
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
110
|
-
"utf8"
|
|
111
|
-
);
|
|
112
|
-
const html = readFileSync(
|
|
113
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/index.html"),
|
|
114
|
-
"utf8"
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
expect(runtime).toContain("const toExactArrayBuffer = (bytes) =>");
|
|
118
|
-
expect(html).toContain("const toExactArrayBuffer = (bytes) =>");
|
|
119
|
-
expect(runtime).toContain("data = toExactArrayBuffer(decodeBase64(source.data));");
|
|
120
|
-
expect(html).toContain("data = toExactArrayBuffer(decodeBase64(source.data));");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("correlates EPUB diagnostics and load terminals by request id", () => {
|
|
124
|
-
const runtime = readFileSync(
|
|
125
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
126
|
-
"utf8"
|
|
127
|
-
);
|
|
128
|
-
const html = readFileSync(
|
|
129
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/index.html"),
|
|
130
|
-
"utf8"
|
|
131
|
-
);
|
|
132
|
-
for (const artifact of [runtime, html]) {
|
|
133
|
-
expect(artifact).toContain("epub.load.start");
|
|
134
|
-
expect(artifact).toContain("epub.book.ready");
|
|
135
|
-
expect(artifact).toContain("epub.display.start");
|
|
136
|
-
expect(artifact).toContain("epub.load.ready");
|
|
137
|
-
expect(artifact).toContain("epub.load.error");
|
|
138
|
-
expect(artifact).toContain("epub.load.timeout");
|
|
139
|
-
expect(artifact).toContain("loadId");
|
|
140
|
-
expect(artifact).toContain("document.ready");
|
|
141
|
-
expect(artifact).toContain("document.error");
|
|
142
|
-
expect(artifact).toContain("withEpubStageTimeout");
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("invalidates a pending EPUB load when another document starts", () => {
|
|
147
|
-
const runtime = readFileSync(
|
|
148
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
149
|
-
"utf8"
|
|
150
|
-
);
|
|
151
|
-
const loadStart = runtime.indexOf("if (kind === 'load')");
|
|
152
|
-
const generationInvalidation = runtime.indexOf(
|
|
153
|
-
"epubLoadGeneration += 1",
|
|
154
|
-
loadStart
|
|
155
|
-
);
|
|
156
|
-
const currentTypeAssignment = runtime.indexOf(
|
|
157
|
-
"currentType = payload.type",
|
|
158
|
-
loadStart
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
expect(generationInvalidation).toBeGreaterThan(loadStart);
|
|
162
|
-
expect(generationInvalidation).toBeLessThan(currentTypeAssignment);
|
|
163
|
-
expect(runtime).toContain(
|
|
164
|
-
"const loadEpub = async (source, loadId, generation) =>"
|
|
165
|
-
);
|
|
166
|
-
expect(runtime).toContain(
|
|
167
|
-
"const result = await loadEpub(payload.source, id, generation);"
|
|
168
|
-
);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("rejects a stale EPUB load after outline resolution", () => {
|
|
172
|
-
const runtime = readFileSync(
|
|
173
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
174
|
-
"utf8"
|
|
175
|
-
);
|
|
176
|
-
const outlineStart = runtime.indexOf("const outline = await withEpubStageTimeout(");
|
|
177
|
-
const outlineGuard = runtime.indexOf(
|
|
178
|
-
"if (!isCurrentLoad()) throw new Error('EPUB load superseded by a newer load');",
|
|
179
|
-
outlineStart
|
|
180
|
-
);
|
|
181
|
-
const readyEvent = runtime.indexOf(
|
|
182
|
-
"sendEpubDiagnostic('epub.load.ready'",
|
|
183
|
-
outlineStart
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
expect(outlineStart).toBeGreaterThan(-1);
|
|
187
|
-
expect(outlineGuard).toBeGreaterThan(outlineStart);
|
|
188
|
-
expect(outlineGuard).toBeLessThan(readyEvent);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("uses continuous scrolling for EPUB rendition", () => {
|
|
192
|
-
const runtime = readFileSync(
|
|
193
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
194
|
-
"utf8"
|
|
195
|
-
);
|
|
196
|
-
const html = readFileSync(
|
|
197
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/index.html"),
|
|
198
|
-
"utf8"
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
expect(runtime).toContain("manager: 'continuous'");
|
|
202
|
-
expect(html).toContain("manager: 'continuous'");
|
|
203
|
-
expect(runtime).toContain("flow: 'scrolled-continuous'");
|
|
204
|
-
expect(html).toContain("flow: 'scrolled-continuous'");
|
|
205
|
-
expect(runtime).toContain("rendition.on('relocated'");
|
|
206
|
-
expect(html).toContain("rendition.on('relocated'");
|
|
207
|
-
expect(runtime).toContain("sendEvent('VIEWER_SCROLL'");
|
|
208
|
-
expect(html).toContain("sendEvent('VIEWER_SCROLL'");
|
|
209
|
-
expect(runtime).toContain("sendEvent('VIEWER_TAP'");
|
|
210
|
-
expect(html).toContain("sendEvent('VIEWER_TAP'");
|
|
211
|
-
expect(runtime).not.toContain("flow: 'paginated'");
|
|
212
|
-
expect(html).not.toContain("flow: 'paginated'");
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it("observes the real EPUB manager queue without changing queue semantics", () => {
|
|
216
|
-
const runtime = readFileSync(
|
|
217
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
218
|
-
"utf8"
|
|
219
|
-
);
|
|
220
|
-
const html = readFileSync(
|
|
221
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/index.html"),
|
|
222
|
-
"utf8"
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
for (const artifact of [runtime, html]) {
|
|
226
|
-
expect(artifact).toContain("const originalCheck = typeof manager.check");
|
|
227
|
-
expect(artifact).toContain("manager.check = function (...args)");
|
|
228
|
-
expect(artifact).toContain("const originalEnqueue = queue && typeof queue.enqueue");
|
|
229
|
-
expect(artifact).toContain("queue.enqueue = function (...args)");
|
|
230
|
-
expect(artifact).toContain("manager.queue.enqueue");
|
|
231
|
-
expect(artifact).toContain("queue.enqueue = originalEnqueue");
|
|
232
|
-
expect(artifact).toContain("manager.check.request");
|
|
233
|
-
expect(artifact).toContain("manager.check.start");
|
|
234
|
-
expect(artifact).toContain("manager.check.end");
|
|
235
|
-
expect(artifact).toContain("epubSelectionCleanups");
|
|
236
|
-
expect(artifact).toContain("__papyrusSelectionCleanup");
|
|
237
|
-
expect(artifact).toContain("setSelectionActive(false)");
|
|
238
|
-
expect(artifact).not.toContain("String(task)");
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
});
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
import { JSDOM } from "jsdom";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
-
|
|
6
|
-
type RuntimeMessage = {
|
|
7
|
-
type?: string;
|
|
8
|
-
id?: string;
|
|
9
|
-
name?: string;
|
|
10
|
-
kind?: string;
|
|
11
|
-
ok?: boolean;
|
|
12
|
-
data?: { pageCount?: number };
|
|
13
|
-
error?: { message?: string } | string;
|
|
14
|
-
loadId?: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const runtimeSource = readFileSync(
|
|
18
|
-
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
19
|
-
"utf8",
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
const createRuntime = () => {
|
|
23
|
-
const messages: RuntimeMessage[] = [];
|
|
24
|
-
const dom = new JSDOM(
|
|
25
|
-
'<!doctype html><html><body><div id="viewer"></div></body></html>',
|
|
26
|
-
{ runScripts: "outside-only", url: "http://localhost/" },
|
|
27
|
-
);
|
|
28
|
-
dom.window.ReactNativeWebView = {
|
|
29
|
-
postMessage: (raw: string) => messages.push(JSON.parse(raw)),
|
|
30
|
-
} as never;
|
|
31
|
-
dom.window.eval(runtimeSource);
|
|
32
|
-
|
|
33
|
-
const send = (message: Record<string, unknown>) => {
|
|
34
|
-
dom.window.dispatchEvent(
|
|
35
|
-
new dom.window.MessageEvent("message", { data: JSON.stringify(message) }),
|
|
36
|
-
);
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
return { dom, messages, send };
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const waitFor = async (
|
|
43
|
-
predicate: () => boolean,
|
|
44
|
-
timeoutMs = 1000,
|
|
45
|
-
): Promise<void> => {
|
|
46
|
-
const deadline = Date.now() + timeoutMs;
|
|
47
|
-
while (!predicate()) {
|
|
48
|
-
if (Date.now() >= deadline) throw new Error("runtime message timeout");
|
|
49
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
describe("document loading runtime", () => {
|
|
54
|
-
it("publishes an empty TXT document as a completed one-page load", async () => {
|
|
55
|
-
const runtime = createRuntime();
|
|
56
|
-
|
|
57
|
-
runtime.send({
|
|
58
|
-
id: "empty-1",
|
|
59
|
-
kind: "load",
|
|
60
|
-
payload: { type: "text", source: { kind: "text", text: "" } },
|
|
61
|
-
});
|
|
62
|
-
await waitFor(() =>
|
|
63
|
-
runtime.messages.some(
|
|
64
|
-
(message) => message.type === "response" && message.id === "empty-1",
|
|
65
|
-
),
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
expect(runtime.messages).toContainEqual({
|
|
69
|
-
type: "event",
|
|
70
|
-
name: "document.ready",
|
|
71
|
-
payload: { loadId: "empty-1", pageCount: 1 },
|
|
72
|
-
});
|
|
73
|
-
expect(runtime.messages).toContainEqual(
|
|
74
|
-
expect.objectContaining({ type: "response", id: "empty-1", ok: true }),
|
|
75
|
-
);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("reports an invalid TXT source instead of leaving loading pending", async () => {
|
|
79
|
-
const runtime = createRuntime();
|
|
80
|
-
|
|
81
|
-
runtime.send({
|
|
82
|
-
id: "invalid-1",
|
|
83
|
-
kind: "load",
|
|
84
|
-
payload: { type: "text", source: null },
|
|
85
|
-
});
|
|
86
|
-
await waitFor(() =>
|
|
87
|
-
runtime.messages.some(
|
|
88
|
-
(message) => message.type === "response" && message.id === "invalid-1",
|
|
89
|
-
),
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
expect(runtime.messages).toContainEqual(
|
|
93
|
-
expect.objectContaining({
|
|
94
|
-
type: "event",
|
|
95
|
-
name: "document.error",
|
|
96
|
-
payload: expect.objectContaining({ loadId: "invalid-1" }),
|
|
97
|
-
}),
|
|
98
|
-
);
|
|
99
|
-
expect(runtime.messages).toContainEqual(
|
|
100
|
-
expect.objectContaining({ type: "response", id: "invalid-1", ok: false }),
|
|
101
|
-
);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("marks a pending TXT load stale when another format starts", async () => {
|
|
105
|
-
const runtime = createRuntime();
|
|
106
|
-
let resolveText: ((value: string) => void) | undefined;
|
|
107
|
-
const fetchText = vi.fn(
|
|
108
|
-
() =>
|
|
109
|
-
new Promise<string>((resolve) => {
|
|
110
|
-
resolveText = resolve;
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
runtime.dom.window.fetch = vi.fn(async () => ({
|
|
114
|
-
text: fetchText,
|
|
115
|
-
})) as never;
|
|
116
|
-
|
|
117
|
-
runtime.send({
|
|
118
|
-
id: "text-a",
|
|
119
|
-
kind: "load",
|
|
120
|
-
payload: {
|
|
121
|
-
type: "text",
|
|
122
|
-
source: { kind: "uri", uri: "http://localhost/a.txt" },
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
await waitFor(() => fetchText.mock.calls.length === 1);
|
|
126
|
-
|
|
127
|
-
runtime.send({
|
|
128
|
-
id: "new-document-b",
|
|
129
|
-
kind: "load",
|
|
130
|
-
payload: {
|
|
131
|
-
type: "text",
|
|
132
|
-
source: { kind: "text", text: "new document" },
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
await waitFor(() =>
|
|
136
|
-
runtime.messages.some(
|
|
137
|
-
(message) => message.type === "response" && message.id === "new-document-b",
|
|
138
|
-
),
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
resolveText?.("old document");
|
|
142
|
-
await waitFor(() =>
|
|
143
|
-
runtime.messages.some(
|
|
144
|
-
(message) => message.type === "response" && message.id === "text-a",
|
|
145
|
-
),
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
expect(runtime.messages).toContainEqual({
|
|
149
|
-
type: "event",
|
|
150
|
-
name: "document.ready",
|
|
151
|
-
payload: { loadId: "new-document-b", pageCount: 1 },
|
|
152
|
-
});
|
|
153
|
-
expect(runtime.messages).toContainEqual(
|
|
154
|
-
expect.objectContaining({
|
|
155
|
-
type: "event",
|
|
156
|
-
name: "document.stale",
|
|
157
|
-
payload: expect.objectContaining({ loadId: "text-a" }),
|
|
158
|
-
}),
|
|
159
|
-
);
|
|
160
|
-
expect(runtime.messages).toContainEqual(
|
|
161
|
-
expect.objectContaining({ type: "response", id: "new-document-b", ok: true }),
|
|
162
|
-
);
|
|
163
|
-
expect(runtime.messages).toContainEqual(
|
|
164
|
-
expect.objectContaining({ type: "response", id: "text-a", ok: false }),
|
|
165
|
-
);
|
|
166
|
-
expect(
|
|
167
|
-
runtime.messages.filter(
|
|
168
|
-
(message) => message.type === "event" && message.name === "document.ready",
|
|
169
|
-
),
|
|
170
|
-
).toHaveLength(1);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import {createRequire} from 'node:module';
|
|
2
|
-
import {describe,it,expect} from 'vitest';
|
|
3
|
-
const annotations = createRequire(import.meta.url)('./epubAnnotations.js');
|
|
4
|
-
const anchor = {version:1,kind:'epub-cfi',cfiRange:'epubcfi(/6/2!/4/2:0)',href:'chapter.xhtml',quote:'word'};
|
|
5
|
-
describe('EPUB contextual anchors',()=>{
|
|
6
|
-
it('rejects malformed CFI and unsafe hrefs',()=>{expect(annotations.validAnchor(anchor)).toBe(true);expect(annotations.validAnchor({...anchor,cfiRange:'bad'})).toBe(false);expect(annotations.validAnchor({...anchor,href:'javascript:alert(1)'})).toBe(false);});
|
|
7
|
-
it('recovers uniquely in chapter text preserving UTF16 and excludes overlays',()=>{document.body.innerHTML='<p>A 😀 word end</p><div data-papyrus-overlay>word</div>';expect(annotations.recoverRange(document,anchor)?.toString()).toBe('word');});
|
|
8
|
-
it('leaves ambiguous quotes unresolved unless context distinguishes them',()=>{document.body.innerHTML='<p>one word two word end</p>';expect(annotations.recoverRange(document,anchor)).toBeNull();expect(annotations.recoverRange(document,{...anchor,prefix:'two '})?.toString()).toBe('word');});
|
|
9
|
-
it('verifies exact CFI quote before controlled recovery',()=>{document.body.innerHTML='<p>other word</p>';const bad=document.createRange();bad.selectNodeContents(document.body);expect(annotations.resolveRange({document,range:()=>bad},anchor)?.toString()).toBe('word');});
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('renders true strikeout, grouped notes and reconciles 120 stable IDs after reflow',()=>{
|
|
13
|
-
document.body.innerHTML='<p>word</p>';
|
|
14
|
-
const range=document.createRange();range.selectNodeContents(document.querySelector('p')!);
|
|
15
|
-
let top=20;Object.defineProperty(range,'getClientRects',{value:()=>[{left:10,right:50,top,bottom:top+20,width:40,height:20}]});
|
|
16
|
-
let queue:FrameRequestCallback[]=[];
|
|
17
|
-
const originalRAF=window.requestAnimationFrame;window.requestAnimationFrame=cb=>{queue.push(cb);return queue.length;};
|
|
18
|
-
let snapshot=Array.from({length:120},(_,i)=>({id:`n${i}`,anchor,color:'#ff0000',markupStyle:'strikeout',noteContent:`note ${i}`}));
|
|
19
|
-
const taps:string[]=[];
|
|
20
|
-
const renderer=annotations.install({document,window,range:()=>range},{href:'chapter.xhtml',documentId:undefined,annotations:()=>snapshot,labels:()=>({comment:'Nota'}),isCurrent:()=>true,onTap:(id:string)=>taps.push(id)});
|
|
21
|
-
const flush=()=>{const tasks=queue;queue=[];tasks.forEach(cb=>cb(0));};flush();
|
|
22
|
-
expect(document.querySelectorAll('[data-annotation-id]')).toHaveLength(120);
|
|
23
|
-
expect(document.querySelectorAll('[data-papyrus-overlay] button')).toHaveLength(1);
|
|
24
|
-
const group=document.querySelector('[data-annotation-id="n0"]')!;
|
|
25
|
-
expect((group.firstChild as HTMLElement).style.top).toBe('30px');
|
|
26
|
-
expect((group.lastChild as HTMLElement).textContent).toBe('120');
|
|
27
|
-
(group.lastChild as HTMLElement).click();expect(taps).toEqual(['n0']);
|
|
28
|
-
top=80;renderer.schedule();flush();expect(document.querySelector('[data-annotation-id="n0"]')).toBe(group);expect((group.firstChild as HTMLElement).style.top).toBe('90px');
|
|
29
|
-
snapshot=[];renderer.schedule();flush();expect(document.querySelectorAll('[data-annotation-id]')).toHaveLength(0);
|
|
30
|
-
renderer.destroy();expect(document.querySelector('[data-papyrus-overlay]')).toBeNull();window.requestAnimationFrame=originalRAF;
|
|
31
|
-
});
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
createEpubScrollStallDetector,
|
|
4
|
-
getEpubScrollDirection,
|
|
5
|
-
} from "./epubScrollDiagnostics";
|
|
6
|
-
|
|
7
|
-
describe("EPUB scroll diagnostics", () => {
|
|
8
|
-
it("classifies upward movement from the scroll offset", () => {
|
|
9
|
-
expect(getEpubScrollDirection(400, 420)).toBe("up");
|
|
10
|
-
expect(getEpubScrollDirection(420, 400)).toBe("down");
|
|
11
|
-
expect(getEpubScrollDirection(400, 400)).toBe("none");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("reports a reverse-scroll stall only after valid consecutive moves", () => {
|
|
15
|
-
const detector = createEpubScrollStallDetector({
|
|
16
|
-
minConsecutiveSamples: 3,
|
|
17
|
-
minDurationMs: 150,
|
|
18
|
-
movementThresholdPx: 4,
|
|
19
|
-
offsetEpsilonPx: 1,
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
expect(
|
|
23
|
-
detector.push({
|
|
24
|
-
timestamp: 0,
|
|
25
|
-
scrollTop: 420,
|
|
26
|
-
previousScrollTop: 420,
|
|
27
|
-
deltaY: 18,
|
|
28
|
-
scrollHeight: 2000,
|
|
29
|
-
clientHeight: 600,
|
|
30
|
-
scrollEnabled: true,
|
|
31
|
-
selectionActive: false,
|
|
32
|
-
gestureLock: false,
|
|
33
|
-
spineIndex: 4,
|
|
34
|
-
progress: 0.3,
|
|
35
|
-
})
|
|
36
|
-
).toBeNull();
|
|
37
|
-
|
|
38
|
-
expect(
|
|
39
|
-
detector.push({
|
|
40
|
-
timestamp: 70,
|
|
41
|
-
scrollTop: 420,
|
|
42
|
-
previousScrollTop: 420,
|
|
43
|
-
deltaY: 18,
|
|
44
|
-
scrollHeight: 2000,
|
|
45
|
-
clientHeight: 600,
|
|
46
|
-
scrollEnabled: true,
|
|
47
|
-
selectionActive: false,
|
|
48
|
-
gestureLock: false,
|
|
49
|
-
spineIndex: 4,
|
|
50
|
-
progress: 0.3,
|
|
51
|
-
})
|
|
52
|
-
).toBeNull();
|
|
53
|
-
|
|
54
|
-
const stall = detector.push({
|
|
55
|
-
timestamp: 180,
|
|
56
|
-
scrollTop: 420,
|
|
57
|
-
previousScrollTop: 420,
|
|
58
|
-
deltaY: 18,
|
|
59
|
-
scrollHeight: 2000,
|
|
60
|
-
clientHeight: 600,
|
|
61
|
-
scrollEnabled: true,
|
|
62
|
-
selectionActive: false,
|
|
63
|
-
gestureLock: false,
|
|
64
|
-
spineIndex: 4,
|
|
65
|
-
progress: 0.3,
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
expect(stall).toMatchObject({
|
|
69
|
-
name: "epub.scroll.stall",
|
|
70
|
-
direction: "up",
|
|
71
|
-
scrollTop: 420,
|
|
72
|
-
consecutiveSamples: 3,
|
|
73
|
-
durationMs: 180,
|
|
74
|
-
spineIndex: 4,
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("ignores top, disabled, selected, and genuinely moving samples", () => {
|
|
79
|
-
const detector = createEpubScrollStallDetector();
|
|
80
|
-
const base = {
|
|
81
|
-
timestamp: 0,
|
|
82
|
-
scrollTop: 0,
|
|
83
|
-
previousScrollTop: 0,
|
|
84
|
-
deltaY: 20,
|
|
85
|
-
scrollHeight: 2000,
|
|
86
|
-
clientHeight: 600,
|
|
87
|
-
scrollEnabled: true,
|
|
88
|
-
selectionActive: false,
|
|
89
|
-
gestureLock: false,
|
|
90
|
-
spineIndex: 0,
|
|
91
|
-
progress: 0,
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
expect(detector.push(base)).toBeNull();
|
|
95
|
-
expect(detector.push({ ...base, timestamp: 100, scrollEnabled: false })).toBeNull();
|
|
96
|
-
expect(detector.push({ ...base, timestamp: 200, selectionActive: true })).toBeNull();
|
|
97
|
-
expect(
|
|
98
|
-
detector.push({
|
|
99
|
-
...base,
|
|
100
|
-
timestamp: 300,
|
|
101
|
-
scrollTop: 380,
|
|
102
|
-
previousScrollTop: 400,
|
|
103
|
-
})
|
|
104
|
-
).toBeNull();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
});
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import {readFileSync} from 'node:fs';
|
|
2
|
-
import {JSDOM} from 'jsdom';
|
|
3
|
-
import {describe,it,expect} from 'vitest';
|
|
4
|
-
const source=readFileSync('packages/ui-react-native/runtime/runtime.js','utf8');
|
|
5
|
-
function fixture() {
|
|
6
|
-
const messages:any[]=[];
|
|
7
|
-
const dom=new JSDOM('<div id="viewer"></div>',{runScripts:'outside-only'});
|
|
8
|
-
dom.window.ReactNativeWebView={postMessage:(raw:string)=>messages.push(JSON.parse(raw))} as never;
|
|
9
|
-
// Expose only the setup boundary in the test copy; execute the production handler.
|
|
10
|
-
dom.window.eval(source.replace("sendMessage({ type: 'ready' });",`
|
|
11
|
-
window.seedMenu=(session,payload,contents)=>{documentSessionId=session;nativeMenuSelection={payload,contents};};`));
|
|
12
|
-
const chapter=new JSDOM('<p>one word and a phrase</p>');
|
|
13
|
-
const text=chapter.window.document.querySelector('p')!.firstChild!;
|
|
14
|
-
const range=chapter.window.document.createRange();range.setStart(text,4);range.setEnd(text,8);
|
|
15
|
-
chapter.window.getSelection()!.addRange(range);
|
|
16
|
-
const payload={documentSessionId:'current',text:'word',pageIndex:1,
|
|
17
|
-
anchor:{kind:'epub-cfi',version:1,cfiRange:'epubcfi(/6/2!/4/2:4)',href:'one.xhtml',quote:'word'}};
|
|
18
|
-
(dom.window as any).seedMenu('current',payload,{document:chapter.window.document,window:chapter.window});
|
|
19
|
-
const send=(key:string,session='current')=>dom.window.dispatchEvent(new dom.window.MessageEvent('message',
|
|
20
|
-
{data:JSON.stringify({type:'epub-selection-action',id:'selection-menu',key,documentSessionId:session})}));
|
|
21
|
-
return {messages,chapter,send,dom};
|
|
22
|
-
}
|
|
23
|
-
describe('native EPUB menu dispatch',()=>{
|
|
24
|
-
it('uses the chapter CFI snapshot even when root selection is empty',()=>{
|
|
25
|
-
const f=fixture();expect(f.dom.window.getSelection()!.toString()).toBe('');f.send('highlight');
|
|
26
|
-
expect(f.messages).toContainEqual(expect.objectContaining({name:'EPUB_SELECTION_ACTION',payload:expect.objectContaining({action:'highlight',text:'word',anchor:expect.objectContaining({href:'one.xhtml'})})}));
|
|
27
|
-
expect(f.chapter.window.getSelection()!.toString()).toBe('');
|
|
28
|
-
});
|
|
29
|
-
it('rejects stale document sessions and unknown actions',()=>{
|
|
30
|
-
const f=fixture();f.send('copy','old');f.send('deleteDocument');expect(f.messages).toHaveLength(0);
|
|
31
|
-
expect(f.chapter.window.getSelection()!.toString()).toBe('word');
|
|
32
|
-
});
|
|
33
|
-
it('selects all inside the chapter, without annotating the previous word',()=>{
|
|
34
|
-
const f=fixture();f.send('selectAll');expect(f.chapter.window.getSelection()!.toString()).toBe('one word and a phrase');expect(f.messages).toHaveLength(0);
|
|
35
|
-
});
|
|
36
|
-
it('consumes each action once',()=>{
|
|
37
|
-
const f=fixture();f.send('comment');f.send('comment');expect(f.messages).toHaveLength(1);
|
|
38
|
-
});
|
|
39
|
-
});
|