leafthrough 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  A 3D magazine you can flip through. Pages are printed paper: they bend, roll, catch the
4
4
  light and cast shadows. Built on three.js, written in TypeScript, with bindings for React,
5
- Vue, Svelte, and a Web Component for everything else (Angular, Solid, Lit, plain HTML).
5
+ Vue, Svelte, and a Web Component for everything else (Angular, Solid, Lit, plain HTML). It
6
+ can also be [one object in your own three.js scene](#in-your-own-threejs-scene).
6
7
 
7
8
  **[Live demo](https://dmtrbrl.github.io/leafthrough/)**, with one page per binding and a dev panel
8
- for every option, method and event.
9
+ for every option, method and event, and
10
+ [a three.js scene](https://dmtrbrl.github.io/leafthrough/three.html) with the magazine in it.
9
11
 
10
12
  - **Two layouts.** `spread` is an open magazine with sheets turning over the spine.
11
13
  `single` shows one page at a time, and read pages roll up behind the magazine. `auto`
@@ -136,6 +138,76 @@ error trackers listen. To register the element under another tag, call
136
138
 
137
139
  **Solid**: `<leaf-through prop:pages={pages()} on:pagechange={(e) => setPage(e.detail.page)} />`.
138
140
 
141
+ ## In your own three.js scene
142
+
143
+ `LeafThroughObject` is the magazine as a `THREE.Group`, for a scene you already have. It
144
+ draws with your renderer and takes pointer input through your camera, so it turns wherever
145
+ it lies, however it's moved, turned or scaled. Every option, method, property and event of
146
+ `LeafThrough` works the same, apart from those about its own canvas and DOM: the options
147
+ `bleed`, `maxPixelRatio`, `plain`, `label` and `formatPageLabel`, and the properties
148
+ `plain` and `element`.
149
+
150
+ ```ts
151
+ import { LeafThroughObject } from 'leafthrough'
152
+
153
+ const magazine = new LeafThroughObject({ renderer, camera }, { pages, layout: 'spread' })
154
+ magazine.rotation.x = -Math.PI / 2 // lay it on the table
155
+ magazine.scale.setScalar(0.21) // a page 21 cm wide, in a scene measured in metres
156
+ scene.add(magazine)
157
+
158
+ renderer.setAnimationLoop((now) => {
159
+ magazine.tick(now) // advance the pages, then draw as usual
160
+ renderer.render(scene, camera)
161
+ })
162
+ ```
163
+
164
+ - **Units.** A page is 1 wide and `pageAspect` tall. The spine runs up the y axis at
165
+ x = 0 and the pages lie on the z = 0 plane, facing +z. A spread closed on its front
166
+ cover shows it at x 0 to 1, and closed on its back cover at x -1 to 0.
167
+ `magazine.bounds.pages` is the pages at rest, the rectangle to frame with your camera;
168
+ `magazine.bounds.reach` is everything a turning page sweeps through.
169
+ - **Input.** The magazine listens for pointer events on `renderer.domElement` (or the
170
+ `domElement` you pass, which must show exactly the camera's view). It takes presses in
171
+ its hit area: the pages in `spread` (only the cover's side while closed), and in
172
+ `single` a box round the page with a strip left of the spine for turning back. It
173
+ doesn't look at your scene, so objects in front of the magazine don't block it.
174
+ `magazine.covers(clientX, clientY)` tells you whether it takes a press there; check that
175
+ in your own handlers to leave such presses alone. Hide the magazine (`visible = false`,
176
+ on it or a parent) and it takes none. With `interaction.keyboard: true` the keys work
177
+ while that element has focus; a canvas only takes focus with a `tabIndex`, so give it
178
+ one or use `'global'`. Drags scale with the magazine's size on screen, so they feel the
179
+ same near or far. For touch drags, give the element `touch-action: pan-y pinch-zoom`
180
+ (or `none`), so the browser doesn't take them for scrolling.
181
+ - **Light.** The paper is a `MeshStandardMaterial`, lit by your scene's lights,
182
+ environment, fog and tone mapping. Of the `lighting` options, `shadows` makes the
183
+ sheets cast and receive shadows when your renderer draws them, and `sun.position`
184
+ should point towards your shadow-casting directional light, in the magazine's own
185
+ frame (only the direction counts). It places the shadow a turning sheet casts right by
186
+ the spine, where the shadow map can't.
187
+ - **Rendering on demand.** If you only draw when something changes, listen for `change`:
188
+ it fires when input, loading or a method call needs a frame. Then keep drawing while
189
+ `tick()` returns true.
190
+ - **Accessibility is yours.** With no DOM of its own, it has no live region, no page
191
+ descriptions and no plain view: pages' `alt` and `text` aren't read. Describe the pages
192
+ in your own page, and announce `pagechange` if readers should hear it.
193
+
194
+ ```ts
195
+ magazine.tick(now) // advance; true while the pages still move
196
+ magazine.covers(e.clientX, e.clientY) // whether the magazine takes a press there
197
+ magazine.bounds // { pages, reach }, in the magazine's own units
198
+ magazine.camera = otherCamera // cast input from another camera
199
+ magazine.enabled = false // cancel active gestures and stop taking pointer/key input
200
+ magazine.addEventListener('change', () => requestRender())
201
+ magazine.addEventListener('pagechange', (e) => e.detail) // also ready, pageload, error
202
+ magazine.dispose() // free everything and leave the scene
203
+ ```
204
+
205
+ The [three.js demo](https://dmtrbrl.github.io/leafthrough/three.html) takes a magazine
206
+ out of a portrait paperboard mailer on a table; its source is
207
+ [`demo/main-three.ts`](https://github.com/dmtrbrl/leafthrough/blob/main/demo/main-three.ts),
208
+ with the native three.js mailer geometry in
209
+ [`demo/envelope.ts`](https://github.com/dmtrbrl/leafthrough/blob/main/demo/envelope.ts).
210
+
139
211
  ## Sizing
140
212
 
141
213
  The magazine fills the element you give it and keeps its proportions, centred.
@@ -188,6 +260,7 @@ The magazine fills the element you give it and keeps its proportions, centred.
188
260
  | `backColor` | `#fcfcf9` | Unprinted paper: the blank back of single-layout pages, and transparent parts of page images. |
189
261
  | `wrinkles` | `1` | Waviness of resting sheets; `0` lays them flat. |
190
262
  | `bulge` | `1` | How much pages rise off the spine. |
263
+ | `cutout` | `false` | Cuts each sheet to the shape of its front page: transparent areas are no paper at all, shadow included. |
191
264
 
192
265
  **`lighting`**: `ambient: { sky, ground, intensity }`, `sun: { color, intensity, position }`,
193
266
  `shadows` (default `true`), `shadowMapSize` (default `2048`), and `floorShadow`. That last
@@ -264,7 +337,8 @@ pages: [
264
337
  readers get both, and changing them doesn't reload anything.
265
338
  - **Transparent artwork.** PNG and WebP alpha is composited onto the paper: transparent
266
339
  areas are unprinted paper in `paper.backColor`, including when the reverse side shows
267
- through.
340
+ through. With `paper.cutout`, they're cut away instead, so a sheet can be any shape: its
341
+ front page's outline (draw the back page as its mirror image).
268
342
  - **Cross-origin URLs.** URLs on another origin need CORS headers, because the images are
269
343
  uploaded to WebGL.
270
344
  - **Textures.** A three.js `Texture` you pass in is never disposed, but the magazine sets its
@@ -292,7 +366,8 @@ off screen or the tab is hidden.
292
366
  The 3D canvas is hidden from screen readers, so give pages an `alt` and their `text` (see
293
367
  [Pages](#pages)). The magazine keeps the visible pages' descriptions and text in the page,
294
368
  visually hidden, where screen readers can read them; each page is a `figure` named by
295
- `formatPageLabel`.
369
+ `formatPageLabel`. `LeafThroughObject` has no DOM, so none of this comes with it (see
370
+ [In your own three.js scene](#in-your-own-threejs-scene)).
296
371
 
297
372
  **Plain view.** `plain: true` shows the pages as flat images, with the same layouts,
298
373
  navigation, keyboard and events, and no 3D. Clicking the right half turns forward, the left
@@ -303,7 +378,7 @@ magazine also switches to it by itself when the browser can't run WebGL.
303
378
 
304
379
  ```bash
305
380
  npm install
306
- npm run dev # demo playground: a page per binding, each with the dev panel
381
+ npm run dev # demo playground: a page per binding, each with the dev panel, and a three.js scene
307
382
  npm run typecheck
308
383
  npm test # browser tests in headless Chromium (first run: npx playwright install chromium)
309
384
  npm run build # dist/: ESM bundles + type declarations
@@ -329,7 +404,12 @@ How it works:
329
404
  - `textures.ts` loads, prioritises and unloads page textures.
330
405
  - `src/paper` holds the shared paper material and the procedural grain.
331
406
  - `LeafThrough.ts` handles the DOM, sizing, loading, layout switching, keyboard and
332
- events.
407
+ events. `LeafThroughObject.ts` does the same inside someone else's scene, without the DOM.
408
+ - Engines take input through `EngineInput` (`engine.ts`): an element to listen on, a ray
409
+ for a point on screen, and whether a press lands on the pages. They meet the ray with
410
+ their own floor plane, so the same code serves the magazine's own camera and any scene's.
411
+ - `navigation.ts` maps pages to engine positions, riffles an animated `goTo()` and binds
412
+ the keyboard, for both.
333
413
 
334
414
  ## Credits
335
415
 
@@ -338,4 +418,4 @@ The page deformation and interaction model are reverse-engineered from the magaz
338
418
 
339
419
  ## License
340
420
 
341
- [MIT](LICENSE)
421
+ [MIT](https://github.com/dmtrbrl/leafthrough/blob/main/LICENSE)
@@ -36,9 +36,7 @@ export declare class LeafThrough extends EventTarget {
36
36
  private readonly content;
37
37
  private readonly renderer;
38
38
  private frameRequest;
39
- private readonly blank;
40
- private readonly backBlank;
41
- private pattern;
39
+ private readonly paper;
42
40
  private loader;
43
41
  private view;
44
42
  private spreadMedia;
@@ -52,7 +50,7 @@ export declare class LeafThrough extends EventTarget {
52
50
  private visible;
53
51
  private needsRender;
54
52
  private running;
55
- private riffle;
53
+ private readonly riffle;
56
54
  private lastPagesKey;
57
55
  private ready;
58
56
  private disposed;
@@ -93,10 +91,8 @@ export declare class LeafThrough extends EventTarget {
93
91
  private remount;
94
92
  private createPlain;
95
93
  private createScene;
96
- private patternFor;
97
94
  private layoutBox;
98
95
  private pagesKey;
99
- private preloadRadius;
100
96
  private loadPages;
101
97
  private inView;
102
98
  private followReader;
@@ -104,7 +100,6 @@ export declare class LeafThrough extends EventTarget {
104
100
  private pageLoaded;
105
101
  private fail;
106
102
  private pageToPosition;
107
- private positionToPage;
108
103
  private pagesAt;
109
104
  private detail;
110
105
  private lastDetailKey;
@@ -112,8 +107,6 @@ export declare class LeafThrough extends EventTarget {
112
107
  private syncContent;
113
108
  private applyLabel;
114
109
  private bindKeyboard;
115
- private stopRiffle;
116
- private stepRiffle;
117
110
  private syncLoop;
118
111
  private setLoop;
119
112
  private placeHitArea;
@@ -0,0 +1,152 @@
1
+ import * as THREE from 'three';
2
+ import { type Rect } from './framing.js';
3
+ import type { GoToOptions } from './LeafThrough.js';
4
+ import { type LeafThroughOptions, type Layout, type PageChangeDetail } from './options.js';
5
+ /**
6
+ * The magazine's options, less those about its own canvas and DOM. Of `lighting`, only
7
+ * `shadows` (whether sheets cast and receive them) and `sun.position` apply: the lights are
8
+ * your scene's. Point `sun.position` towards your shadow-casting directional light, in the
9
+ * magazine's own frame; it places the shadow a turning sheet casts right by the spine.
10
+ */
11
+ export type LeafThroughObjectOptions = Omit<LeafThroughOptions, 'bleed' | 'maxPixelRatio' | 'plain' | 'label' | 'formatPageLabel'>;
12
+ /** What the magazine draws with, from your scene. */
13
+ export interface LeafThroughObjectContext {
14
+ /** The renderer drawing your scene. Pages load into it. */
15
+ renderer: THREE.WebGLRenderer;
16
+ /** The camera your scene is drawn with. Pointer input is cast from it; change it later through `camera`. */
17
+ camera: THREE.Camera;
18
+ /**
19
+ * Takes pointer input, and keys with `interaction.keyboard: true` while it has focus. It must
20
+ * show the camera's whole view. Default `renderer.domElement`.
21
+ */
22
+ domElement?: HTMLElement;
23
+ }
24
+ export interface LeafThroughObjectEventMap extends THREE.Object3DEventMap {
25
+ pagechange: {
26
+ detail: PageChangeDetail;
27
+ };
28
+ ready: {};
29
+ pageload: {
30
+ detail: {
31
+ page: number;
32
+ };
33
+ };
34
+ error: {
35
+ detail: {
36
+ error: unknown;
37
+ page?: number;
38
+ };
39
+ };
40
+ /** Something changed between frames: draw one, and keep drawing while `tick()` returns true. */
41
+ change: {};
42
+ }
43
+ /** Where the magazine lies in its own units. */
44
+ export interface LeafThroughBounds {
45
+ /** The pages at rest: what to frame. */
46
+ pages: Rect;
47
+ /** Everything a turning page can sweep through. */
48
+ reach: Rect;
49
+ }
50
+ /**
51
+ * A 3D magazine to add to your own three.js scene. It draws nothing itself: call `tick()`
52
+ * every frame you render, and render again whenever it fires `change`.
53
+ *
54
+ * In its own units a page is 1 wide and `pageAspect` tall, the spine runs up the y axis at
55
+ * x = 0, and the pages lie on the z = 0 plane facing +z. Move, turn and scale it like any
56
+ * object. Pointer input is cast through `camera` onto the pages, wherever they are.
57
+ *
58
+ * ```ts
59
+ * const magazine = new LeafThroughObject({ renderer, camera }, { pages, layout: 'spread' })
60
+ * scene.add(magazine)
61
+ * renderer.setAnimationLoop((now) => {
62
+ * magazine.tick(now)
63
+ * renderer.render(scene, camera)
64
+ * })
65
+ * ```
66
+ */
67
+ export declare class LeafThroughObject extends THREE.Group<LeafThroughObjectEventMap> {
68
+ /** The camera your scene is drawn with; pointer input is cast from it. */
69
+ camera: THREE.Camera;
70
+ readonly domElement: HTMLElement;
71
+ private inputEnabled;
72
+ /**
73
+ * Whether it takes pointer and key input. Turn it off while it's tucked away or moving,
74
+ * and on again; the pages stay as they are. Turning it off lets go of a page being held.
75
+ * Default `true`.
76
+ */
77
+ get enabled(): boolean;
78
+ set enabled(value: boolean);
79
+ private readonly renderer;
80
+ private options;
81
+ private readonly paper;
82
+ private loader;
83
+ private view;
84
+ private spreadMedia;
85
+ private readonly motionMedia;
86
+ private readonly abort;
87
+ private readonly riffle;
88
+ private readonly metrics;
89
+ private readonly ray;
90
+ private readonly point;
91
+ private readonly ends;
92
+ private needsRender;
93
+ private lastPagesKey;
94
+ private lastDetailKey;
95
+ private inView;
96
+ private ready;
97
+ private readyFired;
98
+ private disposed;
99
+ constructor({ renderer, camera, domElement }: LeafThroughObjectContext, options: LeafThroughObjectOptions);
100
+ /** First visible page, 0-based. */
101
+ get page(): number;
102
+ get pageCount(): number;
103
+ get layout(): Layout;
104
+ get visiblePages(): number[];
105
+ /** True while a page is being dragged or is completing its turn. */
106
+ get turning(): boolean;
107
+ /**
108
+ * Where the magazine lies in its own units: frame `pages` with your camera. Changes with
109
+ * the layout, which `'auto'` can switch as the window resizes.
110
+ */
111
+ get bounds(): LeafThroughBounds;
112
+ /** Turns to the next page. Returns false at the end. */
113
+ next(): boolean;
114
+ /** Turns to the previous page. Returns false at the start. */
115
+ prev(): boolean;
116
+ /** Shows `page` (0-based), jumping there or turning the pages one by one. */
117
+ goTo(page: number, { animate }?: GoToOptions): void;
118
+ /** Loads the pages that failed again. Also happens by itself when the browser comes back online. */
119
+ retry(): void;
120
+ /** Changes some options and keeps the rest. Structural changes rebuild the pages and keep the current page. */
121
+ update(options: Partial<LeafThroughObjectOptions>): void;
122
+ /** Replaces every option at once: anything left out goes back to its default. */
123
+ setOptions(options: LeafThroughObjectOptions): void;
124
+ /**
125
+ * Whether a press at this point on screen lands on the magazine, which then takes it. Check
126
+ * it in your own pointer handlers to leave such presses alone. False while it's hidden or
127
+ * not `enabled`.
128
+ */
129
+ covers(clientX: number, clientY: number): boolean;
130
+ /**
131
+ * Advances the pages to `now` (a `requestAnimationFrame` timestamp). Call it every frame
132
+ * before rendering. Returns true while the pages are moving and need another frame.
133
+ */
134
+ tick(now?: number): boolean;
135
+ /** Frees the pages, textures and listeners, and takes the magazine out of its parent. */
136
+ dispose(): void;
137
+ private applyOptions;
138
+ private pickLayout;
139
+ private watchSpreadQuery;
140
+ private remount;
141
+ private readonly axis;
142
+ private pageAxis;
143
+ private dragWidth;
144
+ private shown;
145
+ private requestFrame;
146
+ private pagesKey;
147
+ private loadPages;
148
+ private followReader;
149
+ private fail;
150
+ private detail;
151
+ private emitPageChange;
152
+ }