@papyrus-sdk/ui-react-native 0.2.24 → 0.2.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papyrus-sdk/ui-react-native",
3
- "version": "0.2.24",
3
+ "version": "0.2.27",
4
4
  "license": "MIT",
5
5
  "keywords": [
6
6
  "papyrus",
@@ -48,9 +48,11 @@
48
48
  "@papyrus-sdk/core": ">=0.2.18",
49
49
  "@papyrus-sdk/engine-native": ">=0.2.17",
50
50
  "@papyrus-sdk/types": ">=0.2.11",
51
+ "@react-native-clipboard/clipboard": "^1.16.3",
51
52
  "react": ">=18.2.0 <20",
52
53
  "react-native": ">=0.81.0",
53
54
  "react-native-gesture-handler": ">=2.28.0",
55
+ "react-native-safe-area-context": "^5.6.2",
54
56
  "react-native-webview": ">=13"
55
57
  },
56
58
  "peerDependenciesMeta": {
@@ -62,9 +64,11 @@
62
64
  "@papyrus-sdk/core": "0.2.19",
63
65
  "@papyrus-sdk/engine-native": "workspace:*",
64
66
  "@papyrus-sdk/types": "0.2.12",
67
+ "@react-native-clipboard/clipboard": "^1.16.3",
65
68
  "@types/react": "19.2.14",
66
69
  "react": "^19.1.0",
67
70
  "react-native": "^0.81.0",
68
- "react-native-gesture-handler": "~2.28.0"
71
+ "react-native-gesture-handler": "~2.28.0",
72
+ "react-native-safe-area-context": "^5.6.2"
69
73
  }
70
74
  }
@@ -87,4 +87,155 @@ describe("mobile comic runtime helpers", () => {
87
87
  expect(runtime).toContain("file-chunk-request");
88
88
  expect(html).toContain("file-chunk-request");
89
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
+ });
90
241
  });
@@ -0,0 +1,172 @@
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
+ });
@@ -0,0 +1,107 @@
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
+ });
@@ -0,0 +1,127 @@
1
+ export type EpubScrollDirection = "up" | "down" | "none";
2
+
3
+ export type EpubScrollSample = {
4
+ timestamp: number;
5
+ scrollTop: number;
6
+ previousScrollTop: number;
7
+ deltaY: number;
8
+ scrollHeight: number;
9
+ clientHeight: number;
10
+ scrollEnabled: boolean;
11
+ selectionActive: boolean;
12
+ gestureLock: boolean;
13
+ spineIndex: number | null;
14
+ progress: number | null;
15
+ };
16
+
17
+ export type EpubScrollStall = {
18
+ name: "epub.scroll.stall";
19
+ direction: "up";
20
+ scrollTop: number;
21
+ scrollHeight: number;
22
+ clientHeight: number;
23
+ consecutiveSamples: number;
24
+ durationMs: number;
25
+ deltaY: number;
26
+ scrollEnabled: boolean;
27
+ selectionActive: boolean;
28
+ gestureLock: boolean;
29
+ spineIndex: number | null;
30
+ progress: number | null;
31
+ };
32
+
33
+ export type EpubScrollStallDetectorOptions = {
34
+ minConsecutiveSamples?: number;
35
+ minDurationMs?: number;
36
+ maxDurationMs?: number;
37
+ movementThresholdPx?: number;
38
+ offsetEpsilonPx?: number;
39
+ topThresholdPx?: number;
40
+ };
41
+
42
+ const DEFAULT_OPTIONS: Required<EpubScrollStallDetectorOptions> = {
43
+ minConsecutiveSamples: 3,
44
+ minDurationMs: 150,
45
+ maxDurationMs: 1000,
46
+ movementThresholdPx: 4,
47
+ offsetEpsilonPx: 1,
48
+ topThresholdPx: 2,
49
+ };
50
+
51
+ export const getEpubScrollDirection = (
52
+ scrollTop: number,
53
+ previousScrollTop: number,
54
+ epsilon = 0.5
55
+ ): EpubScrollDirection => {
56
+ const delta = scrollTop - previousScrollTop;
57
+ if (delta < -epsilon) return "up";
58
+ if (delta > epsilon) return "down";
59
+ return "none";
60
+ };
61
+
62
+ export const createEpubScrollStallDetector = (
63
+ options: EpubScrollStallDetectorOptions = {}
64
+ ) => {
65
+ const config = { ...DEFAULT_OPTIONS, ...options };
66
+ let samples: EpubScrollSample[] = [];
67
+ let reported = false;
68
+
69
+ const reset = () => {
70
+ samples = [];
71
+ reported = false;
72
+ };
73
+
74
+ const push = (sample: EpubScrollSample): EpubScrollStall | null => {
75
+ const expectedUpwardMovement = sample.deltaY >= config.movementThresholdPx;
76
+ const awayFromTop = sample.scrollTop > config.topThresholdPx;
77
+ const noActualMovement =
78
+ Math.abs(sample.scrollTop - sample.previousScrollTop) <=
79
+ config.offsetEpsilonPx;
80
+ const valid =
81
+ expectedUpwardMovement &&
82
+ awayFromTop &&
83
+ sample.scrollEnabled &&
84
+ !sample.selectionActive &&
85
+ !sample.gestureLock &&
86
+ noActualMovement;
87
+
88
+ if (!valid) {
89
+ reset();
90
+ return null;
91
+ }
92
+
93
+ samples.push(sample);
94
+ const first = samples[0];
95
+ const durationMs = sample.timestamp - first.timestamp;
96
+ if (durationMs > config.maxDurationMs) {
97
+ samples = [sample];
98
+ return null;
99
+ }
100
+ if (
101
+ reported ||
102
+ samples.length < config.minConsecutiveSamples ||
103
+ durationMs < config.minDurationMs
104
+ ) {
105
+ return null;
106
+ }
107
+
108
+ reported = true;
109
+ return {
110
+ name: "epub.scroll.stall",
111
+ direction: "up",
112
+ scrollTop: sample.scrollTop,
113
+ scrollHeight: sample.scrollHeight,
114
+ clientHeight: sample.clientHeight,
115
+ consecutiveSamples: samples.length,
116
+ durationMs,
117
+ deltaY: sample.deltaY,
118
+ scrollEnabled: sample.scrollEnabled,
119
+ selectionActive: sample.selectionActive,
120
+ gestureLock: sample.gestureLock,
121
+ spineIndex: sample.spineIndex,
122
+ progress: sample.progress,
123
+ };
124
+ };
125
+
126
+ return { push, reset };
127
+ };