@vysmo/flipbook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/loader.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Normalise a single page source into an element the `Runner` can use
3
+ * as a `TextureSource`. Strings are loaded into a new `Image`; existing
4
+ * image elements are awaited on; canvases pass through instantly.
5
+ */
6
+ export async function resolvePage(source) {
7
+ if (typeof source === "string") {
8
+ const img = new Image();
9
+ img.crossOrigin = "anonymous";
10
+ img.src = source;
11
+ await img.decode();
12
+ return img;
13
+ }
14
+ if (source instanceof HTMLCanvasElement) {
15
+ return source;
16
+ }
17
+ // HTMLImageElement — wait for it if it's not yet complete.
18
+ if (source.complete && source.naturalWidth > 0)
19
+ return source;
20
+ await source.decode();
21
+ return source;
22
+ }
23
+ export function resolveAll(sources) {
24
+ return Promise.all(sources.map(resolvePage));
25
+ }
26
+ //# sourceMappingURL=loader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loader.js","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"AAIA;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAAkB;IAClD,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,GAAG,GAAG,IAAI,KAAK,EAAE,CAAC;QACxB,GAAG,CAAC,WAAW,GAAG,WAAW,CAAC;QAC9B,GAAG,CAAC,GAAG,GAAG,MAAM,CAAC;QACjB,MAAM,GAAG,CAAC,MAAM,EAAE,CAAC;QACnB,OAAO,GAAG,CAAC;IACb,CAAC;IACD,IAAI,MAAM,YAAY,iBAAiB,EAAE,CAAC;QACxC,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,2DAA2D;IAC3D,IAAI,MAAM,CAAC,QAAQ,IAAI,MAAM,CAAC,YAAY,GAAG,CAAC;QAAE,OAAO,MAAM,CAAC;IAC9D,MAAM,MAAM,CAAC,MAAM,EAAE,CAAC;IACtB,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,UAAU,CACxB,OAA8B;IAE9B,OAAO,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC;AAC/C,CAAC"}
@@ -0,0 +1,120 @@
1
+ import type { EasingFn } from "@vysmo/easings";
2
+ /**
3
+ * A single page. URL strings are decoded into images; `HTMLImageElement`
4
+ * / `HTMLCanvasElement` are used as-is. Rich-DOM pages are out of scope
5
+ * for v1 — pre-render to a canvas if you need them.
6
+ */
7
+ export type PageSource = string | HTMLImageElement | HTMLCanvasElement;
8
+ export type FlipbookAxis = "horizontal" | "vertical";
9
+ export interface FlipbookOptions {
10
+ /**
11
+ * Host element. The flipbook creates its WebGL canvas inside this
12
+ * container. Size the container via CSS — the canvas fills 100% of it.
13
+ */
14
+ container: HTMLElement;
15
+ /** Page sources. Order matters: index 0 is the cover unless `initialPage` says otherwise. */
16
+ pages: readonly PageSource[];
17
+ /** Starting page index in [0, pages.length). Default 0. */
18
+ initialPage?: number;
19
+ /**
20
+ * Curl axis. `"horizontal"` peels the right edge leftward like an
21
+ * English book (pinned spine on the left). `"vertical"` peels the
22
+ * bottom edge upward like a wall calendar (pinned binding at the top).
23
+ * Default `"horizontal"`.
24
+ */
25
+ axis?: FlipbookAxis;
26
+ /**
27
+ * Hinge tilt in radians, **added on top of the axis baseline** (0 for
28
+ * horizontal, π/2 for vertical). Tilts the curl line away from a clean
29
+ * vertical/horizontal sweep. Default 0.12.
30
+ */
31
+ tilt?: number;
32
+ /** Page-back colour passed straight to the page-curl transition. */
33
+ backColor?: readonly [number, number, number];
34
+ /** Flip duration in milliseconds. Default 900. */
35
+ flipDuration?: number;
36
+ /** Easing for the flip progress. Default `cubicInOut` from `@vysmo/easings`. */
37
+ ease?: EasingFn;
38
+ /** Wrap from last → first (and vice versa). Default false — books have ends. */
39
+ loop?: boolean;
40
+ /**
41
+ * Click anywhere to flip: left half = prev, right half = next (vertical
42
+ * axis: top half = prev, bottom half = next). Default true.
43
+ */
44
+ clickNavigation?: boolean;
45
+ /**
46
+ * Pointer-drag scrubbing: drag in the flip direction to peel the page
47
+ * mid-curl. Release past `dragCommitThreshold` commits; below reverts.
48
+ * Default true.
49
+ */
50
+ dragNavigation?: boolean;
51
+ /**
52
+ * Drag commit threshold in [0, 1]. Released drag past this progress
53
+ * commits the flip; below reverts. Default 0.5. Lower (0.3) makes
54
+ * flips "sticky" — easier to commit. Higher (0.7) requires a more
55
+ * deliberate drag.
56
+ */
57
+ dragCommitThreshold?: number;
58
+ /**
59
+ * Keyboard navigation: `ArrowRight`/`ArrowDown` → next, `ArrowLeft`/
60
+ * `ArrowUp` → prev, `Home`/`End` → first/last. Default true.
61
+ */
62
+ keyboardNavigation?: boolean;
63
+ /**
64
+ * Auto-advance pages on a timer. `true` uses default 4000ms interval;
65
+ * pass `{ intervalMs }` to set your own. Pauses while the user is
66
+ * dragging or while the page lacks focus. Off by default. Use
67
+ * `play()` / `pause()` on the handle to toggle at runtime.
68
+ */
69
+ autoplay?: boolean | {
70
+ intervalMs: number;
71
+ };
72
+ /** Accessible label exposed via `aria-label`. Default `"Flipbook"`. */
73
+ ariaLabel?: string;
74
+ }
75
+ export interface FlipOptions {
76
+ /**
77
+ * Skip the curl animation and snap directly to the target page. Still
78
+ * emits `change` but not `flipstart` / `flipend`.
79
+ */
80
+ instant?: boolean;
81
+ }
82
+ export type FlipbookEvent = "change" | "flipstart" | "flipend";
83
+ export interface FlipbookHandle {
84
+ readonly current: number;
85
+ readonly length: number;
86
+ readonly isFlipping: boolean;
87
+ /**
88
+ * Resolves when every string / image source has finished decoding.
89
+ * Canvas sources resolve instantly. Safe to call `next()` etc. before
90
+ * this resolves — they queue until the first frame is ready.
91
+ */
92
+ readonly ready: Promise<void>;
93
+ /** Advance to the next page. No-op while a flip is already in flight. */
94
+ next(): Promise<void>;
95
+ /** Retreat to the previous page. No-op while a flip is already in flight. */
96
+ prev(): Promise<void>;
97
+ /** Flip directly to a specific index. No-op if already there. */
98
+ goTo(index: number, options?: FlipOptions): Promise<void>;
99
+ /**
100
+ * Drive the current flip's progress externally. `progress` in [0, 1]
101
+ * scrubs from the current page to the next. Useful for scroll-driven
102
+ * flipbooks: pipe `@vysmo/scroll` progress straight in. Calling `seek`
103
+ * cancels any in-flight tween.
104
+ *
105
+ * Reaching 1 commits the flip; reaching 0 reverts to the current page.
106
+ * Other values hold the page mid-curl.
107
+ */
108
+ seek(progress: number): void;
109
+ /** Start auto-advance. Idempotent — no-op if already playing. */
110
+ play(): void;
111
+ /** Stop auto-advance. Idempotent — no-op if already paused. */
112
+ pause(): void;
113
+ /** True when autoplay is active (started + not paused). */
114
+ readonly isPlaying: boolean;
115
+ on(event: "change", cb: (current: number, previous: number) => void): () => void;
116
+ on(event: "flipstart", cb: (from: number, to: number) => void): () => void;
117
+ on(event: "flipend", cb: (from: number, to: number) => void): () => void;
118
+ destroy(): void;
119
+ }
120
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@vysmo/flipbook",
3
+ "version": "0.1.0",
4
+ "description": "WebGL flipbook driven by the @vysmo/transitions page-curl mesh shader. Click halves, keyboard nav, drag corners to scrub mid-flip. Drop-in component or headless API.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "flipbook",
8
+ "page-flip",
9
+ "page-curl",
10
+ "webgl",
11
+ "transitions",
12
+ "drag-scrub",
13
+ "headless"
14
+ ],
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "main": "./dist/index.js",
18
+ "module": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "dependencies": {
33
+ "@vysmo/animations": "0.1.0",
34
+ "@vysmo/transitions": "0.1.0",
35
+ "@vysmo/easings": "0.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "@vitest/browser": "^3.2.4",
39
+ "esbuild": "^0.28.0",
40
+ "playwright": "^1.59.1",
41
+ "typescript": "^5.6.3",
42
+ "vitest": "^3.2.4"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.json",
46
+ "typecheck": "tsc -p tsconfig.typecheck.json",
47
+ "test": "vitest run && vitest run --config vitest.ssr.config.ts",
48
+ "test:browser": "vitest run",
49
+ "test:ssr": "vitest run --config vitest.ssr.config.ts",
50
+ "test:watch": "vitest",
51
+ "size": "node scripts/check-bundle-size.mjs"
52
+ }
53
+ }
@@ -0,0 +1,465 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { createFlipbook, type FlipbookHandle } from "../index.js";
3
+
4
+ let container: HTMLElement;
5
+
6
+ beforeEach(() => {
7
+ container = document.createElement("div");
8
+ container.style.width = "400px";
9
+ container.style.height = "300px";
10
+ document.body.appendChild(container);
11
+ });
12
+
13
+ afterEach(() => {
14
+ container.remove();
15
+ });
16
+
17
+ function makePage(color: string): HTMLCanvasElement {
18
+ const c = document.createElement("canvas");
19
+ c.width = 80;
20
+ c.height = 60;
21
+ const ctx = c.getContext("2d")!;
22
+ ctx.fillStyle = color;
23
+ ctx.fillRect(0, 0, 80, 60);
24
+ return c;
25
+ }
26
+
27
+ async function mountReady(
28
+ pages: HTMLCanvasElement[],
29
+ overrides?: Partial<Parameters<typeof createFlipbook>[0]>,
30
+ ): Promise<FlipbookHandle> {
31
+ const f = createFlipbook({
32
+ container,
33
+ pages,
34
+ flipDuration: 30,
35
+ ...overrides,
36
+ });
37
+ await f.ready;
38
+ return f;
39
+ }
40
+
41
+ describe("createFlipbook: structure", () => {
42
+ it("mounts a wrapper + canvas + aria-live status into the container", async () => {
43
+ const f = await mountReady([makePage("red"), makePage("green")]);
44
+ const wrapper = container.querySelector("[data-page-flip-wrapper]");
45
+ expect(wrapper).toBeTruthy();
46
+ expect(wrapper!.getAttribute("role")).toBe("region");
47
+ expect(wrapper!.getAttribute("aria-roledescription")).toBe("flipbook");
48
+ expect(wrapper!.getAttribute("aria-label")).toBe("Flipbook");
49
+ expect(wrapper!.querySelector("[data-page-flip-canvas]")).toBeTruthy();
50
+ const live = wrapper!.querySelector('[aria-live="polite"]');
51
+ expect(live).toBeTruthy();
52
+ f.destroy();
53
+ });
54
+
55
+ it("starts at index 0 by default; initialPage overrides", async () => {
56
+ const a = await mountReady([makePage("red"), makePage("green")]);
57
+ expect(a.current).toBe(0);
58
+ a.destroy();
59
+ container.innerHTML = "";
60
+ const b = await mountReady(
61
+ [makePage("red"), makePage("green"), makePage("blue")],
62
+ { initialPage: 2 },
63
+ );
64
+ expect(b.current).toBe(2);
65
+ b.destroy();
66
+ });
67
+
68
+ it("length reflects the number of pages", async () => {
69
+ const f = await mountReady([
70
+ makePage("red"),
71
+ makePage("green"),
72
+ makePage("blue"),
73
+ ]);
74
+ expect(f.length).toBe(3);
75
+ f.destroy();
76
+ });
77
+
78
+ it("throws when pages array is empty", () => {
79
+ expect(() => createFlipbook({ container, pages: [] })).toThrow(
80
+ /at least one page/,
81
+ );
82
+ });
83
+
84
+ it("respects a custom ariaLabel", async () => {
85
+ const f = await mountReady([makePage("red"), makePage("green")], {
86
+ ariaLabel: "Magazine",
87
+ });
88
+ const wrapper = container.querySelector("[data-page-flip-wrapper]");
89
+ expect(wrapper!.getAttribute("aria-label")).toBe("Magazine");
90
+ f.destroy();
91
+ });
92
+ });
93
+
94
+ describe("createFlipbook: navigation", () => {
95
+ it("next() advances the current index", async () => {
96
+ const f = await mountReady([
97
+ makePage("red"),
98
+ makePage("green"),
99
+ makePage("blue"),
100
+ ]);
101
+ await f.next();
102
+ expect(f.current).toBe(1);
103
+ await f.next();
104
+ expect(f.current).toBe(2);
105
+ f.destroy();
106
+ });
107
+
108
+ it("next() stops at last when loop=false (default)", async () => {
109
+ const f = await mountReady(
110
+ [makePage("red"), makePage("green")],
111
+ { initialPage: 1 },
112
+ );
113
+ await f.next();
114
+ expect(f.current).toBe(1);
115
+ f.destroy();
116
+ });
117
+
118
+ it("next() wraps last → first when loop=true", async () => {
119
+ const f = await mountReady(
120
+ [makePage("red"), makePage("green")],
121
+ { initialPage: 1, loop: true },
122
+ );
123
+ await f.next();
124
+ expect(f.current).toBe(0);
125
+ f.destroy();
126
+ });
127
+
128
+ it("prev() stops at first when loop=false", async () => {
129
+ const f = await mountReady([makePage("red"), makePage("green")]);
130
+ await f.prev();
131
+ expect(f.current).toBe(0);
132
+ f.destroy();
133
+ });
134
+
135
+ it("prev() wraps first → last when loop=true", async () => {
136
+ const f = await mountReady(
137
+ [makePage("red"), makePage("green"), makePage("blue")],
138
+ { loop: true },
139
+ );
140
+ await f.prev();
141
+ expect(f.current).toBe(2);
142
+ f.destroy();
143
+ });
144
+
145
+ it("goTo(index) flips to a specific page", async () => {
146
+ const f = await mountReady([
147
+ makePage("red"),
148
+ makePage("green"),
149
+ makePage("blue"),
150
+ ]);
151
+ await f.goTo(2);
152
+ expect(f.current).toBe(2);
153
+ f.destroy();
154
+ });
155
+
156
+ it("goTo(index, { instant: true }) snaps without playing the curl", async () => {
157
+ const f = await mountReady([
158
+ makePage("red"),
159
+ makePage("green"),
160
+ makePage("blue"),
161
+ ]);
162
+ const starts: number[] = [];
163
+ f.on("flipstart", () => starts.push(1));
164
+ await f.goTo(2, { instant: true });
165
+ expect(f.current).toBe(2);
166
+ expect(starts).toHaveLength(0);
167
+ f.destroy();
168
+ });
169
+
170
+ it("goTo(currentIndex) is a no-op", async () => {
171
+ const f = await mountReady([makePage("red"), makePage("green")]);
172
+ const starts: number[] = [];
173
+ f.on("flipstart", () => starts.push(1));
174
+ await f.goTo(0);
175
+ expect(starts).toHaveLength(0);
176
+ f.destroy();
177
+ });
178
+
179
+ it("clamps out-of-range indices on instant goTo", async () => {
180
+ const f = await mountReady([
181
+ makePage("red"),
182
+ makePage("green"),
183
+ makePage("blue"),
184
+ ]);
185
+ await f.goTo(99, { instant: true });
186
+ expect(f.current).toBe(2);
187
+ await f.goTo(-5, { instant: true });
188
+ expect(f.current).toBe(0);
189
+ f.destroy();
190
+ });
191
+ });
192
+
193
+ describe("createFlipbook: events", () => {
194
+ it("fires flipstart + change + flipend in order on next()", async () => {
195
+ const f = await mountReady([makePage("red"), makePage("green")]);
196
+ const events: string[] = [];
197
+ f.on("flipstart", (from, to) => events.push(`start:${from}->${to}`));
198
+ f.on("change", (cur, prev) => events.push(`change:${prev}->${cur}`));
199
+ f.on("flipend", (from, to) => events.push(`end:${from}->${to}`));
200
+ await f.next();
201
+ expect(events).toEqual(["start:0->1", "change:0->1", "end:0->1"]);
202
+ f.destroy();
203
+ });
204
+
205
+ it("instant goTo emits change but not flipstart/end", async () => {
206
+ const f = await mountReady([
207
+ makePage("red"),
208
+ makePage("green"),
209
+ makePage("blue"),
210
+ ]);
211
+ const events: string[] = [];
212
+ f.on("flipstart", () => events.push("start"));
213
+ f.on("change", () => events.push("change"));
214
+ f.on("flipend", () => events.push("end"));
215
+ await f.goTo(2, { instant: true });
216
+ expect(events).toEqual(["change"]);
217
+ f.destroy();
218
+ });
219
+
220
+ it("unsubscribe callback stops further events", async () => {
221
+ const f = await mountReady([makePage("red"), makePage("green")]);
222
+ const seen: number[] = [];
223
+ const off = f.on("change", (c) => seen.push(c));
224
+ await f.next();
225
+ off();
226
+ await f.prev();
227
+ expect(seen).toEqual([1]);
228
+ f.destroy();
229
+ });
230
+ });
231
+
232
+ describe("createFlipbook: keyboard", () => {
233
+ it("ArrowRight advances; ArrowLeft retreats", async () => {
234
+ const f = await mountReady([
235
+ makePage("red"),
236
+ makePage("green"),
237
+ makePage("blue"),
238
+ ]);
239
+ const wrapper = container.querySelector(
240
+ "[data-page-flip-wrapper]",
241
+ ) as HTMLElement;
242
+ wrapper.focus();
243
+ const firstChange = new Promise<number>((resolve) => {
244
+ const off = f.on("change", (cur) => {
245
+ off();
246
+ resolve(cur);
247
+ });
248
+ });
249
+ wrapper.dispatchEvent(
250
+ new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
251
+ );
252
+ expect(await firstChange).toBe(1);
253
+ f.destroy();
254
+ });
255
+
256
+ it("Home jumps to first page; End jumps to last", async () => {
257
+ const f = await mountReady(
258
+ [makePage("a"), makePage("b"), makePage("c"), makePage("d")],
259
+ { initialPage: 1 },
260
+ );
261
+ const wrapper = container.querySelector(
262
+ "[data-page-flip-wrapper]",
263
+ ) as HTMLElement;
264
+ wrapper.focus();
265
+
266
+ const endChange = new Promise<number>((resolve) => {
267
+ const off = f.on("change", (cur) => {
268
+ off();
269
+ resolve(cur);
270
+ });
271
+ });
272
+ wrapper.dispatchEvent(
273
+ new KeyboardEvent("keydown", { key: "End", bubbles: true }),
274
+ );
275
+ expect(await endChange).toBe(3);
276
+
277
+ const homeChange = new Promise<number>((resolve) => {
278
+ const off = f.on("change", (cur) => {
279
+ off();
280
+ resolve(cur);
281
+ });
282
+ });
283
+ wrapper.dispatchEvent(
284
+ new KeyboardEvent("keydown", { key: "Home", bubbles: true }),
285
+ );
286
+ expect(await homeChange).toBe(0);
287
+ f.destroy();
288
+ });
289
+ });
290
+
291
+ describe("createFlipbook: pointer", () => {
292
+ it("click on right half triggers next; left half triggers prev", async () => {
293
+ const f = await mountReady(
294
+ [makePage("a"), makePage("b"), makePage("c")],
295
+ { initialPage: 1 },
296
+ );
297
+ const wrapper = container.querySelector(
298
+ "[data-page-flip-wrapper]",
299
+ ) as HTMLElement;
300
+ const rect = wrapper.getBoundingClientRect();
301
+
302
+ function clickAt(x: number, y: number): void {
303
+ const id = 1;
304
+ wrapper.dispatchEvent(
305
+ new PointerEvent("pointerdown", {
306
+ pointerId: id,
307
+ clientX: x,
308
+ clientY: y,
309
+ button: 0,
310
+ bubbles: true,
311
+ }),
312
+ );
313
+ wrapper.dispatchEvent(
314
+ new PointerEvent("pointerup", {
315
+ pointerId: id,
316
+ clientX: x,
317
+ clientY: y,
318
+ bubbles: true,
319
+ }),
320
+ );
321
+ }
322
+
323
+ const rightChange = new Promise<number>((resolve) => {
324
+ const off = f.on("change", (cur) => {
325
+ off();
326
+ resolve(cur);
327
+ });
328
+ });
329
+ clickAt(rect.left + rect.width * 0.85, rect.top + rect.height / 2);
330
+ expect(await rightChange).toBe(2);
331
+
332
+ const leftChange = new Promise<number>((resolve) => {
333
+ const off = f.on("change", (cur) => {
334
+ off();
335
+ resolve(cur);
336
+ });
337
+ });
338
+ clickAt(rect.left + rect.width * 0.15, rect.top + rect.height / 2);
339
+ expect(await leftChange).toBe(1);
340
+
341
+ f.destroy();
342
+ });
343
+
344
+ it("drag past 50% commits forward; release under 50% reverts", async () => {
345
+ const f = await mountReady([makePage("a"), makePage("b"), makePage("c")]);
346
+ const wrapper = container.querySelector(
347
+ "[data-page-flip-wrapper]",
348
+ ) as HTMLElement;
349
+ const rect = wrapper.getBoundingClientRect();
350
+
351
+ function dragRelease(dx: number): Promise<number | null> {
352
+ return new Promise((resolve) => {
353
+ const id = 2;
354
+ const startX = rect.left + rect.width * 0.7;
355
+ const y = rect.top + rect.height / 2;
356
+ let resolved = false;
357
+ const off = f.on("change", (cur) => {
358
+ off();
359
+ resolved = true;
360
+ resolve(cur);
361
+ });
362
+ wrapper.dispatchEvent(
363
+ new PointerEvent("pointerdown", {
364
+ pointerId: id,
365
+ clientX: startX,
366
+ clientY: y,
367
+ button: 0,
368
+ bubbles: true,
369
+ }),
370
+ );
371
+ wrapper.dispatchEvent(
372
+ new PointerEvent("pointermove", {
373
+ pointerId: id,
374
+ clientX: startX + dx,
375
+ clientY: y,
376
+ bubbles: true,
377
+ }),
378
+ );
379
+ wrapper.dispatchEvent(
380
+ new PointerEvent("pointerup", {
381
+ pointerId: id,
382
+ clientX: startX + dx,
383
+ clientY: y,
384
+ bubbles: true,
385
+ }),
386
+ );
387
+ // Resolve null after a short window if no change fires (revert path).
388
+ setTimeout(() => {
389
+ if (!resolved) {
390
+ off();
391
+ resolve(null);
392
+ }
393
+ }, 80);
394
+ });
395
+ }
396
+
397
+ // Drag left by 80% of width — should commit forward.
398
+ const after = await dragRelease(-rect.width * 0.8);
399
+ expect(after).toBe(1);
400
+ expect(f.current).toBe(1);
401
+
402
+ // Drag left by 20% — should revert. f.current stays 1.
403
+ const noop = await dragRelease(-rect.width * 0.2);
404
+ expect(noop).toBeNull();
405
+ expect(f.current).toBe(1);
406
+
407
+ f.destroy();
408
+ });
409
+
410
+ it("drag at first page in backward direction is a no-op (loop=false)", async () => {
411
+ const f = await mountReady([makePage("a"), makePage("b")]);
412
+ const wrapper = container.querySelector(
413
+ "[data-page-flip-wrapper]",
414
+ ) as HTMLElement;
415
+ const rect = wrapper.getBoundingClientRect();
416
+ const id = 3;
417
+ wrapper.dispatchEvent(
418
+ new PointerEvent("pointerdown", {
419
+ pointerId: id,
420
+ clientX: rect.left + rect.width / 2,
421
+ clientY: rect.top + rect.height / 2,
422
+ button: 0,
423
+ bubbles: true,
424
+ }),
425
+ );
426
+ wrapper.dispatchEvent(
427
+ new PointerEvent("pointermove", {
428
+ pointerId: id,
429
+ clientX: rect.left + rect.width * 1.2,
430
+ clientY: rect.top + rect.height / 2,
431
+ bubbles: true,
432
+ }),
433
+ );
434
+ wrapper.dispatchEvent(
435
+ new PointerEvent("pointerup", {
436
+ pointerId: id,
437
+ clientX: rect.left + rect.width * 1.2,
438
+ clientY: rect.top + rect.height / 2,
439
+ bubbles: true,
440
+ }),
441
+ );
442
+ // No flip should commit; current stays at 0.
443
+ await new Promise((r) => setTimeout(r, 60));
444
+ expect(f.current).toBe(0);
445
+ f.destroy();
446
+ });
447
+ });
448
+
449
+ describe("createFlipbook: destroy", () => {
450
+ it("removes the wrapper from the container", async () => {
451
+ const f = await mountReady([makePage("red"), makePage("green")]);
452
+ expect(container.querySelector("[data-page-flip-wrapper]")).toBeTruthy();
453
+ f.destroy();
454
+ expect(container.querySelector("[data-page-flip-wrapper]")).toBeNull();
455
+ });
456
+
457
+ it("stops delivering events after destroy", async () => {
458
+ const f = await mountReady([makePage("red"), makePage("green")]);
459
+ const seen: number[] = [];
460
+ f.on("change", (c) => seen.push(c));
461
+ f.destroy();
462
+ await f.next().catch(() => {});
463
+ expect(seen).toEqual([]);
464
+ });
465
+ });