leafthrough 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +338 -2
- package/dist/LeafThrough.d.ts +119 -0
- package/dist/chunk-U2OBPSZN.js +2929 -0
- package/dist/chunk-U2OBPSZN.js.map +1 -0
- package/dist/content.d.ts +42 -0
- package/dist/element.d.ts +79 -0
- package/dist/element.js +182 -0
- package/dist/element.js.map +1 -0
- package/dist/engine.d.ts +43 -0
- package/dist/framing.d.ts +48 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +193 -0
- package/dist/paper/paperMaterial.d.ts +34 -0
- package/dist/paper/paperPattern.d.ts +2 -0
- package/dist/plain/PlainEngine.d.ts +38 -0
- package/dist/react.d.ts +27 -0
- package/dist/react.js +60 -0
- package/dist/react.js.map +1 -0
- package/dist/single/SingleEngine.d.ts +70 -0
- package/dist/single/singleSheet.d.ts +23 -0
- package/dist/spread/SpreadEngine.d.ts +60 -0
- package/dist/spread/spreadSheet.d.ts +23 -0
- package/dist/svelte.d.ts +19 -0
- package/dist/svelte.js +24 -0
- package/dist/svelte.js.map +1 -0
- package/dist/textures.d.ts +50 -0
- package/dist/vue.d.ts +87 -0
- package/dist/vue.js +65 -0
- package/dist/vue.js.map +1 -0
- package/package.json +102 -4
|
@@ -0,0 +1,2929 @@
|
|
|
1
|
+
// src/options.ts
|
|
2
|
+
var DEFAULT_SPREAD_QUERY = "(min-width: 768px) and (orientation: landscape)";
|
|
3
|
+
var DEFAULT_PAGE_ASPECT = 1.377;
|
|
4
|
+
function defaultPageLabel({ visiblePages, pageCount }) {
|
|
5
|
+
if (!visiblePages.length) return `Closed, ${pageCount} pages`;
|
|
6
|
+
const [first, last] = [visiblePages[0] + 1, visiblePages[visiblePages.length - 1] + 1];
|
|
7
|
+
return first === last ? `Page ${first} of ${pageCount}` : `Pages ${first}\u2013${last} of ${pageCount}`;
|
|
8
|
+
}
|
|
9
|
+
function isPageDescriptor(page) {
|
|
10
|
+
return typeof page === "object" && page !== null && Object.getPrototypeOf(page) === Object.prototype && "src" in page;
|
|
11
|
+
}
|
|
12
|
+
var pageImage = (page) => isPageDescriptor(page) ? page.src : page;
|
|
13
|
+
function resolveSettings(options, layout, prefersReducedMotion) {
|
|
14
|
+
const spread2 = layout === "spread";
|
|
15
|
+
const paper = options.paper ?? {};
|
|
16
|
+
const lighting = options.lighting ?? {};
|
|
17
|
+
const motion = options.motion ?? {};
|
|
18
|
+
const interaction = options.interaction ?? {};
|
|
19
|
+
const reduced = motion.reducedMotion === "auto" || motion.reducedMotion === void 0 ? prefersReducedMotion : motion.reducedMotion;
|
|
20
|
+
return {
|
|
21
|
+
layout,
|
|
22
|
+
pageAspect: options.pageAspect ?? DEFAULT_PAGE_ASPECT,
|
|
23
|
+
loop: options.loop ?? true,
|
|
24
|
+
bleed: options.bleed ?? true,
|
|
25
|
+
paper: {
|
|
26
|
+
grain: paper.grain ?? true,
|
|
27
|
+
grainScale: paper.grainScale ?? 1.46,
|
|
28
|
+
grainStrength: paper.grainStrength ?? 0.17,
|
|
29
|
+
showThrough: paper.showThrough ?? (spread2 ? 0.04 : 0.3),
|
|
30
|
+
inkGloss: paper.inkGloss ?? 0.33,
|
|
31
|
+
roughness: paper.roughness ?? 0.5,
|
|
32
|
+
metalness: paper.metalness ?? 0.17,
|
|
33
|
+
backColor: paper.backColor ?? "#fcfcf9",
|
|
34
|
+
wrinkles: paper.wrinkles ?? 1,
|
|
35
|
+
bulge: paper.bulge ?? 1
|
|
36
|
+
},
|
|
37
|
+
lighting: {
|
|
38
|
+
ambient: {
|
|
39
|
+
sky: lighting.ambient?.sky ?? "#ffffff",
|
|
40
|
+
ground: lighting.ambient?.ground ?? "#a1aeaf",
|
|
41
|
+
intensity: lighting.ambient?.intensity ?? (spread2 ? 1.5 : 1.7)
|
|
42
|
+
},
|
|
43
|
+
sun: {
|
|
44
|
+
color: lighting.sun?.color ?? "#ffffff",
|
|
45
|
+
intensity: lighting.sun?.intensity ?? 2,
|
|
46
|
+
position: lighting.sun?.position ?? (spread2 ? [-3.5, 1.3, 4.1] : [0.689, -1.15, 2.15])
|
|
47
|
+
},
|
|
48
|
+
shadows: lighting.shadows ?? true,
|
|
49
|
+
shadowMapSize: lighting.shadowMapSize ?? 2048,
|
|
50
|
+
floorShadow: lighting.floorShadow ?? 0.15
|
|
51
|
+
},
|
|
52
|
+
motion: {
|
|
53
|
+
speed: Math.max(0.05, (motion.speed ?? 1) * (reduced ? 2.5 : 1)),
|
|
54
|
+
dragDistance: motion.dragDistance ?? (spread2 ? 0.8 : 1.2),
|
|
55
|
+
commitThreshold: motion.commitThreshold ?? (spread2 ? 0.2 : 0.1),
|
|
56
|
+
curl: motion.curl ?? 1,
|
|
57
|
+
maxTilt: motion.maxTilt ?? (spread2 ? 45 : 35),
|
|
58
|
+
jitter: motion.jitter ?? true,
|
|
59
|
+
hoverLift: reduced ? 0 : motion.hoverLift ?? 0.03,
|
|
60
|
+
reduced
|
|
61
|
+
},
|
|
62
|
+
interaction: {
|
|
63
|
+
drag: interaction.drag ?? true,
|
|
64
|
+
click: interaction.click ?? true,
|
|
65
|
+
hold: (interaction.hold ?? true) && !reduced,
|
|
66
|
+
hover: interaction.hover ?? true,
|
|
67
|
+
keyboard: interaction.keyboard ?? true
|
|
68
|
+
},
|
|
69
|
+
maxPixelRatio: options.maxPixelRatio ?? 2
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/LeafThrough.ts
|
|
74
|
+
import * as THREE10 from "three";
|
|
75
|
+
|
|
76
|
+
// src/content.ts
|
|
77
|
+
import * as THREE from "three";
|
|
78
|
+
var KEPT_IMAGES = 8;
|
|
79
|
+
var VISUALLY_HIDDEN = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap";
|
|
80
|
+
var PageContent = class {
|
|
81
|
+
constructor(events) {
|
|
82
|
+
this.events = events;
|
|
83
|
+
this.element.dataset.leafthroughContent = "";
|
|
84
|
+
this.element.style.cssText = VISUALLY_HIDDEN;
|
|
85
|
+
}
|
|
86
|
+
events;
|
|
87
|
+
element = document.createElement("div");
|
|
88
|
+
key = "";
|
|
89
|
+
shown = 0;
|
|
90
|
+
// plain view: recently shown page images by source and alt, oldest first
|
|
91
|
+
images = /* @__PURE__ */ new Map();
|
|
92
|
+
/** Brings the DOM up to date; cheap when nothing changed. */
|
|
93
|
+
show(state) {
|
|
94
|
+
const { pages, visiblePages, layout, plain } = state;
|
|
95
|
+
const key = [
|
|
96
|
+
plain,
|
|
97
|
+
layout,
|
|
98
|
+
state.paper,
|
|
99
|
+
...visiblePages.map((i) => {
|
|
100
|
+
const page = pages[i];
|
|
101
|
+
const info = isPageDescriptor(page) ? page : {};
|
|
102
|
+
return [i, sourceKey(pageImage(page)), info.alt, info.text, state.label(i)].join("\0");
|
|
103
|
+
})
|
|
104
|
+
].join("");
|
|
105
|
+
if (key === this.key) return;
|
|
106
|
+
this.key = key;
|
|
107
|
+
const token = ++this.shown;
|
|
108
|
+
const used = /* @__PURE__ */ new Set();
|
|
109
|
+
let pending = 0;
|
|
110
|
+
const settle = () => {
|
|
111
|
+
if (token === this.shown && --pending === 0) this.events.onShown();
|
|
112
|
+
};
|
|
113
|
+
const figures = visiblePages.flatMap((i) => {
|
|
114
|
+
const page = pages[i];
|
|
115
|
+
const { alt, text } = isPageDescriptor(page) ? page : { alt: void 0, text: void 0 };
|
|
116
|
+
if (!plain && !alt && !text) return [];
|
|
117
|
+
const figure = document.createElement("figure");
|
|
118
|
+
figure.dataset.page = String(i);
|
|
119
|
+
figure.setAttribute("aria-label", state.label(i));
|
|
120
|
+
figure.style.cssText = "margin:0";
|
|
121
|
+
if (plain) {
|
|
122
|
+
const spread2 = layout === "spread";
|
|
123
|
+
const x = spread2 && i % 2 === 0 ? "50%" : "0";
|
|
124
|
+
figure.style.cssText = `margin:0;position:absolute;top:0;left:${x};width:${spread2 ? "50%" : "100%"};height:100%;background:${state.paper};box-shadow:0 1px 6px rgba(0,0,0,.18)`;
|
|
125
|
+
const image = this.imageFor(i, pageImage(page), alt, used);
|
|
126
|
+
if (image instanceof HTMLImageElement && !image.complete) {
|
|
127
|
+
pending++;
|
|
128
|
+
image.addEventListener("load", settle, { once: true });
|
|
129
|
+
image.addEventListener("error", settle, { once: true });
|
|
130
|
+
}
|
|
131
|
+
figure.append(image);
|
|
132
|
+
} else if (alt) {
|
|
133
|
+
const image = document.createElement("span");
|
|
134
|
+
image.setAttribute("role", "img");
|
|
135
|
+
image.setAttribute("aria-label", alt);
|
|
136
|
+
figure.append(image);
|
|
137
|
+
}
|
|
138
|
+
if (text) figure.append(paragraphs(text, plain));
|
|
139
|
+
return [figure];
|
|
140
|
+
});
|
|
141
|
+
this.element.replaceChildren(...figures);
|
|
142
|
+
if (!pending) queueMicrotask(() => token === this.shown && this.events.onShown());
|
|
143
|
+
}
|
|
144
|
+
/** Rebuilds the DOM on the next show(), loading the images again. */
|
|
145
|
+
refresh() {
|
|
146
|
+
this.key = "";
|
|
147
|
+
this.images.clear();
|
|
148
|
+
}
|
|
149
|
+
// a page image for the plain view, reused if it was shown recently (and isn't already in
|
|
150
|
+
// `used`, as when two visible pages share an image)
|
|
151
|
+
imageFor(page, source, alt = "", used) {
|
|
152
|
+
const key = `${sourceKey(source)}\0${alt}`;
|
|
153
|
+
let image = this.images.get(key);
|
|
154
|
+
if (image && !used.has(image)) this.images.delete(key);
|
|
155
|
+
else {
|
|
156
|
+
const created = image = displayImage(source, alt);
|
|
157
|
+
if (created instanceof HTMLImageElement && !created.complete) {
|
|
158
|
+
created.addEventListener("load", () => this.events.onLoad(page), { once: true });
|
|
159
|
+
created.addEventListener("error", () => this.events.onError(page, new Error(`Failed to load page image: ${created.src}`)), {
|
|
160
|
+
once: true
|
|
161
|
+
});
|
|
162
|
+
} else queueMicrotask(() => this.events.onLoad(page));
|
|
163
|
+
}
|
|
164
|
+
used.add(image);
|
|
165
|
+
this.images.set(key, image);
|
|
166
|
+
if (this.images.size > KEPT_IMAGES) this.images.delete(this.images.keys().next().value);
|
|
167
|
+
return image;
|
|
168
|
+
}
|
|
169
|
+
/** Plain view: where the pages go, in px relative to the root. `null` hides the content again. */
|
|
170
|
+
place(box) {
|
|
171
|
+
if (!box) {
|
|
172
|
+
this.element.style.cssText = VISUALLY_HIDDEN;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const px = (n) => `${Math.round(n * 100) / 100}px`;
|
|
176
|
+
this.element.style.cssText = `position:absolute;left:${px(box.x)};top:${px(box.y)};width:${px(box.width)};height:${px(box.height)};pointer-events:none`;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
var ids = /* @__PURE__ */ new WeakMap();
|
|
180
|
+
var nextId = 1;
|
|
181
|
+
function sourceKey(source) {
|
|
182
|
+
if (typeof source === "string") return source;
|
|
183
|
+
let id = ids.get(source);
|
|
184
|
+
if (!id) ids.set(source, id = nextId++);
|
|
185
|
+
return `#${id}`;
|
|
186
|
+
}
|
|
187
|
+
function paragraphs(text, hidden) {
|
|
188
|
+
const block = document.createElement("div");
|
|
189
|
+
if (hidden) block.style.cssText = VISUALLY_HIDDEN;
|
|
190
|
+
for (const paragraph of text.split(/\n\s*\n/)) {
|
|
191
|
+
const p = document.createElement("p");
|
|
192
|
+
p.textContent = paragraph.trim();
|
|
193
|
+
block.append(p);
|
|
194
|
+
}
|
|
195
|
+
return block;
|
|
196
|
+
}
|
|
197
|
+
function displayImage(source, alt = "") {
|
|
198
|
+
const url = typeof source === "string" ? source : source instanceof HTMLImageElement ? source.currentSrc || source.src : null;
|
|
199
|
+
const drawable = source instanceof THREE.Texture ? source.image : source;
|
|
200
|
+
const style = "display:block;width:100%;height:100%";
|
|
201
|
+
if (url === null && drawable instanceof HTMLImageElement) return displayImage(drawable, alt);
|
|
202
|
+
if (url !== null) {
|
|
203
|
+
const img = document.createElement("img");
|
|
204
|
+
img.decoding = "async";
|
|
205
|
+
img.alt = alt;
|
|
206
|
+
img.draggable = false;
|
|
207
|
+
img.style.cssText = style;
|
|
208
|
+
img.src = url;
|
|
209
|
+
return img;
|
|
210
|
+
}
|
|
211
|
+
if (typeof HTMLCanvasElement !== "undefined" && (drawable instanceof HTMLCanvasElement || drawable instanceof ImageBitmap)) {
|
|
212
|
+
const canvas = document.createElement("canvas");
|
|
213
|
+
canvas.width = drawable.width;
|
|
214
|
+
canvas.height = drawable.height;
|
|
215
|
+
canvas.getContext("2d")?.drawImage(drawable, 0, 0);
|
|
216
|
+
return describe(canvas, alt, style);
|
|
217
|
+
}
|
|
218
|
+
return describe(document.createElement("div"), alt, style);
|
|
219
|
+
}
|
|
220
|
+
function describe(element, alt, style) {
|
|
221
|
+
element.style.cssText = style;
|
|
222
|
+
if (alt) {
|
|
223
|
+
element.setAttribute("role", "img");
|
|
224
|
+
element.setAttribute("aria-label", alt);
|
|
225
|
+
} else element.setAttribute("aria-hidden", "true");
|
|
226
|
+
return element;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/framing.ts
|
|
230
|
+
var REFERENCE_ASPECT = 1.377;
|
|
231
|
+
var SPREAD_OVERSCAN = 0.27975;
|
|
232
|
+
function framingFor(layout, aspect) {
|
|
233
|
+
const k = aspect / REFERENCE_ASPECT;
|
|
234
|
+
if (layout === "spread") {
|
|
235
|
+
const box2 = { x: -1, y: -aspect / 2, width: 2, height: aspect };
|
|
236
|
+
const view2 = {
|
|
237
|
+
x: box2.x - SPREAD_OVERSCAN * box2.width,
|
|
238
|
+
y: box2.y - SPREAD_OVERSCAN * box2.height,
|
|
239
|
+
width: box2.width * (1 + 2 * SPREAD_OVERSCAN),
|
|
240
|
+
height: box2.height * (1 + 2 * SPREAD_OVERSCAN)
|
|
241
|
+
};
|
|
242
|
+
const distance = view2.height / 2 / Math.tan(20 * Math.PI / 180);
|
|
243
|
+
return {
|
|
244
|
+
box: box2,
|
|
245
|
+
view: view2,
|
|
246
|
+
eye: { x: 0, y: 0 },
|
|
247
|
+
distance,
|
|
248
|
+
dragWidth: view2.width,
|
|
249
|
+
shadow: { left: -0.8, right: 1, top: 0.85 * k, bottom: -1.05 * k }
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const box = { x: -0.34, y: -1.2 * k, width: 1.56, height: 2.4 * k };
|
|
253
|
+
const view = { x: -1.15, y: box.y, width: box.x + box.width + 1.15, height: box.height };
|
|
254
|
+
return {
|
|
255
|
+
box,
|
|
256
|
+
view,
|
|
257
|
+
eye: { x: box.x + box.width / 2, y: 0 },
|
|
258
|
+
distance: 3.1 * k,
|
|
259
|
+
dragWidth: box.width,
|
|
260
|
+
shadow: { left: -1.1, right: 1.1, top: 0.8 * k, bottom: -0.8 * k }
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function plainFramingFor(layout, aspect) {
|
|
264
|
+
const box = layout === "spread" ? { x: -1, y: -aspect / 2, width: 2, height: aspect } : { x: 0, y: -aspect / 2, width: 1, height: aspect };
|
|
265
|
+
return {
|
|
266
|
+
box,
|
|
267
|
+
view: box,
|
|
268
|
+
eye: { x: box.x + box.width / 2, y: 0 },
|
|
269
|
+
distance: 1,
|
|
270
|
+
dragWidth: box.width,
|
|
271
|
+
shadow: { left: 0, right: 0, top: 0, bottom: 0 }
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function fit(framing, width, height, bleed) {
|
|
275
|
+
const fitted = bleed ? framing.box : framing.view;
|
|
276
|
+
const scale = height > 0 ? Math.min(width / fitted.width, height / fitted.height) : width / fitted.width;
|
|
277
|
+
const boxHeight = height > 0 ? height : fitted.height * scale;
|
|
278
|
+
const left = (width - fitted.width * scale) / 2 - fitted.x * scale;
|
|
279
|
+
const top = (boxHeight - fitted.height * scale) / 2 + (fitted.y + fitted.height) * scale;
|
|
280
|
+
const toPx = (r) => ({
|
|
281
|
+
x: left + r.x * scale,
|
|
282
|
+
y: top - (r.y + r.height) * scale,
|
|
283
|
+
width: r.width * scale,
|
|
284
|
+
height: r.height * scale
|
|
285
|
+
});
|
|
286
|
+
return { scale, view: toPx(framing.view), box: toPx(framing.box), height: boxHeight };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/paper/paperMaterial.ts
|
|
290
|
+
import * as THREE2 from "three";
|
|
291
|
+
var glslFloat = (n) => Number.isInteger(n) ? `${n}.0` : `${n}`;
|
|
292
|
+
function createPaperMaterials(uniforms, shader, settings) {
|
|
293
|
+
const { paper, pageAspect } = settings;
|
|
294
|
+
const blank = new THREE2.Color(paper.backColor);
|
|
295
|
+
const constants = (
|
|
296
|
+
/* glsl */
|
|
297
|
+
`
|
|
298
|
+
const float SHEET_ASPECT = ${glslFloat(pageAspect)};
|
|
299
|
+
const float NORMAL_EPSILON = ${glslFloat(shader.epsilon)};
|
|
300
|
+
const float PATTERN_SIZE = ${glslFloat(paper.grainScale)};
|
|
301
|
+
const float PATTERN_DARKEN = ${glslFloat(paper.grain === false ? 0 : paper.grainStrength)};
|
|
302
|
+
const float PATTERN_ROUGHNESS = 0.33;
|
|
303
|
+
const float SHOW_THROUGH = ${glslFloat(paper.showThrough)};
|
|
304
|
+
const float INK_GLOSS = ${glslFloat(paper.inkGloss)};
|
|
305
|
+
const float SHADOW_OFFSET_X = ${glslFloat(shader.shadowOffsetX)};
|
|
306
|
+
const float CONTACT_REACH = 0.04;
|
|
307
|
+
const vec3 BLANK_PAPER = vec3(${glslFloat(blank.r)}, ${glslFloat(blank.g)}, ${glslFloat(blank.b)});
|
|
308
|
+
`
|
|
309
|
+
);
|
|
310
|
+
const noiseAt = (component) => shader.noise ? `aNoise.${component}` : "0.0";
|
|
311
|
+
const material = new THREE2.MeshStandardMaterial({
|
|
312
|
+
side: THREE2.DoubleSide,
|
|
313
|
+
metalness: paper.metalness,
|
|
314
|
+
roughness: paper.roughness
|
|
315
|
+
});
|
|
316
|
+
material.onBeforeCompile = (program) => {
|
|
317
|
+
Object.assign(program.uniforms, uniforms);
|
|
318
|
+
program.vertexShader = constants + shader.vertexChunk + "varying vec2 vSheetUv;\nvarying vec3 vSheetPosition;\n" + program.vertexShader;
|
|
319
|
+
program.vertexShader = program.vertexShader.replace(
|
|
320
|
+
"#include <beginnormal_vertex>",
|
|
321
|
+
`#include <beginnormal_vertex>
|
|
322
|
+
vec2 sheetStep = vec2(NORMAL_EPSILON, NORMAL_EPSILON / SHEET_ASPECT);
|
|
323
|
+
vec3 sheetP = sheetPosition(uv, ${noiseAt("x")});
|
|
324
|
+
vec3 sheetPx = sheetPosition(uv + vec2(sheetStep.x, 0.0), ${noiseAt("y")});
|
|
325
|
+
vec3 sheetPy = sheetPosition(uv + vec2(0.0, sheetStep.y), ${noiseAt("z")});
|
|
326
|
+
objectNormal = normalize(cross(sheetPx - sheetP, sheetPy - sheetP));`
|
|
327
|
+
).replace(
|
|
328
|
+
"#include <begin_vertex>",
|
|
329
|
+
`vec3 transformed = sheetP;
|
|
330
|
+
vSheetUv = uv;
|
|
331
|
+
vSheetPosition = sheetP;`
|
|
332
|
+
);
|
|
333
|
+
program.fragmentShader = constants + `uniform sampler2D uFront;
|
|
334
|
+
uniform sampler2D uBack;
|
|
335
|
+
uniform sampler2D uPattern;
|
|
336
|
+
uniform vec3 uSpineContact;
|
|
337
|
+
uniform vec3 uLightDirection;
|
|
338
|
+
varying vec2 vSheetUv;
|
|
339
|
+
varying vec3 vSheetPosition;
|
|
340
|
+
${shader.fragmentChunk ?? ""}
|
|
341
|
+
|
|
342
|
+
// Right where a turning sheet leaves the spine it all but touches the pages under it, and
|
|
343
|
+
// the shadow map's depth bias can't tell them apart: a thin strip next to the spine stays
|
|
344
|
+
// lit, which reads as a light seam. There the sheet is a plane through the spine
|
|
345
|
+
// (uSpineContact.xy is its direction across, z says whether a sheet is turning), so test
|
|
346
|
+
// the way to the light against that plane directly.
|
|
347
|
+
float sheetContactShadow(vec3 p, float intensity) {
|
|
348
|
+
if (uSpineContact.z < 0.5) return 1.0;
|
|
349
|
+
vec3 across = vec3(uSpineContact.x, 0.0, uSpineContact.y);
|
|
350
|
+
vec3 normal = vec3(-uSpineContact.y, 0.0, uSpineContact.x);
|
|
351
|
+
float facing = dot(normal, uLightDirection);
|
|
352
|
+
if (abs(facing) < 1e-4) return 1.0;
|
|
353
|
+
float t = -dot(normal, p) / facing;
|
|
354
|
+
if (t <= 1e-4) return 1.0;
|
|
355
|
+
vec3 hit = p + t * uLightDirection;
|
|
356
|
+
float along = dot(hit, across);
|
|
357
|
+
if (along <= 0.0 || along > CONTACT_REACH || abs(hit.y) > SHEET_ASPECT * 0.5) return 1.0;
|
|
358
|
+
return 1.0 - intensity;
|
|
359
|
+
}
|
|
360
|
+
` + program.fragmentShader;
|
|
361
|
+
program.fragmentShader = program.fragmentShader.replace(
|
|
362
|
+
"#include <clipping_planes_fragment>",
|
|
363
|
+
`#include <clipping_planes_fragment>
|
|
364
|
+
${shader.fragmentChunk ? "if (sheetHidden(vSheetPosition)) discard;" : ""}`
|
|
365
|
+
).replace(
|
|
366
|
+
"#include <normal_fragment_begin>",
|
|
367
|
+
`vec3 normal = normalize(vNormal);
|
|
368
|
+
float faceDirection = normal.z >= 0.0 ? 1.0 : -1.0;
|
|
369
|
+
normal *= faceDirection;
|
|
370
|
+
vec3 nonPerturbedNormal = normal;`
|
|
371
|
+
).replace(
|
|
372
|
+
"#include <map_fragment>",
|
|
373
|
+
`vec4 frontColor = texture2D(uFront, vSheetUv);
|
|
374
|
+
vec4 backColor = texture2D(uBack, vec2(1.0 - vSheetUv.x, vSheetUv.y));
|
|
375
|
+
// Transparent artwork is unprinted paper, not a hole in the sheet. Page images are
|
|
376
|
+
// premultiplied, so this is "over" compositing onto the paper colour.
|
|
377
|
+
frontColor = vec4(frontColor.rgb + BLANK_PAPER * (1.0 - frontColor.a), 1.0);
|
|
378
|
+
backColor = vec4(backColor.rgb + BLANK_PAPER * (1.0 - backColor.a), 1.0);
|
|
379
|
+
vec4 faceColor = gl_FrontFacing ? frontColor : backColor;
|
|
380
|
+
vec4 otherColor = gl_FrontFacing ? backColor : frontColor;
|
|
381
|
+
// thin paper lets some of the other side through
|
|
382
|
+
vec4 sheetColor = faceColor;
|
|
383
|
+
sheetColor.rgb *= mix(vec3(1.0), otherColor.rgb, SHOW_THROUGH);
|
|
384
|
+
diffuseColor *= sheetColor;
|
|
385
|
+
float inkAmount = 1.0 - dot(faceColor.rgb, vec3(0.299, 0.587, 0.114));`
|
|
386
|
+
).replace("#include <lights_fragment_begin>", withContactShadow(THREE2.ShaderChunk.lights_fragment_begin)).replace(
|
|
387
|
+
"#include <roughnessmap_fragment>",
|
|
388
|
+
`#include <roughnessmap_fragment>
|
|
389
|
+
// ink sits glossier than bare paper; fibres are rougher
|
|
390
|
+
roughnessFactor *= 1.0 - inkAmount * INK_GLOSS;
|
|
391
|
+
float pattern = texture2D(uPattern, vSheetUv * PATTERN_SIZE * vec2(1.0, SHEET_ASPECT)).r;
|
|
392
|
+
pattern = 2.0 * pow(pattern, 7.0);
|
|
393
|
+
roughnessFactor = clamp(mix(roughnessFactor, 1.0, clamp(pattern * PATTERN_ROUGHNESS, 0.0, 1.0)), 0.0, 1.0);`
|
|
394
|
+
).replace(
|
|
395
|
+
"#include <opaque_fragment>",
|
|
396
|
+
`#include <opaque_fragment>
|
|
397
|
+
gl_FragColor.rgb = mix(gl_FragColor.rgb, vec3(0.0), pattern * PATTERN_DARKEN);`
|
|
398
|
+
);
|
|
399
|
+
};
|
|
400
|
+
const depthMaterial = new THREE2.MeshDepthMaterial({
|
|
401
|
+
depthPacking: THREE2.RGBADepthPacking,
|
|
402
|
+
side: THREE2.DoubleSide
|
|
403
|
+
});
|
|
404
|
+
depthMaterial.onBeforeCompile = (program) => {
|
|
405
|
+
Object.assign(program.uniforms, uniforms);
|
|
406
|
+
program.vertexShader = constants + shader.vertexChunk + program.vertexShader;
|
|
407
|
+
program.vertexShader = program.vertexShader.replace(
|
|
408
|
+
"#include <begin_vertex>",
|
|
409
|
+
`vec3 transformed = sheetPosition(uv, ${noiseAt("x")}) - vec3(SHADOW_OFFSET_X, 0.0, 0.0);`
|
|
410
|
+
);
|
|
411
|
+
};
|
|
412
|
+
return { material, depthMaterial };
|
|
413
|
+
}
|
|
414
|
+
var DIRECTIONAL_SHADOW = "getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowIntensity, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] )";
|
|
415
|
+
function withContactShadow(chunk) {
|
|
416
|
+
if (!chunk.includes(DIRECTIONAL_SHADOW)) return chunk;
|
|
417
|
+
return chunk.replace(
|
|
418
|
+
DIRECTIONAL_SHADOW,
|
|
419
|
+
`min( ${DIRECTIONAL_SHADOW}, sheetContactShadow( vSheetPosition, directionalLightShadow.shadowIntensity ) )`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
function createSheetMesh(geometry, uniforms, shader, settings) {
|
|
423
|
+
const { material, depthMaterial } = createPaperMaterials(uniforms, shader, settings);
|
|
424
|
+
const mesh = new THREE2.Mesh(geometry, material);
|
|
425
|
+
mesh.customDepthMaterial = depthMaterial;
|
|
426
|
+
mesh.castShadow = settings.lighting.shadows;
|
|
427
|
+
mesh.receiveShadow = settings.lighting.shadows;
|
|
428
|
+
mesh.frustumCulled = false;
|
|
429
|
+
return mesh;
|
|
430
|
+
}
|
|
431
|
+
function layerSheet(mesh, depth, count, moving) {
|
|
432
|
+
const material = mesh.material;
|
|
433
|
+
material.polygonOffset = true;
|
|
434
|
+
material.polygonOffsetFactor = 0;
|
|
435
|
+
material.polygonOffsetUnits = moving ? -(count + 4) : -(count - Math.min(depth, count));
|
|
436
|
+
mesh.renderOrder = depth;
|
|
437
|
+
}
|
|
438
|
+
function disposeSheetMesh(mesh) {
|
|
439
|
+
mesh.material.dispose();
|
|
440
|
+
mesh.customDepthMaterial?.dispose();
|
|
441
|
+
}
|
|
442
|
+
function solidTexture(color) {
|
|
443
|
+
const c = new THREE2.Color(color);
|
|
444
|
+
const srgb = c.clone().convertLinearToSRGB();
|
|
445
|
+
const texture = new THREE2.DataTexture(
|
|
446
|
+
new Uint8Array([Math.round(srgb.r * 255), Math.round(srgb.g * 255), Math.round(srgb.b * 255), 255]),
|
|
447
|
+
1,
|
|
448
|
+
1
|
|
449
|
+
);
|
|
450
|
+
texture.colorSpace = THREE2.SRGBColorSpace;
|
|
451
|
+
texture.needsUpdate = true;
|
|
452
|
+
return texture;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/paper/paperPattern.ts
|
|
456
|
+
import * as THREE3 from "three";
|
|
457
|
+
var SIZE = 384;
|
|
458
|
+
var KERNEL = [
|
|
459
|
+
[1, 0.108, -0.049, 0.022],
|
|
460
|
+
[0.131, 0.016, -0.014, 8e-3],
|
|
461
|
+
[-0.039, -0.018, 0, 9e-3],
|
|
462
|
+
[0.021, 5e-3, 4e-3, 9e-3]
|
|
463
|
+
];
|
|
464
|
+
var CLOUD = 0.07;
|
|
465
|
+
var CLOUD_PERIOD = 32;
|
|
466
|
+
var LEVELS = [
|
|
467
|
+
[0, 0.537],
|
|
468
|
+
[1e-3, 0.624],
|
|
469
|
+
[0.01, 0.675],
|
|
470
|
+
[0.05, 0.714],
|
|
471
|
+
[0.1, 0.733],
|
|
472
|
+
[0.25, 0.761],
|
|
473
|
+
[0.5, 0.788],
|
|
474
|
+
[0.75, 0.816],
|
|
475
|
+
[0.9, 0.835],
|
|
476
|
+
[0.95, 0.847],
|
|
477
|
+
[0.99, 0.867],
|
|
478
|
+
[0.999, 0.89],
|
|
479
|
+
[1, 0.914]
|
|
480
|
+
];
|
|
481
|
+
function periodicNoise(period, random) {
|
|
482
|
+
const grid = new Float32Array(period * period);
|
|
483
|
+
for (let i = 0; i < grid.length; i++) grid[i] = random();
|
|
484
|
+
const at = (x, y) => grid[(y % period + period) % period * period + (x % period + period) % period];
|
|
485
|
+
return (x, y) => {
|
|
486
|
+
const xi = Math.floor(x);
|
|
487
|
+
const yi = Math.floor(y);
|
|
488
|
+
const fx = x - xi;
|
|
489
|
+
const fy = y - yi;
|
|
490
|
+
const sx = fx * fx * (3 - 2 * fx);
|
|
491
|
+
const sy = fy * fy * (3 - 2 * fy);
|
|
492
|
+
const a = at(xi, yi) + (at(xi + 1, yi) - at(xi, yi)) * sx;
|
|
493
|
+
const b = at(xi, yi + 1) + (at(xi + 1, yi + 1) - at(xi, yi + 1)) * sx;
|
|
494
|
+
return a + (b - a) * sy;
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function spread(values) {
|
|
498
|
+
let mean = 0;
|
|
499
|
+
for (const v of values) mean += v;
|
|
500
|
+
mean /= values.length;
|
|
501
|
+
let variance = 0;
|
|
502
|
+
for (const v of values) variance += (v - mean) ** 2;
|
|
503
|
+
return Math.sqrt(variance / values.length) || 1;
|
|
504
|
+
}
|
|
505
|
+
function createPaperPattern(renderer) {
|
|
506
|
+
let s = 7;
|
|
507
|
+
const random = () => (s = s * 1664525 + 1013904223 >>> 0) / 4294967296;
|
|
508
|
+
const noise = new Float32Array(SIZE * SIZE);
|
|
509
|
+
for (let i = 0; i < noise.length; i++) {
|
|
510
|
+
noise[i] = Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random());
|
|
511
|
+
}
|
|
512
|
+
const at = (x, y) => noise[(y + SIZE) % SIZE * SIZE + (x + SIZE) % SIZE];
|
|
513
|
+
const reach = KERNEL.length - 1;
|
|
514
|
+
const grain = new Float32Array(SIZE * SIZE);
|
|
515
|
+
for (let y = 0; y < SIZE; y++) {
|
|
516
|
+
for (let x = 0; x < SIZE; x++) {
|
|
517
|
+
let v = 0;
|
|
518
|
+
for (let dy = -reach; dy <= reach; dy++) {
|
|
519
|
+
const row = KERNEL[Math.abs(dy)];
|
|
520
|
+
for (let dx = -reach; dx <= reach; dx++) v += row[Math.abs(dx)] * at(x + dx, y + dy);
|
|
521
|
+
}
|
|
522
|
+
grain[y * SIZE + x] = v;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const cloudNoise = periodicNoise(CLOUD_PERIOD, random);
|
|
526
|
+
const cloud = new Float32Array(SIZE * SIZE);
|
|
527
|
+
for (let y = 0; y < SIZE; y++) {
|
|
528
|
+
for (let x = 0; x < SIZE; x++) cloud[y * SIZE + x] = cloudNoise(x / SIZE * CLOUD_PERIOD, y / SIZE * CLOUD_PERIOD);
|
|
529
|
+
}
|
|
530
|
+
const cloudScale = CLOUD * spread(grain) / spread(cloud);
|
|
531
|
+
for (let i = 0; i < grain.length; i++) grain[i] += cloud[i] * cloudScale;
|
|
532
|
+
const order = Array.from(grain.keys()).sort((a, b) => grain[a] - grain[b]);
|
|
533
|
+
const canvas = document.createElement("canvas");
|
|
534
|
+
canvas.width = canvas.height = SIZE;
|
|
535
|
+
const ctx = canvas.getContext("2d");
|
|
536
|
+
const image = ctx.createImageData(SIZE, SIZE);
|
|
537
|
+
let level = 1;
|
|
538
|
+
order.forEach((pixel, rank) => {
|
|
539
|
+
const share = rank / (order.length - 1);
|
|
540
|
+
while (LEVELS[level][0] < share) level++;
|
|
541
|
+
const [fromShare, from] = LEVELS[level - 1];
|
|
542
|
+
const [toShare, to] = LEVELS[level];
|
|
543
|
+
const g = Math.round(255 * (from + (to - from) * (share - fromShare) / (toShare - fromShare)));
|
|
544
|
+
const o = pixel * 4;
|
|
545
|
+
image.data[o] = image.data[o + 1] = image.data[o + 2] = g;
|
|
546
|
+
image.data[o + 3] = 255;
|
|
547
|
+
});
|
|
548
|
+
ctx.putImageData(image, 0, 0);
|
|
549
|
+
const texture = new THREE3.CanvasTexture(canvas);
|
|
550
|
+
texture.wrapS = texture.wrapT = THREE3.RepeatWrapping;
|
|
551
|
+
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
552
|
+
return texture;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// src/plain/PlainEngine.ts
|
|
556
|
+
import * as THREE4 from "three";
|
|
557
|
+
var SWIPE_PX = 30;
|
|
558
|
+
var PlainEngine = class {
|
|
559
|
+
group = new THREE4.Group();
|
|
560
|
+
maxPosition;
|
|
561
|
+
spread;
|
|
562
|
+
settings;
|
|
563
|
+
hitArea;
|
|
564
|
+
abort = new AbortController();
|
|
565
|
+
current;
|
|
566
|
+
dirty = true;
|
|
567
|
+
// the pointer that started the gesture; other pointers are ignored until it lifts
|
|
568
|
+
press = null;
|
|
569
|
+
constructor(ctx) {
|
|
570
|
+
this.settings = ctx.settings;
|
|
571
|
+
this.hitArea = ctx.hitArea;
|
|
572
|
+
this.spread = ctx.settings.layout === "spread";
|
|
573
|
+
this.maxPosition = this.spread ? Math.ceil(ctx.pageCount / 2) : ctx.pageCount;
|
|
574
|
+
this.current = this.clamp(ctx.position);
|
|
575
|
+
this.bindInput();
|
|
576
|
+
}
|
|
577
|
+
get position() {
|
|
578
|
+
return this.current;
|
|
579
|
+
}
|
|
580
|
+
get turning() {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
// closed, only the cover side can be clicked
|
|
584
|
+
get activeSide() {
|
|
585
|
+
if (!this.spread) return "both";
|
|
586
|
+
if (this.current === 0) return "right";
|
|
587
|
+
if (this.current === this.maxPosition) return "left";
|
|
588
|
+
return "both";
|
|
589
|
+
}
|
|
590
|
+
update() {
|
|
591
|
+
const moved = this.dirty;
|
|
592
|
+
this.dirty = false;
|
|
593
|
+
return moved;
|
|
594
|
+
}
|
|
595
|
+
flip(forward) {
|
|
596
|
+
if (!forward) {
|
|
597
|
+
if (this.current <= 0) return false;
|
|
598
|
+
this.current--;
|
|
599
|
+
} else if (this.spread) {
|
|
600
|
+
if (this.current >= this.maxPosition) return false;
|
|
601
|
+
this.current++;
|
|
602
|
+
} else if (this.current < this.maxPosition - 1) {
|
|
603
|
+
this.current++;
|
|
604
|
+
} else if (this.settings.loop && this.current > 0) {
|
|
605
|
+
this.current = 0;
|
|
606
|
+
} else return false;
|
|
607
|
+
this.dirty = true;
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
jumpTo(position) {
|
|
611
|
+
this.current = this.clamp(position);
|
|
612
|
+
this.dirty = true;
|
|
613
|
+
}
|
|
614
|
+
setPageTexture() {
|
|
615
|
+
}
|
|
616
|
+
dispose() {
|
|
617
|
+
this.abort.abort();
|
|
618
|
+
}
|
|
619
|
+
clamp(position) {
|
|
620
|
+
const last = this.spread ? this.maxPosition : this.maxPosition - 1;
|
|
621
|
+
return Math.min(Math.max(Math.round(position), 0), Math.max(last, 0));
|
|
622
|
+
}
|
|
623
|
+
// a click on the left half goes back, on the right half forward; a swipe goes its way
|
|
624
|
+
bindInput() {
|
|
625
|
+
const el = this.hitArea;
|
|
626
|
+
const signal = this.abort.signal;
|
|
627
|
+
const { interaction } = this.settings;
|
|
628
|
+
el.addEventListener(
|
|
629
|
+
"pointerdown",
|
|
630
|
+
(e) => {
|
|
631
|
+
if (this.press && !e.isPrimary) return;
|
|
632
|
+
if (e.button !== 0 || !(interaction.drag || interaction.click)) return;
|
|
633
|
+
this.press = { id: e.pointerId, x: e.clientX, y: e.clientY };
|
|
634
|
+
},
|
|
635
|
+
{ signal }
|
|
636
|
+
);
|
|
637
|
+
el.addEventListener(
|
|
638
|
+
"pointerup",
|
|
639
|
+
(e) => {
|
|
640
|
+
const press = this.press;
|
|
641
|
+
if (!press || e.pointerId !== press.id) return;
|
|
642
|
+
this.press = null;
|
|
643
|
+
const dx = e.clientX - press.x;
|
|
644
|
+
if (Math.abs(dx) > SWIPE_PX) {
|
|
645
|
+
if (interaction.drag) this.flip(dx < 0);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (!interaction.click) return;
|
|
649
|
+
const rect = el.getBoundingClientRect();
|
|
650
|
+
const side = this.activeSide;
|
|
651
|
+
this.flip(side === "both" ? e.clientX >= rect.left + rect.width / 2 : side === "right");
|
|
652
|
+
},
|
|
653
|
+
{ signal }
|
|
654
|
+
);
|
|
655
|
+
const cancel = (e) => {
|
|
656
|
+
if (this.press?.id === e.pointerId) this.press = null;
|
|
657
|
+
};
|
|
658
|
+
el.addEventListener("pointercancel", cancel, { signal });
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// src/single/SingleEngine.ts
|
|
663
|
+
import * as THREE6 from "three";
|
|
664
|
+
|
|
665
|
+
// src/single/singleSheet.ts
|
|
666
|
+
import * as THREE5 from "three";
|
|
667
|
+
var NORMAL_EPSILON = 0.02;
|
|
668
|
+
var vertexChunk = (
|
|
669
|
+
/* glsl */
|
|
670
|
+
`
|
|
671
|
+
uniform vec3 uPileLift; // x: where the bulge peaks, y: its height, z: how much it drops at the edge
|
|
672
|
+
uniform float uConeTaper; // bulge difference between top and bottom of the pile
|
|
673
|
+
uniform vec2 uPileReach; // horizontal squeeze at the top/bottom so the bulged page keeps its length
|
|
674
|
+
uniform vec4 uTailCurl; // fold direction (cos, sin), where the curl starts, curl length
|
|
675
|
+
uniform float uTailBend; // how far the free end bows back
|
|
676
|
+
uniform vec3 uCone; // radius change per unit of y, radius at the top, how far to wrap
|
|
677
|
+
uniform vec2 uFlipRotation;
|
|
678
|
+
|
|
679
|
+
float sheetPileLift(float x) {
|
|
680
|
+
float t = 1.0 - min(x / uPileLift.x, 1.0);
|
|
681
|
+
float fall = uPileLift.z * smoothstep(uPileLift.x, 1.0, x);
|
|
682
|
+
return uPileLift.y * sqrt(1.0 - t * t) * (1.0 - fall);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Roll a flat point around a cone whose axis is the spine. The radius grows from the
|
|
686
|
+
// top of the page to the bottom (spread), so pages roll up tighter at one end.
|
|
687
|
+
vec3 sheetConeWrap(float x, float y, float spread, float topRadius, float maxWrap) {
|
|
688
|
+
float apexY = SHEET_ASPECT * 0.5;
|
|
689
|
+
float rowRadius = max(1e-5, topRadius + (apexY - y) * spread);
|
|
690
|
+
float cosCone = sqrt(max(0.0, 1.0 - spread * spread));
|
|
691
|
+
|
|
692
|
+
float slant = x * spread / rowRadius;
|
|
693
|
+
float lean = abs(slant) < 1e-3 ? 1.0 - slant * slant / 3.0 : atan(slant) / slant;
|
|
694
|
+
float wrap = (x / rowRadius) * lean;
|
|
695
|
+
|
|
696
|
+
if (wrap <= maxWrap) {
|
|
697
|
+
float reach = length(vec2(rowRadius, x * spread));
|
|
698
|
+
return vec3(
|
|
699
|
+
reach * sin(wrap),
|
|
700
|
+
y + spread * (reach * (1.0 - cos(wrap)) - x * x / (rowRadius + reach)),
|
|
701
|
+
reach * cosCone * (1.0 - cos(wrap))
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// past the wrap angle the sheet leaves the cone and carries on straight
|
|
706
|
+
float fan = spread * maxWrap;
|
|
707
|
+
float fanSin = abs(fan) < 1e-3 ? 1.0 - fan * fan / 6.0 : sin(fan) / fan;
|
|
708
|
+
float fanVers = abs(fan) < 1e-3 ? fan * (0.5 - fan * fan / 24.0) : (1.0 - cos(fan)) / fan;
|
|
709
|
+
|
|
710
|
+
float along = x * spread * sin(fan) + rowRadius * cos(fan);
|
|
711
|
+
float past = x * cos(fan) - rowRadius * maxWrap * fanSin;
|
|
712
|
+
float lift = along * (1.0 - cos(maxWrap)) + past * sin(maxWrap);
|
|
713
|
+
|
|
714
|
+
return vec3(
|
|
715
|
+
along * sin(maxWrap) + past * cos(maxWrap),
|
|
716
|
+
y + rowRadius * maxWrap * fanVers - x * sin(fan) + spread * lift,
|
|
717
|
+
cosCone * lift
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
vec3 sheetShape(float x, float y) {
|
|
722
|
+
return sheetConeWrap(x, y, uCone.x, uCone.y, uCone.z);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// the unread pile bulges up off the spine
|
|
726
|
+
vec3 sheetPile(float u, float y) {
|
|
727
|
+
float row = y / SHEET_ASPECT;
|
|
728
|
+
float taper = 1.0 + uConeTaper * row;
|
|
729
|
+
float x = u * mix(uPileReach.x, uPileReach.y, row + 0.5);
|
|
730
|
+
return vec3(x, y, sheetPileLift(x) * taper);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// the free end of a turning page bows back past the roll
|
|
734
|
+
vec3 sheetTailCurl(vec2 p) {
|
|
735
|
+
float cosTilt = uTailCurl.x;
|
|
736
|
+
float sinTilt = uTailCurl.y;
|
|
737
|
+
float along = p.x * cosTilt + p.y * sinTilt;
|
|
738
|
+
float across = -p.x * sinTilt + p.y * cosTilt;
|
|
739
|
+
float beyond = max(0.0, along - uTailCurl.z);
|
|
740
|
+
float angle = beyond / uTailCurl.w * uTailBend;
|
|
741
|
+
float sinc = abs(angle) < 1e-4 ? 1.0 : sin(angle) / angle;
|
|
742
|
+
float versine = abs(angle) < 1e-4 ? 0.0 : (1.0 - cos(angle)) / angle;
|
|
743
|
+
float curledAlong = along - beyond + beyond * sinc;
|
|
744
|
+
return vec3(
|
|
745
|
+
curledAlong * cosTilt - across * sinTilt,
|
|
746
|
+
curledAlong * sinTilt + across * cosTilt,
|
|
747
|
+
beyond * versine
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
vec3 sheetPosition(vec2 uv, float noise) {
|
|
752
|
+
vec3 pile = sheetPile(uv.x, (uv.y - 0.5) * SHEET_ASPECT);
|
|
753
|
+
vec3 curled = sheetTailCurl(pile.xy);
|
|
754
|
+
vec3 shape = sheetShape(curled.x, curled.y);
|
|
755
|
+
// the curl lifts off the cone surface, along its normal
|
|
756
|
+
if (curled.z != 0.0) {
|
|
757
|
+
vec3 alongX = sheetShape(curled.x + 1e-3, curled.y) - shape;
|
|
758
|
+
vec3 alongY = sheetShape(curled.x, curled.y + 1e-3) - shape;
|
|
759
|
+
shape += normalize(cross(alongX, alongY)) * curled.z;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// spin about the spine
|
|
763
|
+
float z = shape.z + pile.z;
|
|
764
|
+
return vec3(
|
|
765
|
+
shape.x * uFlipRotation.x - z * uFlipRotation.y,
|
|
766
|
+
shape.y,
|
|
767
|
+
shape.x * uFlipRotation.y + z * uFlipRotation.x
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
`
|
|
771
|
+
);
|
|
772
|
+
var fragmentChunk = (
|
|
773
|
+
/* glsl */
|
|
774
|
+
`
|
|
775
|
+
uniform vec3 uTrim; // x: whether to trim, y/z: the top page's right edge at its bottom/top
|
|
776
|
+
|
|
777
|
+
// Right of the spine, a page under the top one only shows where the top page is: whatever
|
|
778
|
+
// reaches past its right or bottom edge is trimmed away.
|
|
779
|
+
bool sheetHidden(vec3 p) {
|
|
780
|
+
if (uTrim.x < 0.5 || p.x <= 0.0) return false;
|
|
781
|
+
float row = clamp(p.y / SHEET_ASPECT + 0.5, 0.0, 1.0);
|
|
782
|
+
return p.x > mix(uTrim.y, uTrim.z, row) || p.y < -0.5 * SHEET_ASPECT;
|
|
783
|
+
}
|
|
784
|
+
`
|
|
785
|
+
);
|
|
786
|
+
function createSingleGeometry(aspect) {
|
|
787
|
+
return new THREE5.PlaneGeometry(1, aspect, 64, Math.round(64 * aspect));
|
|
788
|
+
}
|
|
789
|
+
function createSingleSheet(geometry, front, back, pattern, settings, shading) {
|
|
790
|
+
const uniforms = {
|
|
791
|
+
uPileLift: { value: new THREE5.Vector3(1, 0, 0) },
|
|
792
|
+
uPileReach: { value: new THREE5.Vector2(1, 1) },
|
|
793
|
+
uConeTaper: { value: 0 },
|
|
794
|
+
uTailCurl: { value: new THREE5.Vector4(1, 0, 1, 1) },
|
|
795
|
+
uTailBend: { value: 0 },
|
|
796
|
+
uCone: { value: new THREE5.Vector3(0, 1, 0) },
|
|
797
|
+
uFlipRotation: { value: new THREE5.Vector2(1, 0) },
|
|
798
|
+
uTrim: { value: new THREE5.Vector3(0, 1, 1) },
|
|
799
|
+
uFront: { value: front },
|
|
800
|
+
uBack: { value: back },
|
|
801
|
+
uPattern: { value: pattern },
|
|
802
|
+
uSpineContact: shading.spineContact,
|
|
803
|
+
uLightDirection: shading.lightDirection
|
|
804
|
+
};
|
|
805
|
+
const mesh = createSheetMesh(
|
|
806
|
+
geometry,
|
|
807
|
+
uniforms,
|
|
808
|
+
{ vertexChunk, fragmentChunk, epsilon: NORMAL_EPSILON, shadowOffsetX: -4e-3 },
|
|
809
|
+
settings
|
|
810
|
+
);
|
|
811
|
+
return { mesh, uniforms };
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// src/single/SingleEngine.ts
|
|
815
|
+
var HOLD = {
|
|
816
|
+
startTime: 0.22,
|
|
817
|
+
firstFlipTime: 1.4,
|
|
818
|
+
fastestFlipTime: 1,
|
|
819
|
+
speedUpSheets: 7,
|
|
820
|
+
firstGapShare: 0.16,
|
|
821
|
+
fastestGapShare: 0.1,
|
|
822
|
+
moveSlopPx: 40
|
|
823
|
+
};
|
|
824
|
+
var DRAG = {
|
|
825
|
+
progressSmoothTime: 0.9,
|
|
826
|
+
clickSlopPx: 5,
|
|
827
|
+
claimSteepness: 1.6,
|
|
828
|
+
// a touch swipe this much flatter than vertical turns pages instead of scrolling
|
|
829
|
+
claimAfterPx: 4,
|
|
830
|
+
fallTime: 1.3,
|
|
831
|
+
fallTimeExponent: 0.5
|
|
832
|
+
};
|
|
833
|
+
var CLICK = { flipTime: 1.2, settlePower: 0.8 };
|
|
834
|
+
var GRAB = { smoothTime: 0.05 };
|
|
835
|
+
var WOBBLE = { tiltDeg: 8, flipTimeMin: 0.95, flipTimeMax: 1.08 };
|
|
836
|
+
var ROLL = {
|
|
837
|
+
spineOpenStartDeg: 100,
|
|
838
|
+
// how far read pages swing round before they start rolling
|
|
839
|
+
spineOpenEndDeg: 190,
|
|
840
|
+
spineOpenPower: 4,
|
|
841
|
+
coneEvenPower: 6,
|
|
842
|
+
lastPageEven: 0.98,
|
|
843
|
+
// how close to even the roll gets by the last page, before the power
|
|
844
|
+
coverTopRadius: 0.04,
|
|
845
|
+
backTopRadius: 0.18,
|
|
846
|
+
coverBottomRadius: 0.01,
|
|
847
|
+
backBottomRadius: 0.05,
|
|
848
|
+
radiusSpacingClump: 7.5,
|
|
849
|
+
radiusSpacingSeed: 41.5,
|
|
850
|
+
fallDelayPages: 15,
|
|
851
|
+
// read pages this far back settle flat behind everything (fewer in short magazines)
|
|
852
|
+
fallDurationPages: 3
|
|
853
|
+
};
|
|
854
|
+
var SHAPE = { midLeadPower: 2, midBow: 2, midBowFlatFrom: 0.3, midBowFlatTo: 0.8, midRollScale: 2.5 };
|
|
855
|
+
var PILE = {
|
|
856
|
+
liftHeight: 0.08,
|
|
857
|
+
liftPeakX: 0.4,
|
|
858
|
+
liftEdgeDrop: 0.5,
|
|
859
|
+
liftShutShare: 0.67,
|
|
860
|
+
liftDepthFalloff: 0.5,
|
|
861
|
+
coneTaper: -0.6,
|
|
862
|
+
renderDepth: 3
|
|
863
|
+
};
|
|
864
|
+
var BOOK = { pushBack: 0.05, pushRight: 0.02 };
|
|
865
|
+
var CATCH_UP = { clump: 1.6, seed: 41 };
|
|
866
|
+
var TAU = Math.PI * 2;
|
|
867
|
+
var clamp = THREE6.MathUtils.clamp;
|
|
868
|
+
var smooth = (x) => {
|
|
869
|
+
const t = clamp(x, 0, 1);
|
|
870
|
+
return t * t * (3 - 2 * t);
|
|
871
|
+
};
|
|
872
|
+
var hash = (a, b) => {
|
|
873
|
+
const s = 43758.5453 * Math.sin(12.9898 * a + 78.233 * b);
|
|
874
|
+
return s - Math.floor(s);
|
|
875
|
+
};
|
|
876
|
+
var SingleEngine = class {
|
|
877
|
+
group = new THREE6.Group();
|
|
878
|
+
// the book, nudged back and right as you read
|
|
879
|
+
maxPosition;
|
|
880
|
+
settings;
|
|
881
|
+
halfHeight;
|
|
882
|
+
liftHeight;
|
|
883
|
+
camera;
|
|
884
|
+
canvas;
|
|
885
|
+
hitArea;
|
|
886
|
+
metrics;
|
|
887
|
+
geometry;
|
|
888
|
+
sheets;
|
|
889
|
+
count;
|
|
890
|
+
radiusSpacing;
|
|
891
|
+
catchUp;
|
|
892
|
+
fallDelay;
|
|
893
|
+
fallDuration;
|
|
894
|
+
abort = new AbortController();
|
|
895
|
+
current;
|
|
896
|
+
flights = [];
|
|
897
|
+
drag = null;
|
|
898
|
+
// the pointer that started the gesture; other pointers are ignored until it lifts
|
|
899
|
+
press = null;
|
|
900
|
+
hold = null;
|
|
901
|
+
touch = null;
|
|
902
|
+
dirty = true;
|
|
903
|
+
last = 0;
|
|
904
|
+
raycaster = new THREE6.Raycaster();
|
|
905
|
+
ndc = new THREE6.Vector2();
|
|
906
|
+
floor = new THREE6.Plane(new THREE6.Vector3(0, 0, 1), 0);
|
|
907
|
+
hit = new THREE6.Vector3();
|
|
908
|
+
spine = new THREE6.Vector3();
|
|
909
|
+
constructor(ctx) {
|
|
910
|
+
this.settings = ctx.settings;
|
|
911
|
+
this.halfHeight = ctx.settings.pageAspect / 2;
|
|
912
|
+
this.liftHeight = PILE.liftHeight * ctx.settings.paper.bulge;
|
|
913
|
+
this.camera = ctx.camera;
|
|
914
|
+
this.canvas = ctx.canvas;
|
|
915
|
+
this.hitArea = ctx.hitArea;
|
|
916
|
+
this.metrics = ctx.metrics;
|
|
917
|
+
this.count = ctx.textures.length;
|
|
918
|
+
this.maxPosition = this.count;
|
|
919
|
+
this.current = clamp(Math.round(ctx.position), 0, Math.max(this.count - 1, 0));
|
|
920
|
+
this.geometry = createSingleGeometry(ctx.settings.pageAspect);
|
|
921
|
+
this.sheets = ctx.textures.map((front, index) => {
|
|
922
|
+
const { mesh, uniforms } = createSingleSheet(this.geometry, front, ctx.blank, ctx.pattern, ctx.settings, ctx.shading);
|
|
923
|
+
this.group.add(mesh);
|
|
924
|
+
return {
|
|
925
|
+
index,
|
|
926
|
+
mesh,
|
|
927
|
+
uniforms,
|
|
928
|
+
progress: index < this.current ? 1 : 0,
|
|
929
|
+
grabX: 1,
|
|
930
|
+
grabXTarget: 1,
|
|
931
|
+
grabY: 0,
|
|
932
|
+
grabYTarget: 0,
|
|
933
|
+
wobble: { tiltDeg: 0, flipTime: 1 }
|
|
934
|
+
};
|
|
935
|
+
});
|
|
936
|
+
const P = this.count;
|
|
937
|
+
const gaps = [];
|
|
938
|
+
for (let i = 0; i < P - 1; i++) gaps.push(Math.pow(hash(i + 1, ROLL.radiusSpacingSeed), ROLL.radiusSpacingClump));
|
|
939
|
+
const gapSum = gaps.reduce((a, b) => a + b, 0);
|
|
940
|
+
this.radiusSpacing = [0];
|
|
941
|
+
let acc = 0;
|
|
942
|
+
for (const g of gaps) {
|
|
943
|
+
acc += 0.1 / gaps.length + 0.9 * (gapSum > 0 ? g / gapSum : 1 / gaps.length);
|
|
944
|
+
this.radiusSpacing.push(acc);
|
|
945
|
+
}
|
|
946
|
+
const steps = [];
|
|
947
|
+
for (let i = 0; i < P; i++) steps.push(Math.pow(hash(i + 1, CATCH_UP.seed + 0.5), CATCH_UP.clump));
|
|
948
|
+
const stepSum = steps.reduce((a, b) => a + b, 0);
|
|
949
|
+
this.catchUp = [0];
|
|
950
|
+
acc = 0;
|
|
951
|
+
for (const s of steps) {
|
|
952
|
+
acc += stepSum > 0 ? s / stepSum * P : 1;
|
|
953
|
+
this.catchUp.push(acc);
|
|
954
|
+
}
|
|
955
|
+
const settleBy = P - 2 - PILE.renderDepth;
|
|
956
|
+
this.fallDuration = clamp(settleBy / 2, 1, ROLL.fallDurationPages);
|
|
957
|
+
this.fallDelay = clamp(settleBy - this.fallDuration, 0, ROLL.fallDelayPages);
|
|
958
|
+
this.bindInput();
|
|
959
|
+
}
|
|
960
|
+
get position() {
|
|
961
|
+
return this.current;
|
|
962
|
+
}
|
|
963
|
+
get turning() {
|
|
964
|
+
return this.flights.length > 0 || this.drag !== null;
|
|
965
|
+
}
|
|
966
|
+
activeSide = "both";
|
|
967
|
+
setPageTexture(page, texture) {
|
|
968
|
+
const sheet = this.sheets[page];
|
|
969
|
+
if (!sheet) return;
|
|
970
|
+
sheet.uniforms.uFront.value = texture;
|
|
971
|
+
this.dirty = true;
|
|
972
|
+
}
|
|
973
|
+
dispose() {
|
|
974
|
+
this.cancelHold();
|
|
975
|
+
this.abort.abort();
|
|
976
|
+
for (const sheet of this.sheets) disposeSheetMesh(sheet.mesh);
|
|
977
|
+
this.geometry.dispose();
|
|
978
|
+
}
|
|
979
|
+
// --- helpers ----------------------------------------------------------------
|
|
980
|
+
randomWobble() {
|
|
981
|
+
if (!this.settings.motion.jitter) return { tiltDeg: 0, flipTime: 1 };
|
|
982
|
+
return {
|
|
983
|
+
tiltDeg: THREE6.MathUtils.lerp(-WOBBLE.tiltDeg, WOBBLE.tiltDeg, Math.random()),
|
|
984
|
+
flipTime: THREE6.MathUtils.lerp(WOBBLE.flipTimeMin, WOBBLE.flipTimeMax, Math.random())
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
seconds(time) {
|
|
988
|
+
return time / this.settings.motion.speed;
|
|
989
|
+
}
|
|
990
|
+
// pile lift for a sheet this many pages below the top
|
|
991
|
+
pileHeight(depth) {
|
|
992
|
+
return this.liftHeight / (1 + depth * PILE.liftDepthFalloff) * (1 - 2e-3 * depth);
|
|
993
|
+
}
|
|
994
|
+
// A bulged page is longer than it is wide; squeeze it so its arc stays one page long.
|
|
995
|
+
pileReach(height) {
|
|
996
|
+
if (height <= 1e-6) return 1;
|
|
997
|
+
const curve = (x) => {
|
|
998
|
+
const t = 1 - Math.min(x / PILE.liftPeakX, 1);
|
|
999
|
+
return height * Math.sqrt(1 - t * t) * (1 - PILE.liftEdgeDrop * smooth((x - PILE.liftPeakX) / (1 - PILE.liftPeakX)));
|
|
1000
|
+
};
|
|
1001
|
+
let length = 0;
|
|
1002
|
+
for (let i = 0; i < 6; i++) {
|
|
1003
|
+
const a = (i / 6) ** 2;
|
|
1004
|
+
const b = ((i + 1) / 6) ** 2;
|
|
1005
|
+
length += Math.hypot(b - a, curve(b) - curve(a));
|
|
1006
|
+
}
|
|
1007
|
+
return Math.max(2 - length, 0.5);
|
|
1008
|
+
}
|
|
1009
|
+
// turned pages, reshaped by the uneven catch-up pace
|
|
1010
|
+
paced(turned) {
|
|
1011
|
+
const i = Math.min(Math.floor(turned), this.count);
|
|
1012
|
+
if (i >= this.count) return this.count;
|
|
1013
|
+
return this.catchUp[i] + (this.catchUp[i + 1] - this.catchUp[i]) * (turned - i);
|
|
1014
|
+
}
|
|
1015
|
+
// 0..1 while the last page is turning
|
|
1016
|
+
closing(turned) {
|
|
1017
|
+
return clamp(turned - (this.count - 1), 0, 1);
|
|
1018
|
+
}
|
|
1019
|
+
shutting(turned) {
|
|
1020
|
+
return smooth((this.closing(turned) - 0.6) / 0.4);
|
|
1021
|
+
}
|
|
1022
|
+
inFlight(sheet) {
|
|
1023
|
+
return this.flights.some((f) => f.sheet === sheet);
|
|
1024
|
+
}
|
|
1025
|
+
busy(sheet) {
|
|
1026
|
+
return this.inFlight(sheet) || this.drag?.sheet === sheet;
|
|
1027
|
+
}
|
|
1028
|
+
// floor point under the pointer, relative to the book
|
|
1029
|
+
floorPoint(clientX, clientY) {
|
|
1030
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1031
|
+
this.ndc.set((clientX - rect.left) / rect.width * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
|
|
1032
|
+
this.raycaster.setFromCamera(this.ndc, this.camera);
|
|
1033
|
+
this.floor.constant = -this.group.position.z;
|
|
1034
|
+
const hit = this.raycaster.ray.intersectPlane(this.floor, this.hit);
|
|
1035
|
+
return hit ? { x: hit.x - this.group.position.x, y: hit.y - this.group.position.y } : null;
|
|
1036
|
+
}
|
|
1037
|
+
// where on the page the hand is, steering how the page tilts as it turns
|
|
1038
|
+
grab(sheet, point, snap) {
|
|
1039
|
+
if (!point) return;
|
|
1040
|
+
sheet.grabXTarget = Math.min(Math.abs(point.x), 1);
|
|
1041
|
+
sheet.grabYTarget = clamp(point.y / this.halfHeight, -1, 1);
|
|
1042
|
+
if (snap) {
|
|
1043
|
+
sheet.grabX = sheet.grabXTarget;
|
|
1044
|
+
sheet.grabY = sheet.grabYTarget;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// the last page only turns when the magazine loops back to the cover
|
|
1048
|
+
canTurn(forward) {
|
|
1049
|
+
if (!forward) return this.current > 0;
|
|
1050
|
+
return this.current < (this.settings.loop ? this.count : this.count - 1);
|
|
1051
|
+
}
|
|
1052
|
+
// which page a press at this x would pick up: right of the spine turns forward
|
|
1053
|
+
sheetAt(clientX) {
|
|
1054
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1055
|
+
const spineX = this.spine.copy(this.group.position).project(this.camera).x;
|
|
1056
|
+
const forward = clientX >= rect.left + (0.5 * spineX + 0.5) * rect.width;
|
|
1057
|
+
if (!this.canTurn(forward)) return null;
|
|
1058
|
+
return { sheet: this.sheets[forward ? this.current : this.current - 1], forward };
|
|
1059
|
+
}
|
|
1060
|
+
// moves the turned count when a page is sent one way or the other
|
|
1061
|
+
commit(forward, toProgress) {
|
|
1062
|
+
if (forward && toProgress === 1) this.current++;
|
|
1063
|
+
else if (!forward && toProgress === 0) this.current--;
|
|
1064
|
+
this.dirty = true;
|
|
1065
|
+
}
|
|
1066
|
+
fly(sheet, forward, toProgress, flipTime = CLICK.flipTime) {
|
|
1067
|
+
this.commit(forward, toProgress);
|
|
1068
|
+
const from = sheet.progress;
|
|
1069
|
+
this.flights = this.flights.filter((f) => f.sheet !== sheet);
|
|
1070
|
+
this.flights.push({
|
|
1071
|
+
sheet,
|
|
1072
|
+
from,
|
|
1073
|
+
to: toProgress,
|
|
1074
|
+
start: performance.now(),
|
|
1075
|
+
duration: 1e3 * this.seconds(flipTime) * sheet.wobble.flipTime * Math.max(0.2, Math.abs(toProgress - from))
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
flip(forward) {
|
|
1079
|
+
if (!this.canTurn(forward)) return false;
|
|
1080
|
+
const sheet = this.sheets[forward ? this.current : this.current - 1];
|
|
1081
|
+
if (this.drag?.sheet === sheet) return false;
|
|
1082
|
+
if (!this.inFlight(sheet)) sheet.wobble = this.randomWobble();
|
|
1083
|
+
this.fly(sheet, forward, forward ? 1 : 0);
|
|
1084
|
+
return true;
|
|
1085
|
+
}
|
|
1086
|
+
jumpTo(position) {
|
|
1087
|
+
this.cancelHold();
|
|
1088
|
+
this.drag = null;
|
|
1089
|
+
this.flights = [];
|
|
1090
|
+
this.current = clamp(Math.round(position), 0, Math.max(this.count - 1, 0));
|
|
1091
|
+
for (const sheet of this.sheets) sheet.progress = sheet.index < this.current ? 1 : 0;
|
|
1092
|
+
this.dirty = true;
|
|
1093
|
+
}
|
|
1094
|
+
// --- input ------------------------------------------------------------------
|
|
1095
|
+
bindInput() {
|
|
1096
|
+
const el = this.hitArea;
|
|
1097
|
+
const signal = this.abort.signal;
|
|
1098
|
+
const { interaction } = this.settings;
|
|
1099
|
+
el.addEventListener(
|
|
1100
|
+
"pointermove",
|
|
1101
|
+
(e) => {
|
|
1102
|
+
if (this.press && e.pointerId !== this.press.id) return;
|
|
1103
|
+
if (this.hold && this.moved(e, this.hold.fired ? HOLD.moveSlopPx : void 0)) this.cancelHold();
|
|
1104
|
+
if (!this.drag || !this.press || !interaction.drag) return;
|
|
1105
|
+
const dx = e.clientX - this.press.x;
|
|
1106
|
+
this.drag.progress = clamp(this.drag.base - dx / this.drag.rectWidth / this.settings.motion.dragDistance, 0, 1);
|
|
1107
|
+
this.grab(this.drag.sheet, this.floorPoint(e.clientX, e.clientY), false);
|
|
1108
|
+
this.dirty = true;
|
|
1109
|
+
},
|
|
1110
|
+
{ signal }
|
|
1111
|
+
);
|
|
1112
|
+
el.addEventListener(
|
|
1113
|
+
"pointerdown",
|
|
1114
|
+
(e) => {
|
|
1115
|
+
if (this.press && !e.isPrimary) return;
|
|
1116
|
+
if (e.button !== 0 || !(interaction.drag || interaction.click || interaction.hold)) return;
|
|
1117
|
+
const target = this.sheetAt(e.clientX);
|
|
1118
|
+
if (!target) return;
|
|
1119
|
+
const sheet = target.sheet;
|
|
1120
|
+
if (!this.inFlight(sheet)) sheet.wobble = this.randomWobble();
|
|
1121
|
+
this.grab(sheet, this.floorPoint(e.clientX, e.clientY), true);
|
|
1122
|
+
this.flights = this.flights.filter((f) => f.sheet !== sheet);
|
|
1123
|
+
this.press = { id: e.pointerId, x: e.clientX, y: e.clientY };
|
|
1124
|
+
this.drag = {
|
|
1125
|
+
sheet,
|
|
1126
|
+
forward: target.forward,
|
|
1127
|
+
base: sheet.progress,
|
|
1128
|
+
progress: sheet.progress,
|
|
1129
|
+
rectWidth: this.metrics.dragWidth,
|
|
1130
|
+
speed: 0
|
|
1131
|
+
};
|
|
1132
|
+
this.cancelHold();
|
|
1133
|
+
if (interaction.hold) {
|
|
1134
|
+
this.hold = {
|
|
1135
|
+
forward: target.forward,
|
|
1136
|
+
x: e.clientX,
|
|
1137
|
+
y: e.clientY,
|
|
1138
|
+
count: 0,
|
|
1139
|
+
fired: false,
|
|
1140
|
+
timer: setTimeout(() => this.holdTick(), 1e3 * HOLD.startTime)
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
try {
|
|
1144
|
+
el.setPointerCapture(e.pointerId);
|
|
1145
|
+
} catch {
|
|
1146
|
+
}
|
|
1147
|
+
this.dirty = true;
|
|
1148
|
+
},
|
|
1149
|
+
{ signal }
|
|
1150
|
+
);
|
|
1151
|
+
el.addEventListener("pointerup", (e) => this.release(e), { signal });
|
|
1152
|
+
el.addEventListener("pointercancel", (e) => this.release(e), { signal });
|
|
1153
|
+
el.addEventListener("lostpointercapture", (e) => this.release(e), { signal });
|
|
1154
|
+
if (!interaction.drag) return;
|
|
1155
|
+
el.addEventListener(
|
|
1156
|
+
"touchstart",
|
|
1157
|
+
(e) => {
|
|
1158
|
+
const t = e.touches[0];
|
|
1159
|
+
this.touch = e.touches.length === 1 ? { x: t.clientX, y: t.clientY, claimed: false } : null;
|
|
1160
|
+
},
|
|
1161
|
+
{ passive: true, signal }
|
|
1162
|
+
);
|
|
1163
|
+
el.addEventListener(
|
|
1164
|
+
"touchmove",
|
|
1165
|
+
(e) => {
|
|
1166
|
+
if (!this.touch || e.touches.length !== 1) return;
|
|
1167
|
+
if (this.touch.claimed) {
|
|
1168
|
+
if (e.cancelable) e.preventDefault();
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (!e.cancelable) {
|
|
1172
|
+
this.touch = null;
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const t = e.touches[0];
|
|
1176
|
+
const dx = Math.abs(t.clientX - this.touch.x);
|
|
1177
|
+
const dy = Math.abs(t.clientY - this.touch.y);
|
|
1178
|
+
if (dx >= DRAG.claimAfterPx && dy < dx * DRAG.claimSteepness) {
|
|
1179
|
+
this.touch.claimed = true;
|
|
1180
|
+
e.preventDefault();
|
|
1181
|
+
}
|
|
1182
|
+
},
|
|
1183
|
+
{ passive: false, signal }
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
moved(e, slop = DRAG.clickSlopPx) {
|
|
1187
|
+
return this.press !== null && (e.clientX - this.press.x) ** 2 + (e.clientY - this.press.y) ** 2 > slop * slop;
|
|
1188
|
+
}
|
|
1189
|
+
cancelHold() {
|
|
1190
|
+
if (this.hold) clearTimeout(this.hold.timer);
|
|
1191
|
+
this.hold = null;
|
|
1192
|
+
}
|
|
1193
|
+
// press and hold keeps turning pages, a little faster each time
|
|
1194
|
+
holdTick() {
|
|
1195
|
+
const hold = this.hold;
|
|
1196
|
+
if (!hold) return;
|
|
1197
|
+
if (!hold.fired) {
|
|
1198
|
+
hold.fired = true;
|
|
1199
|
+
this.drag = null;
|
|
1200
|
+
}
|
|
1201
|
+
if (!this.canTurn(hold.forward)) return this.cancelHold();
|
|
1202
|
+
const sheet = this.sheets[hold.forward ? this.current : this.current - 1];
|
|
1203
|
+
const ramp = (from, to) => from + (to - from) * Math.min(hold.count / HOLD.speedUpSheets, 1);
|
|
1204
|
+
if (!this.inFlight(sheet)) sheet.wobble = this.randomWobble();
|
|
1205
|
+
this.grab(sheet, this.floorPoint(hold.x, hold.y), true);
|
|
1206
|
+
const flipTime = ramp(HOLD.firstFlipTime, HOLD.fastestFlipTime);
|
|
1207
|
+
this.fly(sheet, hold.forward, hold.forward ? 1 : 0, flipTime);
|
|
1208
|
+
hold.count++;
|
|
1209
|
+
hold.timer = setTimeout(
|
|
1210
|
+
() => this.holdTick(),
|
|
1211
|
+
1e3 * this.seconds(flipTime) * ramp(HOLD.firstGapShare, HOLD.fastestGapShare)
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
// pointercancel and lostpointercapture put the page back; only pointerup completes a turn
|
|
1215
|
+
release(e) {
|
|
1216
|
+
if (!this.press || e.pointerId !== this.press.id) return;
|
|
1217
|
+
const cancelled = e.type !== "pointerup";
|
|
1218
|
+
const wasMoved = this.moved(e);
|
|
1219
|
+
this.cancelHold();
|
|
1220
|
+
const drag = this.drag;
|
|
1221
|
+
this.drag = null;
|
|
1222
|
+
this.press = null;
|
|
1223
|
+
try {
|
|
1224
|
+
this.hitArea.releasePointerCapture(e.pointerId);
|
|
1225
|
+
} catch {
|
|
1226
|
+
}
|
|
1227
|
+
if (!drag) return;
|
|
1228
|
+
const { interaction } = this.settings;
|
|
1229
|
+
if (!wasMoved || !interaction.drag) {
|
|
1230
|
+
if (!cancelled && interaction.click) this.fly(drag.sheet, drag.forward, drag.forward ? 1 : 0);
|
|
1231
|
+
else this.settleBack(drag);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
let to;
|
|
1235
|
+
if (cancelled) to = drag.forward ? 0 : 1;
|
|
1236
|
+
else {
|
|
1237
|
+
const travelled = drag.forward ? drag.progress - drag.base : drag.base - drag.progress;
|
|
1238
|
+
to = drag.forward === travelled >= this.settings.motion.commitThreshold ? 1 : 0;
|
|
1239
|
+
}
|
|
1240
|
+
this.commit(drag.forward, to);
|
|
1241
|
+
const sheet = drag.sheet;
|
|
1242
|
+
const speed = to === 1 ? drag.speed : -drag.speed;
|
|
1243
|
+
const remaining = Math.max(Math.abs(to - sheet.progress), 1e-3);
|
|
1244
|
+
const duration = this.seconds(DRAG.fallTime) * sheet.wobble.flipTime * Math.pow(remaining, DRAG.fallTimeExponent);
|
|
1245
|
+
this.flights = this.flights.filter((f) => f.sheet !== sheet);
|
|
1246
|
+
this.flights.push({
|
|
1247
|
+
sheet,
|
|
1248
|
+
from: sheet.progress,
|
|
1249
|
+
to,
|
|
1250
|
+
start: performance.now(),
|
|
1251
|
+
duration: 1e3 * duration,
|
|
1252
|
+
launch: clamp(speed * duration / remaining, 0, 2)
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
// a press that didn't turn anything: put the page back where it was
|
|
1256
|
+
settleBack(drag) {
|
|
1257
|
+
const to = drag.forward ? 0 : 1;
|
|
1258
|
+
if (drag.sheet.progress === to) return;
|
|
1259
|
+
this.flights.push({ sheet: drag.sheet, from: drag.sheet.progress, to, start: performance.now(), duration: 300 });
|
|
1260
|
+
}
|
|
1261
|
+
// --- simulation ---------------------------------------------------------------
|
|
1262
|
+
update(now) {
|
|
1263
|
+
for (let i = this.flights.length - 1; i >= 0; i--) {
|
|
1264
|
+
const f = this.flights[i];
|
|
1265
|
+
const t = clamp((now - f.start) / f.duration, 0, 1);
|
|
1266
|
+
let k;
|
|
1267
|
+
if (f.launch === void 0) {
|
|
1268
|
+
k = 0.5 * (1 - Math.cos((1 - Math.pow(1 - t, CLICK.settlePower)) * Math.PI));
|
|
1269
|
+
} else {
|
|
1270
|
+
const t2 = t * t;
|
|
1271
|
+
const t3 = t2 * t;
|
|
1272
|
+
k = (t3 - 2 * t2 + t) * f.launch + (3 * t2 - 2 * t3);
|
|
1273
|
+
}
|
|
1274
|
+
f.sheet.progress = f.from + (f.to - f.from) * k;
|
|
1275
|
+
if (now >= f.start + f.duration) {
|
|
1276
|
+
this.flights.splice(i, 1);
|
|
1277
|
+
this.dirty = true;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (this.count && this.current >= this.count && !this.flights.length && !this.drag) {
|
|
1281
|
+
this.current = 0;
|
|
1282
|
+
for (const sheet of this.sheets) sheet.progress = 0;
|
|
1283
|
+
this.dirty = true;
|
|
1284
|
+
}
|
|
1285
|
+
const dt = this.last ? Math.min((now - this.last) / 1e3, 0.1) : 1 / 60;
|
|
1286
|
+
this.last = now;
|
|
1287
|
+
if (this.drag) {
|
|
1288
|
+
const sheet = this.drag.sheet;
|
|
1289
|
+
const step = (this.drag.progress - sheet.progress) * (1 - Math.pow(0.01, dt / DRAG.progressSmoothTime));
|
|
1290
|
+
sheet.progress += step;
|
|
1291
|
+
this.drag.speed = dt > 0 ? step / dt : 0;
|
|
1292
|
+
}
|
|
1293
|
+
let moving = this.flights.length > 0 || this.drag !== null;
|
|
1294
|
+
const grabStep = 1 - Math.pow(1e-3, dt / GRAB.smoothTime);
|
|
1295
|
+
for (const sheet of this.sheets) {
|
|
1296
|
+
for (const key of ["grabX", "grabY"]) {
|
|
1297
|
+
const target = key === "grabX" ? sheet.grabXTarget : sheet.grabYTarget;
|
|
1298
|
+
const d = target - sheet[key];
|
|
1299
|
+
if (Math.abs(d) < 1e-4) sheet[key] = target;
|
|
1300
|
+
else {
|
|
1301
|
+
sheet[key] += d * grabStep;
|
|
1302
|
+
moving = true;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
if (!this.busy(sheet)) {
|
|
1306
|
+
const rest = sheet.index < this.current ? 1 : 0;
|
|
1307
|
+
if (sheet.progress !== rest) {
|
|
1308
|
+
sheet.progress = rest;
|
|
1309
|
+
this.dirty = true;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
if (!moving && !this.dirty) return false;
|
|
1314
|
+
let turned = 0;
|
|
1315
|
+
for (const sheet of this.sheets) turned += sheet.progress;
|
|
1316
|
+
const edge = this.pileEdge(turned);
|
|
1317
|
+
const top = this.sheets.find((sheet) => sheet.index >= this.current && !this.busy(sheet));
|
|
1318
|
+
for (const sheet of this.sheets) {
|
|
1319
|
+
this.applyUniforms(sheet, turned, edge);
|
|
1320
|
+
sheet.uniforms.uTrim.value.set(sheet === top || this.busy(sheet) ? 0 : 1, edge.bottom, edge.top);
|
|
1321
|
+
}
|
|
1322
|
+
const closing = this.closing(turned) > 0;
|
|
1323
|
+
for (const sheet of this.sheets) {
|
|
1324
|
+
const i = sheet.index;
|
|
1325
|
+
sheet.mesh.visible = closing || i <= this.current + PILE.renderDepth || this.busy(sheet);
|
|
1326
|
+
layerSheet(sheet.mesh, i < this.current ? this.current - 1 - i : i - this.current, this.count, this.busy(sheet));
|
|
1327
|
+
}
|
|
1328
|
+
const read = smooth(this.count > 1 ? this.paced(turned) / (this.count - 1) : 0) * (1 - this.shutting(turned));
|
|
1329
|
+
this.group.position.x = BOOK.pushRight * read;
|
|
1330
|
+
this.group.position.z = -BOOK.pushBack * read;
|
|
1331
|
+
this.dirty = false;
|
|
1332
|
+
return true;
|
|
1333
|
+
}
|
|
1334
|
+
// How far the top of the pile reaches at its bottom and top edges. Pages under it would
|
|
1335
|
+
// reach further, being flatter, and show a sliver of whatever is printed there; instead
|
|
1336
|
+
// they stop at its edge, and whatever still gets past is trimmed in the shader.
|
|
1337
|
+
pileEdge(turned) {
|
|
1338
|
+
const share = PILE.liftShutShare + (1 - PILE.liftShutShare) * (this.count ? this.paced(turned) / this.count : 0);
|
|
1339
|
+
const topShare = share + (PILE.liftShutShare - share) * this.shutting(turned);
|
|
1340
|
+
const height = this.pileHeight(0) * Math.max(topShare, 1e-3);
|
|
1341
|
+
return {
|
|
1342
|
+
bottom: this.pileReach(height * (1 - 0.5 * PILE.coneTaper)),
|
|
1343
|
+
top: this.pileReach(height * (1 + 0.5 * PILE.coneTaper))
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
applyUniforms(sheet, turned, edge) {
|
|
1347
|
+
const u = sheet.uniforms;
|
|
1348
|
+
const P = this.count;
|
|
1349
|
+
const T = sheet.progress;
|
|
1350
|
+
const paced = this.paced(turned);
|
|
1351
|
+
const lastPage = this.catchUp[Math.max(P - 1, 0)];
|
|
1352
|
+
const through = lastPage > 0 ? paced / lastPage : 1;
|
|
1353
|
+
const openFraction = Math.min(through, 1);
|
|
1354
|
+
const spineOpen = (ROLL.spineOpenStartDeg + (ROLL.spineOpenEndDeg - ROLL.spineOpenStartDeg) * Math.pow(openFraction, ROLL.spineOpenPower)) * Math.PI / 180;
|
|
1355
|
+
const shut = this.shutting(turned);
|
|
1356
|
+
const closed = this.closing(turned) >= 1 ? 1 : 0;
|
|
1357
|
+
const fall = Math.max(
|
|
1358
|
+
smooth((paced - (sheet.index + 1) - this.fallDelay) / this.fallDuration),
|
|
1359
|
+
shut
|
|
1360
|
+
);
|
|
1361
|
+
const spacing = this.radiusSpacing[sheet.index] ?? 0;
|
|
1362
|
+
const topR = ROLL.coverTopRadius + (ROLL.backTopRadius - ROLL.coverTopRadius) * spacing;
|
|
1363
|
+
const bottomR = ROLL.coverBottomRadius + (ROLL.backBottomRadius - ROLL.coverBottomRadius) * spacing;
|
|
1364
|
+
const even = Math.pow(Math.min(through * ROLL.lastPageEven, 1), ROLL.coneEvenPower);
|
|
1365
|
+
const midR = (topR + bottomR) / 2;
|
|
1366
|
+
const radii = {
|
|
1367
|
+
top: (topR + (midR - topR) * even) * (1 - fall),
|
|
1368
|
+
bottom: (bottomR + (midR - bottomR) * even) * (1 - fall)
|
|
1369
|
+
};
|
|
1370
|
+
const fallen = fall >= 1;
|
|
1371
|
+
const readShare = P ? paced / P : 0;
|
|
1372
|
+
const depth = fallen ? Math.max(0, P - turned) + sheet.index : Math.max(0, sheet.index - turned);
|
|
1373
|
+
const presence = fallen ? Math.max(smooth(paced - (sheet.index + 1) - this.fallDelay - this.fallDuration), closed) : (1 - T) * (1 - shut);
|
|
1374
|
+
const share = PILE.liftShutShare + (1 - PILE.liftShutShare) * readShare;
|
|
1375
|
+
const open = this.pileHeight(depth) * (fallen ? PILE.liftShutShare : share);
|
|
1376
|
+
const lift = (open + (this.pileHeight(sheet.index) * PILE.liftShutShare - open) * shut) * presence;
|
|
1377
|
+
u.uPileLift.value.set(PILE.liftPeakX, lift, PILE.liftEdgeDrop);
|
|
1378
|
+
const topShare = share + (PILE.liftShutShare - share) * shut;
|
|
1379
|
+
const reference = Math.max(this.pileHeight(0) * Math.max(topShare, 1e-3), 1e-6);
|
|
1380
|
+
u.uConeTaper.value = PILE.coneTaper * lift / reference;
|
|
1381
|
+
const reachFade = 1 - 0.5 * Math.max(sheet.grabY, 0) * sheet.grabX;
|
|
1382
|
+
const tiltDeg = sheet.grabY * (0.5 + 0.5 * sheet.grabX) * this.settings.motion.maxTilt + sheet.wobble.tiltDeg;
|
|
1383
|
+
if (fallen) {
|
|
1384
|
+
u.uCone.value.set(0, 1, 0);
|
|
1385
|
+
u.uTailBend.value = 0;
|
|
1386
|
+
const angle = TAU * T;
|
|
1387
|
+
u.uFlipRotation.value.set(Math.cos(angle), Math.sin(angle));
|
|
1388
|
+
} else {
|
|
1389
|
+
this.shapeTurn(sheet, T, spineOpen, radii, tiltDeg * reachFade * Math.PI / 180);
|
|
1390
|
+
}
|
|
1391
|
+
const height = u.uPileLift.value.y;
|
|
1392
|
+
const taper = u.uConeTaper.value;
|
|
1393
|
+
let bottomReach = this.pileReach(height * (1 - 0.5 * taper));
|
|
1394
|
+
let topReach = this.pileReach(height * (1 + 0.5 * taper));
|
|
1395
|
+
if (!this.busy(sheet) && (fallen || sheet.index >= this.current)) {
|
|
1396
|
+
bottomReach = Math.min(bottomReach, edge.bottom);
|
|
1397
|
+
topReach = Math.min(topReach, edge.top);
|
|
1398
|
+
}
|
|
1399
|
+
u.uPileReach.value.set(bottomReach, topReach);
|
|
1400
|
+
}
|
|
1401
|
+
// a turning page: swing round the spine up to the open angle, then roll round the cone
|
|
1402
|
+
shapeTurn(sheet, T, spineOpen, radii, tilt) {
|
|
1403
|
+
const u = sheet.uniforms;
|
|
1404
|
+
const half = this.halfHeight;
|
|
1405
|
+
const aspect = this.settings.pageAspect;
|
|
1406
|
+
const curl = this.settings.motion.curl;
|
|
1407
|
+
const lead = TAU * (1 - Math.pow(1 - T, SHAPE.midLeadPower));
|
|
1408
|
+
const even = TAU * T;
|
|
1409
|
+
const swing = Math.min(lead, spineOpen);
|
|
1410
|
+
const wrap = lead - swing;
|
|
1411
|
+
const wrapShare = wrap / Math.max(TAU - spineOpen, 1e-3);
|
|
1412
|
+
const rollScale = 1 + (SHAPE.midRollScale - 1) * (1 - smooth(wrapShare));
|
|
1413
|
+
const topRadius = Math.max(radii.top * rollScale, 1e-4);
|
|
1414
|
+
const spread2 = clamp((radii.bottom * rollScale - topRadius) / aspect, -0.999, 0.999);
|
|
1415
|
+
const rolled = (y) => {
|
|
1416
|
+
const r = Math.max(1e-5, topRadius + (half - y) * spread2);
|
|
1417
|
+
const a = clamp(spread2 * wrap, -1.5, 1.5);
|
|
1418
|
+
return Math.abs(spread2) < 1e-4 ? r * wrap : r * Math.tan(a) / spread2;
|
|
1419
|
+
};
|
|
1420
|
+
const angle = tilt + (Math.atan2(rolled(-half) - rolled(half), aspect) - tilt) * smooth(T);
|
|
1421
|
+
const cos = Math.cos(angle);
|
|
1422
|
+
const sin = Math.sin(angle);
|
|
1423
|
+
const curlStart = Math.max(rolled(half) * cos + half * sin, rolled(-half) * cos - half * sin);
|
|
1424
|
+
const curlLength = cos + half * Math.abs(sin) - curlStart;
|
|
1425
|
+
const letGo = 1 - smooth(lead / spineOpen);
|
|
1426
|
+
u.uPileLift.value.y *= letGo;
|
|
1427
|
+
u.uConeTaper.value *= letGo;
|
|
1428
|
+
u.uCone.value.set(spread2, topRadius, wrap);
|
|
1429
|
+
u.uTailCurl.value.set(cos, sin, curlStart, Math.max(curlLength, 1e-3));
|
|
1430
|
+
const flat = 1 - smooth((T - SHAPE.midBowFlatFrom) / (SHAPE.midBowFlatTo - SHAPE.midBowFlatFrom));
|
|
1431
|
+
u.uTailBend.value = Math.max(SHAPE.midBow * curl * flat * Math.min(even - lead, 0), -lead);
|
|
1432
|
+
u.uFlipRotation.value.set(Math.cos(swing), Math.sin(swing));
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
// src/spread/SpreadEngine.ts
|
|
1437
|
+
import * as THREE8 from "three";
|
|
1438
|
+
|
|
1439
|
+
// src/spread/spreadSheet.ts
|
|
1440
|
+
import * as THREE7 from "three";
|
|
1441
|
+
var NORMAL_EPSILON2 = 0.03;
|
|
1442
|
+
var WRINKLE_SCALE = 1.2;
|
|
1443
|
+
var WRINKLE_SEED = 46;
|
|
1444
|
+
var vertexChunk2 = (settings) => (
|
|
1445
|
+
/* glsl */
|
|
1446
|
+
`
|
|
1447
|
+
// resting bulge across the page: up off the spine, a slight dip, up again, down at the edge
|
|
1448
|
+
const float LIFT_MAX_X = 0.14;
|
|
1449
|
+
const float LIFT_MAX_Z = 0.04;
|
|
1450
|
+
const float LIFT_DIP_X = 0.58;
|
|
1451
|
+
const float LIFT_DIP_Z = 0.035;
|
|
1452
|
+
const float LIFT_MID_X = 0.72;
|
|
1453
|
+
const float LIFT_MID_Z = 0.04;
|
|
1454
|
+
const float LIFT_EDGE_Z = 0.03;
|
|
1455
|
+
|
|
1456
|
+
const float BULGE = ${settings.paper.bulge.toFixed(4)};
|
|
1457
|
+
const float WRINKLE = ${(0.13 * settings.paper.wrinkles).toFixed(4)};
|
|
1458
|
+
|
|
1459
|
+
uniform float uFlipProgress;
|
|
1460
|
+
uniform float uWrinkleSide;
|
|
1461
|
+
uniform float uBendAngle;
|
|
1462
|
+
uniform vec2 uFold;
|
|
1463
|
+
uniform vec2 uCurl;
|
|
1464
|
+
uniform vec2 uFlipRotation;
|
|
1465
|
+
uniform float uDirection;
|
|
1466
|
+
uniform float uStackLift;
|
|
1467
|
+
|
|
1468
|
+
attribute vec3 aNoise;
|
|
1469
|
+
|
|
1470
|
+
float sheetRestingLift(float x) {
|
|
1471
|
+
if (x <= LIFT_MAX_X) return LIFT_MAX_Z * sin(x / LIFT_MAX_X * 1.57079633);
|
|
1472
|
+
if (x <= LIFT_DIP_X) return mix(LIFT_MAX_Z, LIFT_DIP_Z, smoothstep(LIFT_MAX_X, LIFT_DIP_X, x));
|
|
1473
|
+
if (x <= LIFT_MID_X) return mix(LIFT_DIP_Z, LIFT_MID_Z, smoothstep(LIFT_DIP_X, LIFT_MID_X, x));
|
|
1474
|
+
float t = (x - LIFT_MID_X) / (1.0 - LIFT_MID_X);
|
|
1475
|
+
return mix(LIFT_MID_Z, LIFT_EDGE_Z, 1.0 - cos(t * 1.57079633));
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
vec3 sheetPosition(vec2 uv, float noise) {
|
|
1479
|
+
float x = uv.x;
|
|
1480
|
+
float y = (uv.y - 0.5) * SHEET_ASPECT;
|
|
1481
|
+
|
|
1482
|
+
// Every sheet shares the same wrinkles and bulge, scaled by its place in the stack,
|
|
1483
|
+
// so stacked sheets nest inside each other instead of crossing.
|
|
1484
|
+
float wrinkle = (mix(noise, 1.0 - noise, uFlipProgress) - 0.5) * smoothstep(0.0, 0.35, x) * WRINKLE * uStackLift;
|
|
1485
|
+
float lift = sheetRestingLift(x) * BULGE * uStackLift;
|
|
1486
|
+
|
|
1487
|
+
// bend with constant curvature past the fold line, trailing against the turn
|
|
1488
|
+
float u = x * uFold.x + y * uFold.y;
|
|
1489
|
+
float v = -x * uFold.y + y * uFold.x;
|
|
1490
|
+
float beyond = max(0.0, u - uCurl.x);
|
|
1491
|
+
float angle = beyond / uCurl.y * uBendAngle;
|
|
1492
|
+
float sinc = abs(angle) < 1e-4 ? 1.0 : sin(angle) / angle;
|
|
1493
|
+
float versine = abs(angle) < 1e-4 ? 0.0 : (1.0 - cos(angle)) / angle;
|
|
1494
|
+
float bentU = u - beyond + beyond * sinc;
|
|
1495
|
+
float bentZ = -uDirection * beyond * versine;
|
|
1496
|
+
|
|
1497
|
+
vec3 p = vec3(bentU * uFold.x - v * uFold.y, bentU * uFold.y + v * uFold.x, bentZ);
|
|
1498
|
+
|
|
1499
|
+
// turn about the spine
|
|
1500
|
+
p = vec3(p.x * uFlipRotation.x - p.z * uFlipRotation.y, p.y, p.x * uFlipRotation.y + p.z * uFlipRotation.x);
|
|
1501
|
+
|
|
1502
|
+
p.z += lift + uWrinkleSide * wrinkle;
|
|
1503
|
+
return p;
|
|
1504
|
+
}
|
|
1505
|
+
`
|
|
1506
|
+
);
|
|
1507
|
+
function hash2(x, y) {
|
|
1508
|
+
const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
|
|
1509
|
+
return s - Math.floor(s);
|
|
1510
|
+
}
|
|
1511
|
+
function valueNoise(x, y) {
|
|
1512
|
+
const xi = Math.floor(x);
|
|
1513
|
+
const yi = Math.floor(y);
|
|
1514
|
+
const fx = x - xi;
|
|
1515
|
+
const fy = y - yi;
|
|
1516
|
+
const sx = fx * fx * (3 - 2 * fx);
|
|
1517
|
+
const sy = fy * fy * (3 - 2 * fy);
|
|
1518
|
+
const a = hash2(xi, yi) + (hash2(xi + 1, yi) - hash2(xi, yi)) * sx;
|
|
1519
|
+
const b = hash2(xi, yi + 1) + (hash2(xi + 1, yi + 1) - hash2(xi, yi + 1)) * sx;
|
|
1520
|
+
return a + (b - a) * sy;
|
|
1521
|
+
}
|
|
1522
|
+
function wrinkleNoise(u, v, aspect) {
|
|
1523
|
+
let x = u / WRINKLE_SCALE + WRINKLE_SEED * 13.37;
|
|
1524
|
+
let y = (v - 0.5) * aspect / WRINKLE_SCALE + WRINKLE_SEED * 7.77;
|
|
1525
|
+
let value = 0;
|
|
1526
|
+
let amplitude = 0.5;
|
|
1527
|
+
for (let octave = 0; octave < 3; octave++) {
|
|
1528
|
+
value += amplitude * valueNoise(x, y);
|
|
1529
|
+
amplitude *= 0.5;
|
|
1530
|
+
x *= 2;
|
|
1531
|
+
y *= 2;
|
|
1532
|
+
}
|
|
1533
|
+
return value;
|
|
1534
|
+
}
|
|
1535
|
+
function createSpreadGeometry(aspect) {
|
|
1536
|
+
const geometry = new THREE7.PlaneGeometry(1, aspect, 64, Math.round(64 * aspect));
|
|
1537
|
+
const uv = geometry.attributes.uv;
|
|
1538
|
+
const noise = new Float32Array(uv.count * 3);
|
|
1539
|
+
for (let i = 0; i < uv.count; i++) {
|
|
1540
|
+
const u = uv.getX(i);
|
|
1541
|
+
const v = uv.getY(i);
|
|
1542
|
+
noise[i * 3] = wrinkleNoise(u, v, aspect);
|
|
1543
|
+
noise[i * 3 + 1] = wrinkleNoise(u + NORMAL_EPSILON2, v, aspect);
|
|
1544
|
+
noise[i * 3 + 2] = wrinkleNoise(u, v + NORMAL_EPSILON2 / aspect, aspect);
|
|
1545
|
+
}
|
|
1546
|
+
geometry.setAttribute("aNoise", new THREE7.BufferAttribute(noise, 3));
|
|
1547
|
+
return geometry;
|
|
1548
|
+
}
|
|
1549
|
+
function createSpreadSheet(geometry, front, back, pattern, settings, shading) {
|
|
1550
|
+
const uniforms = {
|
|
1551
|
+
uFlipProgress: { value: 0 },
|
|
1552
|
+
uWrinkleSide: { value: -1 },
|
|
1553
|
+
uBendAngle: { value: 0 },
|
|
1554
|
+
uFold: { value: new THREE7.Vector2(1, 0) },
|
|
1555
|
+
uCurl: { value: new THREE7.Vector2(0, 1) },
|
|
1556
|
+
uFlipRotation: { value: new THREE7.Vector2(1, 0) },
|
|
1557
|
+
uDirection: { value: 1 },
|
|
1558
|
+
uStackLift: { value: 1 },
|
|
1559
|
+
uFront: { value: front },
|
|
1560
|
+
uBack: { value: back },
|
|
1561
|
+
uPattern: { value: pattern },
|
|
1562
|
+
uSpineContact: shading.spineContact,
|
|
1563
|
+
uLightDirection: shading.lightDirection
|
|
1564
|
+
};
|
|
1565
|
+
const mesh = createSheetMesh(
|
|
1566
|
+
geometry,
|
|
1567
|
+
uniforms,
|
|
1568
|
+
{ vertexChunk: vertexChunk2(settings), epsilon: NORMAL_EPSILON2, noise: true, shadowOffsetX: 4e-3 },
|
|
1569
|
+
settings
|
|
1570
|
+
);
|
|
1571
|
+
return { mesh, uniforms };
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// src/spread/SpreadEngine.ts
|
|
1575
|
+
var CURVE = {
|
|
1576
|
+
arc: 0.88,
|
|
1577
|
+
// share of a half turn the sheet bends by mid-flip
|
|
1578
|
+
arcJitter: 0.2,
|
|
1579
|
+
angleJitter: 0.3,
|
|
1580
|
+
tiltJitterDeg: 6,
|
|
1581
|
+
cornerRollMax: 2.3,
|
|
1582
|
+
directionSmoothTime: 0.4,
|
|
1583
|
+
smoothTime: 0.5,
|
|
1584
|
+
settleEpsilon: 1e-4
|
|
1585
|
+
};
|
|
1586
|
+
var HOVER = { smoothTime: 2, gapToSheetAbove: 0.1 };
|
|
1587
|
+
var HOLD2 = {
|
|
1588
|
+
startTime: 0.22,
|
|
1589
|
+
firstFlipTime: 1.2,
|
|
1590
|
+
fastestFlipTime: 0.5,
|
|
1591
|
+
speedUpSheets: 20,
|
|
1592
|
+
firstGapShare: 0.15,
|
|
1593
|
+
fastestGapShare: 0.05,
|
|
1594
|
+
moveSlopPx: 40
|
|
1595
|
+
};
|
|
1596
|
+
var DRAG2 = { progressSmoothTime: 0.9, fallTime: 1, fallTimeExponent: 0.5, landingSpeed: 0.5, clickSlopPx: 5 };
|
|
1597
|
+
var CLICK2 = { flipTime: 0.85, followArcGainMin: 1, followArcGainMax: 1.08 };
|
|
1598
|
+
var clamp2 = THREE8.MathUtils.clamp;
|
|
1599
|
+
var approach = (smoothTime, dt) => 1 - Math.pow(1e-3, dt / smoothTime);
|
|
1600
|
+
function stackLift(index, count, progress) {
|
|
1601
|
+
if (count <= 1) return 1;
|
|
1602
|
+
return 0.31 + 0.69 * (index + (count - 1 - 2 * index) * (1 - progress)) / (count - 1);
|
|
1603
|
+
}
|
|
1604
|
+
var SpreadEngine = class {
|
|
1605
|
+
group = new THREE8.Group();
|
|
1606
|
+
maxPosition;
|
|
1607
|
+
settings;
|
|
1608
|
+
halfHeight;
|
|
1609
|
+
camera;
|
|
1610
|
+
canvas;
|
|
1611
|
+
hitArea;
|
|
1612
|
+
metrics;
|
|
1613
|
+
geometry;
|
|
1614
|
+
sheets;
|
|
1615
|
+
abort = new AbortController();
|
|
1616
|
+
shading;
|
|
1617
|
+
current;
|
|
1618
|
+
flights = [];
|
|
1619
|
+
drag = null;
|
|
1620
|
+
// the pointer that started the gesture; other pointers are ignored until it lifts
|
|
1621
|
+
press = null;
|
|
1622
|
+
hold = null;
|
|
1623
|
+
hover = { active: false, x: 0, y: 0 };
|
|
1624
|
+
hovered = -1;
|
|
1625
|
+
leader = -1;
|
|
1626
|
+
// the sheet most recently sent flying
|
|
1627
|
+
dirty = true;
|
|
1628
|
+
last = 0;
|
|
1629
|
+
raycaster = new THREE8.Raycaster();
|
|
1630
|
+
ndc = new THREE8.Vector2();
|
|
1631
|
+
floor = new THREE8.Plane(new THREE8.Vector3(0, 0, 1), 0);
|
|
1632
|
+
hit = new THREE8.Vector3();
|
|
1633
|
+
spine = new THREE8.Vector3();
|
|
1634
|
+
constructor(ctx) {
|
|
1635
|
+
this.settings = ctx.settings;
|
|
1636
|
+
this.halfHeight = ctx.settings.pageAspect / 2;
|
|
1637
|
+
this.camera = ctx.camera;
|
|
1638
|
+
this.canvas = ctx.canvas;
|
|
1639
|
+
this.hitArea = ctx.hitArea;
|
|
1640
|
+
this.metrics = ctx.metrics;
|
|
1641
|
+
this.shading = ctx.shading;
|
|
1642
|
+
const count = Math.ceil(ctx.textures.length / 2);
|
|
1643
|
+
this.maxPosition = count;
|
|
1644
|
+
this.current = clamp2(Math.round(ctx.position), 0, count);
|
|
1645
|
+
this.geometry = createSpreadGeometry(ctx.settings.pageAspect);
|
|
1646
|
+
this.sheets = [];
|
|
1647
|
+
for (let i = 0; i < count; i++) {
|
|
1648
|
+
const front = ctx.textures[2 * i];
|
|
1649
|
+
const back = ctx.textures[2 * i + 1] ?? ctx.blank;
|
|
1650
|
+
const { mesh, uniforms } = createSpreadSheet(this.geometry, front, back, ctx.pattern, ctx.settings, ctx.shading);
|
|
1651
|
+
this.sheets.push({
|
|
1652
|
+
index: i,
|
|
1653
|
+
mesh,
|
|
1654
|
+
uniforms,
|
|
1655
|
+
progress: i < this.current ? 1 : 0,
|
|
1656
|
+
direction: 1,
|
|
1657
|
+
directionSmooth: 1,
|
|
1658
|
+
curve: { arc: 0, angleDeg: 0 },
|
|
1659
|
+
curveTarget: { arc: 0, angleDeg: 0 },
|
|
1660
|
+
wobble: { arc: 0.5, angle: 0.5, tilt: 0 },
|
|
1661
|
+
liftBase: stackLift(i, count, 0),
|
|
1662
|
+
liftSpan: stackLift(i, count, 1) - stackLift(i, count, 0)
|
|
1663
|
+
});
|
|
1664
|
+
this.group.add(mesh);
|
|
1665
|
+
}
|
|
1666
|
+
this.bindInput();
|
|
1667
|
+
}
|
|
1668
|
+
get position() {
|
|
1669
|
+
return this.current;
|
|
1670
|
+
}
|
|
1671
|
+
get turning() {
|
|
1672
|
+
return this.flights.length > 0 || this.drag !== null;
|
|
1673
|
+
}
|
|
1674
|
+
// closed, only the cover side can be grabbed
|
|
1675
|
+
get activeSide() {
|
|
1676
|
+
const busy = this.flights.length > 0 || this.drag !== null || this.hold !== null;
|
|
1677
|
+
if (busy) return "both";
|
|
1678
|
+
if (this.current === 0) return "right";
|
|
1679
|
+
if (this.current === this.sheets.length) return "left";
|
|
1680
|
+
return "both";
|
|
1681
|
+
}
|
|
1682
|
+
setPageTexture(page, texture) {
|
|
1683
|
+
const sheet = this.sheets[page >> 1];
|
|
1684
|
+
if (!sheet) return;
|
|
1685
|
+
if (page % 2 === 0) sheet.uniforms.uFront.value = texture;
|
|
1686
|
+
else sheet.uniforms.uBack.value = texture;
|
|
1687
|
+
this.dirty = true;
|
|
1688
|
+
}
|
|
1689
|
+
dispose() {
|
|
1690
|
+
this.cancelHold();
|
|
1691
|
+
this.abort.abort();
|
|
1692
|
+
for (const sheet of this.sheets) disposeSheetMesh(sheet.mesh);
|
|
1693
|
+
this.geometry.dispose();
|
|
1694
|
+
}
|
|
1695
|
+
// --- helpers ----------------------------------------------------------------
|
|
1696
|
+
randomWobble() {
|
|
1697
|
+
if (!this.settings.motion.jitter) return { arc: 0.5, angle: 0.5, tilt: 0 };
|
|
1698
|
+
return { arc: Math.random(), angle: Math.random(), tilt: 2 * Math.random() - 1 };
|
|
1699
|
+
}
|
|
1700
|
+
seconds(time) {
|
|
1701
|
+
return time / this.settings.motion.speed;
|
|
1702
|
+
}
|
|
1703
|
+
// how far a sheet may bend before its far corner rolls over itself
|
|
1704
|
+
arcLimit(angleDeg) {
|
|
1705
|
+
const t = Math.abs(angleDeg) * Math.PI / 180;
|
|
1706
|
+
const reach = Math.cos(t) / (1 - this.halfHeight * Math.sin(t));
|
|
1707
|
+
return CURVE.cornerRollMax / (Math.PI * reach);
|
|
1708
|
+
}
|
|
1709
|
+
restingArc(wobble, angleDeg) {
|
|
1710
|
+
return Math.min(CURVE.arc * this.settings.motion.curl * (1 - wobble.arc * CURVE.arcJitter), this.arcLimit(angleDeg));
|
|
1711
|
+
}
|
|
1712
|
+
inFlight(index) {
|
|
1713
|
+
return this.flights.some((f) => f.index === index);
|
|
1714
|
+
}
|
|
1715
|
+
// which sheet a press at this x would pick up
|
|
1716
|
+
sheetAt(clientX) {
|
|
1717
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1718
|
+
const spineX = this.spine.set(0, 0, 0).project(this.camera).x;
|
|
1719
|
+
const forward = clientX >= rect.left + (0.5 * spineX + 0.5) * rect.width;
|
|
1720
|
+
if (forward ? this.current >= this.sheets.length : this.current <= 0) return null;
|
|
1721
|
+
return { index: forward ? this.current : this.current - 1, forward };
|
|
1722
|
+
}
|
|
1723
|
+
// Where the hand is around the spine shapes the bend: near the top or bottom the fold
|
|
1724
|
+
// tilts, and further from the spine it tilts more.
|
|
1725
|
+
curveFor(clientX, clientY, index) {
|
|
1726
|
+
const wobble = this.sheets[index].wobble;
|
|
1727
|
+
const maxTilt = this.settings.motion.maxTilt;
|
|
1728
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
1729
|
+
this.ndc.set((clientX - rect.left) / rect.width * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
|
|
1730
|
+
this.raycaster.setFromCamera(this.ndc, this.camera);
|
|
1731
|
+
const hit = this.raycaster.ray.intersectPlane(this.floor, this.hit);
|
|
1732
|
+
let angleDeg = 0;
|
|
1733
|
+
if (hit) {
|
|
1734
|
+
let deg = Math.atan2(hit.y, hit.x) * 180 / Math.PI;
|
|
1735
|
+
if (deg > 90) deg = 180 - deg;
|
|
1736
|
+
if (deg < -90) deg = -180 - deg;
|
|
1737
|
+
const jitter = 1 - wobble.angle * CURVE.angleJitter;
|
|
1738
|
+
angleDeg = clamp2(
|
|
1739
|
+
-(deg / 90) * maxTilt * Math.hypot(hit.x, hit.y) * jitter + wobble.tilt * CURVE.tiltJitterDeg,
|
|
1740
|
+
-maxTilt,
|
|
1741
|
+
maxTilt
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
const leader = this.sheets[this.leader];
|
|
1745
|
+
const following = leader && this.leader !== index && this.inFlight(this.leader) && !(leader.direction > 0 ? leader.progress > 0.5 : leader.progress < 0.5);
|
|
1746
|
+
if (!following) return { arc: this.restingArc(wobble, angleDeg), angleDeg };
|
|
1747
|
+
const lead = leader.curveTarget;
|
|
1748
|
+
angleDeg = clamp2(angleDeg, Math.min(0, lead.angleDeg), Math.max(0, lead.angleDeg));
|
|
1749
|
+
const gain = THREE8.MathUtils.lerp(CLICK2.followArcGainMin, CLICK2.followArcGainMax, wobble.arc);
|
|
1750
|
+
return { arc: clamp2(lead.arc * gain, lead.arc, this.arcLimit(angleDeg)), angleDeg };
|
|
1751
|
+
}
|
|
1752
|
+
setCurve(sheet, curve, snap) {
|
|
1753
|
+
sheet.curveTarget.arc = curve.arc;
|
|
1754
|
+
sheet.curveTarget.angleDeg = curve.angleDeg;
|
|
1755
|
+
if (snap) {
|
|
1756
|
+
sheet.curve.arc = curve.arc;
|
|
1757
|
+
sheet.curve.angleDeg = curve.angleDeg;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
commit(index, toProgress) {
|
|
1761
|
+
this.leader = index;
|
|
1762
|
+
this.sheets[index].wobble = this.randomWobble();
|
|
1763
|
+
this.current = toProgress === 1 ? index + 1 : index;
|
|
1764
|
+
this.dirty = true;
|
|
1765
|
+
}
|
|
1766
|
+
fly(index, forward, toProgress, flipTime = CLICK2.flipTime) {
|
|
1767
|
+
this.commit(index, toProgress);
|
|
1768
|
+
const from = this.sheets[index].progress;
|
|
1769
|
+
this.flights = this.flights.filter((f) => f.index !== index);
|
|
1770
|
+
this.flights.push({
|
|
1771
|
+
index,
|
|
1772
|
+
from,
|
|
1773
|
+
to: toProgress,
|
|
1774
|
+
direction: forward ? 1 : -1,
|
|
1775
|
+
start: performance.now(),
|
|
1776
|
+
duration: 1e3 * this.seconds(flipTime) * Math.max(0.2, Math.abs(toProgress - from))
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
flip(forward) {
|
|
1780
|
+
if (forward ? this.current >= this.sheets.length : this.current <= 0) return false;
|
|
1781
|
+
const index = forward ? this.current : this.current - 1;
|
|
1782
|
+
if (this.drag?.index === index) return false;
|
|
1783
|
+
const sheet = this.sheets[index];
|
|
1784
|
+
if (!this.inFlight(index)) {
|
|
1785
|
+
sheet.wobble = this.randomWobble();
|
|
1786
|
+
const angleDeg = sheet.wobble.tilt * CURVE.tiltJitterDeg;
|
|
1787
|
+
this.setCurve(sheet, { arc: this.restingArc(sheet.wobble, angleDeg), angleDeg }, true);
|
|
1788
|
+
}
|
|
1789
|
+
sheet.direction = forward ? 1 : -1;
|
|
1790
|
+
this.fly(index, forward, forward ? 1 : 0);
|
|
1791
|
+
return true;
|
|
1792
|
+
}
|
|
1793
|
+
jumpTo(position) {
|
|
1794
|
+
this.cancelHold();
|
|
1795
|
+
this.drag = null;
|
|
1796
|
+
this.flights = [];
|
|
1797
|
+
this.current = clamp2(Math.round(position), 0, this.sheets.length);
|
|
1798
|
+
for (const sheet of this.sheets) {
|
|
1799
|
+
sheet.progress = sheet.index < this.current ? 1 : 0;
|
|
1800
|
+
sheet.direction = sheet.directionSmooth = 1;
|
|
1801
|
+
}
|
|
1802
|
+
this.dirty = true;
|
|
1803
|
+
}
|
|
1804
|
+
// --- input ------------------------------------------------------------------
|
|
1805
|
+
bindInput() {
|
|
1806
|
+
const el = this.hitArea;
|
|
1807
|
+
const signal = this.abort.signal;
|
|
1808
|
+
const { interaction } = this.settings;
|
|
1809
|
+
el.addEventListener(
|
|
1810
|
+
"pointermove",
|
|
1811
|
+
(e) => {
|
|
1812
|
+
if (this.press && e.pointerId !== this.press.id) return;
|
|
1813
|
+
if (this.hold && this.moved(e, this.hold.fired ? HOLD2.moveSlopPx : void 0)) this.cancelHold();
|
|
1814
|
+
if (this.drag && this.press) {
|
|
1815
|
+
if (!interaction.drag) return;
|
|
1816
|
+
const dx = e.clientX - this.press.x;
|
|
1817
|
+
this.drag.progress = clamp2(this.drag.base - dx / this.drag.rectWidth / this.settings.motion.dragDistance, 0, 1);
|
|
1818
|
+
this.drag.targetCurve = this.curveFor(e.clientX, e.clientY, this.drag.index);
|
|
1819
|
+
} else if (e.pointerType === "mouse") {
|
|
1820
|
+
this.hover = { active: true, x: e.clientX, y: e.clientY };
|
|
1821
|
+
}
|
|
1822
|
+
this.dirty = true;
|
|
1823
|
+
},
|
|
1824
|
+
{ signal }
|
|
1825
|
+
);
|
|
1826
|
+
el.addEventListener(
|
|
1827
|
+
"pointerleave",
|
|
1828
|
+
() => {
|
|
1829
|
+
this.hover.active = false;
|
|
1830
|
+
this.dirty = true;
|
|
1831
|
+
},
|
|
1832
|
+
{ signal }
|
|
1833
|
+
);
|
|
1834
|
+
el.addEventListener(
|
|
1835
|
+
"pointerdown",
|
|
1836
|
+
(e) => {
|
|
1837
|
+
if (this.press && !e.isPrimary) return;
|
|
1838
|
+
if (e.button !== 0 || !(interaction.drag || interaction.click || interaction.hold)) return;
|
|
1839
|
+
const target = this.sheetAt(e.clientX);
|
|
1840
|
+
if (!target) return;
|
|
1841
|
+
const sheet = this.sheets[target.index];
|
|
1842
|
+
if (!this.inFlight(target.index)) this.setCurve(sheet, this.curveFor(e.clientX, e.clientY, target.index), true);
|
|
1843
|
+
this.flights = this.flights.filter((f) => f.index !== target.index);
|
|
1844
|
+
this.press = { id: e.pointerId, x: e.clientX, y: e.clientY };
|
|
1845
|
+
this.drag = {
|
|
1846
|
+
index: target.index,
|
|
1847
|
+
forward: target.forward,
|
|
1848
|
+
base: sheet.progress,
|
|
1849
|
+
progress: sheet.progress,
|
|
1850
|
+
rectWidth: this.metrics.dragWidth,
|
|
1851
|
+
targetCurve: { ...sheet.curveTarget },
|
|
1852
|
+
speed: 0
|
|
1853
|
+
};
|
|
1854
|
+
sheet.direction = target.forward ? 1 : -1;
|
|
1855
|
+
this.cancelHold();
|
|
1856
|
+
if (interaction.hold) {
|
|
1857
|
+
this.hold = {
|
|
1858
|
+
forward: target.forward,
|
|
1859
|
+
x: e.clientX,
|
|
1860
|
+
y: e.clientY,
|
|
1861
|
+
count: 0,
|
|
1862
|
+
fired: false,
|
|
1863
|
+
timer: setTimeout(() => this.holdTick(), 1e3 * HOLD2.startTime)
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
try {
|
|
1867
|
+
el.setPointerCapture(e.pointerId);
|
|
1868
|
+
} catch {
|
|
1869
|
+
}
|
|
1870
|
+
this.dirty = true;
|
|
1871
|
+
},
|
|
1872
|
+
{ signal }
|
|
1873
|
+
);
|
|
1874
|
+
el.addEventListener("pointerup", (e) => this.release(e), { signal });
|
|
1875
|
+
el.addEventListener("pointercancel", (e) => this.release(e), { signal });
|
|
1876
|
+
el.addEventListener("lostpointercapture", (e) => this.release(e), { signal });
|
|
1877
|
+
}
|
|
1878
|
+
moved(e, slop = DRAG2.clickSlopPx) {
|
|
1879
|
+
return this.press !== null && (e.clientX - this.press.x) ** 2 + (e.clientY - this.press.y) ** 2 > slop * slop;
|
|
1880
|
+
}
|
|
1881
|
+
cancelHold() {
|
|
1882
|
+
if (this.hold) clearTimeout(this.hold.timer);
|
|
1883
|
+
this.hold = null;
|
|
1884
|
+
}
|
|
1885
|
+
// press and hold keeps turning pages, faster and faster
|
|
1886
|
+
holdTick() {
|
|
1887
|
+
const hold = this.hold;
|
|
1888
|
+
if (!hold) return;
|
|
1889
|
+
if (!hold.fired) {
|
|
1890
|
+
hold.fired = true;
|
|
1891
|
+
this.drag = null;
|
|
1892
|
+
}
|
|
1893
|
+
if (hold.forward ? this.current >= this.sheets.length : this.current <= 0) return this.cancelHold();
|
|
1894
|
+
const ramp = (from, to) => from + (to - from) * Math.min(hold.count / HOLD2.speedUpSheets, 1);
|
|
1895
|
+
const index = hold.forward ? this.current : this.current - 1;
|
|
1896
|
+
const sheet = this.sheets[index];
|
|
1897
|
+
this.setCurve(sheet, hold.count === 0 ? sheet.curveTarget : this.curveFor(hold.x, hold.y, index), true);
|
|
1898
|
+
sheet.direction = hold.forward ? 1 : -1;
|
|
1899
|
+
const flipTime = ramp(HOLD2.firstFlipTime, HOLD2.fastestFlipTime);
|
|
1900
|
+
this.fly(index, hold.forward, hold.forward ? 1 : 0, flipTime);
|
|
1901
|
+
hold.count++;
|
|
1902
|
+
hold.timer = setTimeout(
|
|
1903
|
+
() => this.holdTick(),
|
|
1904
|
+
1e3 * this.seconds(flipTime) * ramp(HOLD2.firstGapShare, HOLD2.fastestGapShare)
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
// pointercancel and lostpointercapture put the page back; only pointerup completes a turn
|
|
1908
|
+
release(e) {
|
|
1909
|
+
if (!this.press || e.pointerId !== this.press.id) return;
|
|
1910
|
+
const cancelled = e.type !== "pointerup";
|
|
1911
|
+
const wasMoved = this.moved(e);
|
|
1912
|
+
this.cancelHold();
|
|
1913
|
+
const drag = this.drag;
|
|
1914
|
+
this.drag = null;
|
|
1915
|
+
this.press = null;
|
|
1916
|
+
try {
|
|
1917
|
+
this.hitArea.releasePointerCapture(e.pointerId);
|
|
1918
|
+
} catch {
|
|
1919
|
+
}
|
|
1920
|
+
if (!drag) return;
|
|
1921
|
+
const { interaction } = this.settings;
|
|
1922
|
+
if (!wasMoved || !interaction.drag) {
|
|
1923
|
+
if (!cancelled && interaction.click) this.fly(drag.index, drag.forward, drag.forward ? 1 : 0);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const travelled = drag.forward ? drag.progress - drag.base : drag.base - drag.progress;
|
|
1927
|
+
const committed = !cancelled && travelled >= this.settings.motion.commitThreshold;
|
|
1928
|
+
const to = drag.forward === committed ? 1 : 0;
|
|
1929
|
+
this.commit(drag.index, to);
|
|
1930
|
+
const sheet = this.sheets[drag.index];
|
|
1931
|
+
const speed = to === 1 ? drag.speed : -drag.speed;
|
|
1932
|
+
const remaining = Math.max(Math.abs(to - sheet.progress), 1e-3);
|
|
1933
|
+
const duration = this.seconds(DRAG2.fallTime) * Math.pow(remaining, DRAG2.fallTimeExponent);
|
|
1934
|
+
this.flights = this.flights.filter((f) => f.index !== drag.index);
|
|
1935
|
+
this.flights.push({
|
|
1936
|
+
index: drag.index,
|
|
1937
|
+
from: sheet.progress,
|
|
1938
|
+
to,
|
|
1939
|
+
direction: drag.forward ? 1 : -1,
|
|
1940
|
+
start: performance.now(),
|
|
1941
|
+
duration: 1e3 * duration,
|
|
1942
|
+
launch: clamp2(speed * duration / remaining, 0, 2)
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
// --- simulation ---------------------------------------------------------------
|
|
1946
|
+
update(now) {
|
|
1947
|
+
for (let i = this.flights.length - 1; i >= 0; i--) {
|
|
1948
|
+
const f = this.flights[i];
|
|
1949
|
+
const sheet = this.sheets[f.index];
|
|
1950
|
+
const t = clamp2((now - f.start) / f.duration, 0, 1);
|
|
1951
|
+
let k;
|
|
1952
|
+
if (f.launch === void 0) {
|
|
1953
|
+
k = 0.5 * (1 - Math.cos(t * Math.PI));
|
|
1954
|
+
} else {
|
|
1955
|
+
const t2 = t * t;
|
|
1956
|
+
const t3 = t2 * t;
|
|
1957
|
+
k = (t3 - 2 * t2 + t) * f.launch + (3 * t2 - 2 * t3) + (t3 - t2) * DRAG2.landingSpeed;
|
|
1958
|
+
}
|
|
1959
|
+
sheet.progress = f.from + (f.to - f.from) * k;
|
|
1960
|
+
sheet.direction = f.direction;
|
|
1961
|
+
if (now >= f.start + f.duration) {
|
|
1962
|
+
this.flights.splice(i, 1);
|
|
1963
|
+
this.dirty = true;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
const dt = this.last ? Math.min((now - this.last) / 1e3, 0.1) : 1 / 60;
|
|
1967
|
+
this.last = now;
|
|
1968
|
+
let moving = this.flights.length > 0 || this.drag !== null;
|
|
1969
|
+
if (this.drag) {
|
|
1970
|
+
const sheet = this.sheets[this.drag.index];
|
|
1971
|
+
const step = (this.drag.progress - sheet.progress) * (1 - Math.pow(0.01, dt / DRAG2.progressSmoothTime));
|
|
1972
|
+
sheet.progress += step;
|
|
1973
|
+
this.drag.speed = dt > 0 ? step / dt : 0;
|
|
1974
|
+
this.setCurve(sheet, this.drag.targetCurve, false);
|
|
1975
|
+
}
|
|
1976
|
+
const { hoverLift } = this.settings.motion;
|
|
1977
|
+
const previous = this.hovered;
|
|
1978
|
+
const canHover = this.settings.interaction.hover && hoverLift > 0;
|
|
1979
|
+
const target = canHover && this.hover.active && !this.drag && !this.hold ? this.sheetAt(this.hover.x) : null;
|
|
1980
|
+
this.hovered = target && !this.inFlight(target.index) ? target.index : -1;
|
|
1981
|
+
if (this.hovered !== -1) {
|
|
1982
|
+
const sheet = this.sheets[this.hovered];
|
|
1983
|
+
this.setCurve(sheet, this.curveFor(this.hover.x, this.hover.y, this.hovered), this.hovered !== previous);
|
|
1984
|
+
}
|
|
1985
|
+
const toward = approach(HOVER.smoothTime, dt);
|
|
1986
|
+
const directionStep = approach(CURVE.directionSmoothTime, dt);
|
|
1987
|
+
const curveStep = approach(CURVE.smoothTime, dt);
|
|
1988
|
+
for (const sheet of this.sheets) {
|
|
1989
|
+
const i = sheet.index;
|
|
1990
|
+
if (!this.inFlight(i) && this.drag?.index !== i) {
|
|
1991
|
+
const rest = i >= this.current ? 0 : 1;
|
|
1992
|
+
const goal = i === this.hovered ? Math.abs(rest - hoverLift) : rest;
|
|
1993
|
+
const diff = goal - sheet.progress;
|
|
1994
|
+
if (Math.abs(diff) < CURVE.settleEpsilon) {
|
|
1995
|
+
if (sheet.progress !== goal) {
|
|
1996
|
+
sheet.progress = goal;
|
|
1997
|
+
this.dirty = true;
|
|
1998
|
+
}
|
|
1999
|
+
} else {
|
|
2000
|
+
sheet.progress += diff * toward;
|
|
2001
|
+
moving = true;
|
|
2002
|
+
}
|
|
2003
|
+
const above = this.sheets[rest === 0 ? i - 1 : i + 1];
|
|
2004
|
+
if (above) {
|
|
2005
|
+
const bounded = rest === 0 ? Math.min(sheet.progress, Math.max(0, above.progress - HOVER.gapToSheetAbove)) : Math.max(sheet.progress, Math.min(1, above.progress + HOVER.gapToSheetAbove));
|
|
2006
|
+
if (bounded !== sheet.progress) {
|
|
2007
|
+
sheet.progress = bounded;
|
|
2008
|
+
moving = true;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
sheet.direction = rest === 1 && sheet.progress !== rest ? -1 : 1;
|
|
2012
|
+
sheet.directionSmooth = sheet.direction;
|
|
2013
|
+
}
|
|
2014
|
+
const dd = sheet.direction - sheet.directionSmooth;
|
|
2015
|
+
if (Math.abs(dd) < CURVE.settleEpsilon) {
|
|
2016
|
+
if (sheet.directionSmooth !== sheet.direction) {
|
|
2017
|
+
sheet.directionSmooth = sheet.direction;
|
|
2018
|
+
this.dirty = true;
|
|
2019
|
+
}
|
|
2020
|
+
} else {
|
|
2021
|
+
sheet.directionSmooth += dd * directionStep;
|
|
2022
|
+
moving = true;
|
|
2023
|
+
}
|
|
2024
|
+
for (const key of ["arc", "angleDeg"]) {
|
|
2025
|
+
const d = sheet.curveTarget[key] - sheet.curve[key];
|
|
2026
|
+
if (Math.abs(d) < CURVE.settleEpsilon) sheet.curve[key] = sheet.curveTarget[key];
|
|
2027
|
+
else {
|
|
2028
|
+
sheet.curve[key] += d * curveStep;
|
|
2029
|
+
moving = true;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
this.applyUniforms(sheet);
|
|
2033
|
+
}
|
|
2034
|
+
if (!moving && !this.dirty) return false;
|
|
2035
|
+
let contact = null;
|
|
2036
|
+
let upright = 0;
|
|
2037
|
+
for (const sheet of this.sheets) {
|
|
2038
|
+
const i = sheet.index;
|
|
2039
|
+
const depth = i >= this.current ? i - this.current : this.current - 1 - i;
|
|
2040
|
+
const turning = this.inFlight(i) || this.drag?.index === i;
|
|
2041
|
+
sheet.mesh.castShadow = this.settings.lighting.shadows && (depth < 3 || turning);
|
|
2042
|
+
layerSheet(sheet.mesh, depth, this.sheets.length, turning);
|
|
2043
|
+
const lift = Math.abs(sheet.uniforms.uFlipRotation.value.y);
|
|
2044
|
+
if (turning && lift > upright) {
|
|
2045
|
+
upright = lift;
|
|
2046
|
+
contact = sheet;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
const rotation = contact?.uniforms.uFlipRotation.value;
|
|
2050
|
+
if (rotation) this.shading.spineContact.value.set(rotation.x, rotation.y, 1);
|
|
2051
|
+
else this.shading.spineContact.value.set(1, 0, 0);
|
|
2052
|
+
this.dirty = false;
|
|
2053
|
+
return true;
|
|
2054
|
+
}
|
|
2055
|
+
applyUniforms(sheet) {
|
|
2056
|
+
const u = sheet.uniforms;
|
|
2057
|
+
const half = this.halfHeight;
|
|
2058
|
+
const turn = sheet.progress * Math.PI;
|
|
2059
|
+
const bend = Math.sin(turn) * Math.PI * sheet.curve.arc;
|
|
2060
|
+
const tilt = sheet.curve.angleDeg * Math.PI / 180;
|
|
2061
|
+
const cos = Math.cos(tilt);
|
|
2062
|
+
const sin = Math.sin(tilt);
|
|
2063
|
+
const curlLength = 1 - half * Math.sin(Math.abs(tilt));
|
|
2064
|
+
const curlStart = 1 - curlLength;
|
|
2065
|
+
const reach = Math.max(0, cos + sin * half * Math.sign(sin) - curlStart);
|
|
2066
|
+
const tipAngle = reach / curlLength * bend;
|
|
2067
|
+
const tipLift = reach * (Math.abs(tipAngle) < 1e-4 ? 0 : (1 - Math.cos(tipAngle)) / tipAngle);
|
|
2068
|
+
const rotation = turn + 0.5 * sheet.directionSmooth * tipLift;
|
|
2069
|
+
u.uFlipProgress.value = sheet.progress;
|
|
2070
|
+
u.uWrinkleSide.value = -Math.cos(turn);
|
|
2071
|
+
u.uDirection.value = sheet.directionSmooth;
|
|
2072
|
+
u.uStackLift.value = sheet.liftBase + sheet.liftSpan * sheet.progress;
|
|
2073
|
+
u.uBendAngle.value = bend;
|
|
2074
|
+
u.uFold.value.set(cos, sin);
|
|
2075
|
+
u.uCurl.value.set(curlStart, curlLength);
|
|
2076
|
+
u.uFlipRotation.value.set(Math.cos(rotation), Math.sin(rotation));
|
|
2077
|
+
}
|
|
2078
|
+
};
|
|
2079
|
+
|
|
2080
|
+
// src/textures.ts
|
|
2081
|
+
import * as THREE9 from "three";
|
|
2082
|
+
var CONCURRENCY = 4;
|
|
2083
|
+
var PageLoader = class {
|
|
2084
|
+
constructor(sources, renderer, radius, events) {
|
|
2085
|
+
this.sources = sources;
|
|
2086
|
+
this.renderer = renderer;
|
|
2087
|
+
this.radius = radius;
|
|
2088
|
+
this.events = events;
|
|
2089
|
+
this.textures = new Array(sources.length);
|
|
2090
|
+
}
|
|
2091
|
+
sources;
|
|
2092
|
+
renderer;
|
|
2093
|
+
radius;
|
|
2094
|
+
events;
|
|
2095
|
+
/** Loaded textures by page; missing pages are still loading, unloaded or failed. */
|
|
2096
|
+
textures;
|
|
2097
|
+
owned = /* @__PURE__ */ new Set();
|
|
2098
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
2099
|
+
failed = /* @__PURE__ */ new Set();
|
|
2100
|
+
// `first` pages load before the rest; the rest go nearest first
|
|
2101
|
+
first = [];
|
|
2102
|
+
// the visible pages
|
|
2103
|
+
from = 0;
|
|
2104
|
+
to = 0;
|
|
2105
|
+
// whether the magazine loops, putting the cover right after the last page
|
|
2106
|
+
wrap = false;
|
|
2107
|
+
waiting = /* @__PURE__ */ new Set();
|
|
2108
|
+
settleFirst = () => {
|
|
2109
|
+
};
|
|
2110
|
+
disposed = false;
|
|
2111
|
+
/**
|
|
2112
|
+
* Starts loading around the visible pages `from`–`to`. Resolves once the `first` pages
|
|
2113
|
+
* within reach are in (or failed).
|
|
2114
|
+
*/
|
|
2115
|
+
start(from, to, first) {
|
|
2116
|
+
this.from = from;
|
|
2117
|
+
this.to = to;
|
|
2118
|
+
this.first = first.filter((i) => i >= 0 && i < this.sources.length && this.distance(i) <= this.radius);
|
|
2119
|
+
this.waiting = new Set(this.first);
|
|
2120
|
+
const ready = new Promise((resolve) => this.settleFirst = resolve);
|
|
2121
|
+
this.focus(from, to);
|
|
2122
|
+
if (!this.waiting.size) this.settleFirst();
|
|
2123
|
+
return ready;
|
|
2124
|
+
}
|
|
2125
|
+
/** Follows the reader: loads around the visible pages and unloads pages left far behind. */
|
|
2126
|
+
focus(from, to = from) {
|
|
2127
|
+
if (this.disposed) return;
|
|
2128
|
+
this.from = from;
|
|
2129
|
+
this.to = to;
|
|
2130
|
+
const keep = 2 * this.radius;
|
|
2131
|
+
for (const [i, request] of this.inFlight) {
|
|
2132
|
+
if (this.distance(i) <= keep) continue;
|
|
2133
|
+
request.abort();
|
|
2134
|
+
this.inFlight.delete(i);
|
|
2135
|
+
}
|
|
2136
|
+
this.textures.forEach((texture, i) => {
|
|
2137
|
+
if (!texture || this.distance(i) <= keep || !this.owned.has(texture)) return;
|
|
2138
|
+
this.textures[i] = void 0;
|
|
2139
|
+
this.events.onUnload(i);
|
|
2140
|
+
this.owned.delete(texture);
|
|
2141
|
+
texture.dispose();
|
|
2142
|
+
});
|
|
2143
|
+
this.pump();
|
|
2144
|
+
}
|
|
2145
|
+
setWrap(wrap) {
|
|
2146
|
+
if (wrap === this.wrap) return;
|
|
2147
|
+
this.wrap = wrap;
|
|
2148
|
+
this.focus(this.from, this.to);
|
|
2149
|
+
}
|
|
2150
|
+
setRadius(radius) {
|
|
2151
|
+
this.radius = radius;
|
|
2152
|
+
this.focus(this.from, this.to);
|
|
2153
|
+
}
|
|
2154
|
+
/** Loads the pages that failed again. */
|
|
2155
|
+
retry() {
|
|
2156
|
+
this.failed.clear();
|
|
2157
|
+
this.pump();
|
|
2158
|
+
}
|
|
2159
|
+
dispose() {
|
|
2160
|
+
this.disposed = true;
|
|
2161
|
+
for (const request of this.inFlight.values()) request.abort();
|
|
2162
|
+
for (const texture of this.owned) texture.dispose();
|
|
2163
|
+
this.owned.clear();
|
|
2164
|
+
}
|
|
2165
|
+
// pages away from the visible ones
|
|
2166
|
+
distance(page) {
|
|
2167
|
+
const straight = page < this.from ? this.from - page : page > this.to ? page - this.to : 0;
|
|
2168
|
+
if (!this.wrap) return straight;
|
|
2169
|
+
const count = this.sources.length;
|
|
2170
|
+
return Math.min(straight, page + count - this.to, this.from + count - page);
|
|
2171
|
+
}
|
|
2172
|
+
// starts the most wanted pages while there's room
|
|
2173
|
+
pump() {
|
|
2174
|
+
if (this.disposed || this.inFlight.size >= CONCURRENCY) return;
|
|
2175
|
+
const wanted = (i) => !this.textures[i] && !this.inFlight.has(i) && !this.failed.has(i) && this.distance(i) <= this.radius;
|
|
2176
|
+
const queue = [
|
|
2177
|
+
...this.first.filter((i) => this.waiting.has(i)),
|
|
2178
|
+
...this.sources.map((_, i) => i).sort((a, b) => this.distance(a) - this.distance(b))
|
|
2179
|
+
];
|
|
2180
|
+
for (const page of queue) {
|
|
2181
|
+
if (this.inFlight.size >= CONCURRENCY) return;
|
|
2182
|
+
if (wanted(page)) this.load(page);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
load(page) {
|
|
2186
|
+
const request = new AbortController();
|
|
2187
|
+
this.inFlight.set(page, request);
|
|
2188
|
+
this.createTexture(this.sources[page], request.signal).then((texture) => {
|
|
2189
|
+
if (this.disposed || request.signal.aborted) {
|
|
2190
|
+
if (this.owned.delete(texture)) texture.dispose();
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
texture.colorSpace = THREE9.SRGBColorSpace;
|
|
2194
|
+
texture.premultiplyAlpha = true;
|
|
2195
|
+
texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
|
|
2196
|
+
texture.needsUpdate = true;
|
|
2197
|
+
this.renderer.initTexture(texture);
|
|
2198
|
+
this.textures[page] = texture;
|
|
2199
|
+
this.events.onLoad(page, texture);
|
|
2200
|
+
}).catch((error) => {
|
|
2201
|
+
if (this.disposed || request.signal.aborted) return;
|
|
2202
|
+
this.failed.add(page);
|
|
2203
|
+
this.events.onError(page, error);
|
|
2204
|
+
}).finally(() => {
|
|
2205
|
+
if (this.inFlight.get(page) === request) this.inFlight.delete(page);
|
|
2206
|
+
if (this.waiting.delete(page) && !this.waiting.size) this.settleFirst();
|
|
2207
|
+
this.pump();
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
async createTexture(source, signal) {
|
|
2211
|
+
if (source instanceof THREE9.Texture) return source;
|
|
2212
|
+
const own = (texture) => (this.owned.add(texture), texture);
|
|
2213
|
+
if (typeof source === "string") return own(new THREE9.Texture(await loadImage(source, signal)));
|
|
2214
|
+
if (typeof HTMLCanvasElement !== "undefined" && source instanceof HTMLCanvasElement) return own(new THREE9.CanvasTexture(source));
|
|
2215
|
+
if (typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement) {
|
|
2216
|
+
if (!source.complete || !source.naturalWidth) await source.decode();
|
|
2217
|
+
return own(new THREE9.Texture(source));
|
|
2218
|
+
}
|
|
2219
|
+
const canvas = document.createElement("canvas");
|
|
2220
|
+
canvas.width = source.width;
|
|
2221
|
+
canvas.height = source.height;
|
|
2222
|
+
canvas.getContext("2d").drawImage(source, 0, 0);
|
|
2223
|
+
return own(new THREE9.CanvasTexture(canvas));
|
|
2224
|
+
}
|
|
2225
|
+
};
|
|
2226
|
+
function loadImage(url, signal) {
|
|
2227
|
+
return new Promise((resolve, reject) => {
|
|
2228
|
+
const img = new Image();
|
|
2229
|
+
img.crossOrigin = "anonymous";
|
|
2230
|
+
img.decoding = "async";
|
|
2231
|
+
const onAbort = () => {
|
|
2232
|
+
done();
|
|
2233
|
+
img.src = "";
|
|
2234
|
+
reject(new DOMException("Page no longer needed", "AbortError"));
|
|
2235
|
+
};
|
|
2236
|
+
const done = () => {
|
|
2237
|
+
img.onload = img.onerror = null;
|
|
2238
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2239
|
+
};
|
|
2240
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2241
|
+
img.onload = () => {
|
|
2242
|
+
done();
|
|
2243
|
+
img.decode().then(() => resolve(img), () => resolve(img));
|
|
2244
|
+
};
|
|
2245
|
+
img.onerror = () => {
|
|
2246
|
+
done();
|
|
2247
|
+
reject(new Error(`Failed to load page image: ${url}`));
|
|
2248
|
+
};
|
|
2249
|
+
img.src = url;
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
async function loadGrain(source) {
|
|
2253
|
+
let texture;
|
|
2254
|
+
if (typeof source === "string") texture = new THREE9.Texture(await loadImage(source));
|
|
2255
|
+
else if (source instanceof HTMLCanvasElement) texture = new THREE9.CanvasTexture(source);
|
|
2256
|
+
else {
|
|
2257
|
+
if (!source.complete || !source.naturalWidth) await source.decode();
|
|
2258
|
+
texture = new THREE9.Texture(source);
|
|
2259
|
+
}
|
|
2260
|
+
texture.wrapS = texture.wrapT = THREE9.RepeatWrapping;
|
|
2261
|
+
texture.needsUpdate = true;
|
|
2262
|
+
return texture;
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
// src/LeafThrough.ts
|
|
2266
|
+
var LIVE_KEYS = /* @__PURE__ */ new Set([
|
|
2267
|
+
"onPageChange",
|
|
2268
|
+
"onReady",
|
|
2269
|
+
"onPageLoad",
|
|
2270
|
+
"onError",
|
|
2271
|
+
"formatPageLabel",
|
|
2272
|
+
"label",
|
|
2273
|
+
"startPage",
|
|
2274
|
+
"preload",
|
|
2275
|
+
"pages"
|
|
2276
|
+
]);
|
|
2277
|
+
var DEFAULT_PRELOAD = 6;
|
|
2278
|
+
var isClient = typeof window !== "undefined";
|
|
2279
|
+
var LeafThrough = class extends EventTarget {
|
|
2280
|
+
container;
|
|
2281
|
+
/** The element the magazine renders into; focusable, labelled, and styleable via `[data-leafthrough]`. */
|
|
2282
|
+
element;
|
|
2283
|
+
options;
|
|
2284
|
+
canvas;
|
|
2285
|
+
hitArea;
|
|
2286
|
+
live;
|
|
2287
|
+
content;
|
|
2288
|
+
// null when the browser can't run WebGL; the magazine then shows plain pages
|
|
2289
|
+
renderer;
|
|
2290
|
+
frameRequest = 0;
|
|
2291
|
+
blank;
|
|
2292
|
+
backBlank;
|
|
2293
|
+
pattern = null;
|
|
2294
|
+
loader = null;
|
|
2295
|
+
view = null;
|
|
2296
|
+
spreadMedia = null;
|
|
2297
|
+
motionMedia;
|
|
2298
|
+
abort = new AbortController();
|
|
2299
|
+
resizeObserver;
|
|
2300
|
+
metrics = { dragWidth: 1 };
|
|
2301
|
+
hitBox = null;
|
|
2302
|
+
hitSide = "both";
|
|
2303
|
+
intersection;
|
|
2304
|
+
visible = true;
|
|
2305
|
+
needsRender = true;
|
|
2306
|
+
running = false;
|
|
2307
|
+
// an animated goTo(): turn one page every `interval` ms until `target`
|
|
2308
|
+
riffle = null;
|
|
2309
|
+
lastPagesKey = "";
|
|
2310
|
+
ready = false;
|
|
2311
|
+
disposed = false;
|
|
2312
|
+
constructor(container, options) {
|
|
2313
|
+
super();
|
|
2314
|
+
if (!isClient) throw new Error("LeafThrough needs a browser; create it on the client.");
|
|
2315
|
+
this.container = container;
|
|
2316
|
+
this.options = snapshot(options);
|
|
2317
|
+
const element = this.element = document.createElement("div");
|
|
2318
|
+
element.dataset.leafthrough = "";
|
|
2319
|
+
element.dataset.state = "loading";
|
|
2320
|
+
element.tabIndex = 0;
|
|
2321
|
+
element.setAttribute("role", "region");
|
|
2322
|
+
element.setAttribute("aria-roledescription", "magazine");
|
|
2323
|
+
element.style.cssText = "position:relative;width:100%;height:100%;outline-offset:4px;touch-action:pan-y pinch-zoom;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none";
|
|
2324
|
+
this.canvas = document.createElement("canvas");
|
|
2325
|
+
this.canvas.style.cssText = "position:absolute;display:block;pointer-events:none;opacity:0;transition:opacity .4s";
|
|
2326
|
+
this.canvas.setAttribute("aria-hidden", "true");
|
|
2327
|
+
this.hitArea = document.createElement("div");
|
|
2328
|
+
this.hitArea.dataset.leafthroughHitArea = "";
|
|
2329
|
+
this.hitArea.style.cssText = "position:absolute;cursor:grab;touch-action:pan-y pinch-zoom";
|
|
2330
|
+
this.live = document.createElement("div");
|
|
2331
|
+
this.live.setAttribute("aria-live", "polite");
|
|
2332
|
+
this.live.style.cssText = VISUALLY_HIDDEN;
|
|
2333
|
+
this.content = new PageContent({
|
|
2334
|
+
onLoad: (page) => !this.disposed && this.pageLoaded(page),
|
|
2335
|
+
onError: (page, error) => !this.disposed && this.fail(error, page),
|
|
2336
|
+
onShown: () => {
|
|
2337
|
+
if (!this.plain) return;
|
|
2338
|
+
this.ready = true;
|
|
2339
|
+
this.needsRender = true;
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
2342
|
+
element.append(this.canvas, this.content.element, this.hitArea, this.live);
|
|
2343
|
+
this.renderer = createRenderer(this.canvas);
|
|
2344
|
+
container.append(element);
|
|
2345
|
+
this.blank = solidTexture("#fcfcf9");
|
|
2346
|
+
const backColor = options.paper?.backColor ?? "#fcfcf9";
|
|
2347
|
+
this.backBlank = { texture: solidTexture(backColor), color: colorKey(backColor) };
|
|
2348
|
+
const signal = this.abort.signal;
|
|
2349
|
+
this.watchSpreadQuery();
|
|
2350
|
+
this.motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
2351
|
+
this.motionMedia.addEventListener("change", () => this.remount(), { signal });
|
|
2352
|
+
this.resizeObserver = new ResizeObserver(() => this.layoutBox());
|
|
2353
|
+
this.resizeObserver.observe(container);
|
|
2354
|
+
this.intersection = new IntersectionObserver(
|
|
2355
|
+
(entries) => {
|
|
2356
|
+
this.visible = entries[entries.length - 1].isIntersecting;
|
|
2357
|
+
this.syncLoop();
|
|
2358
|
+
},
|
|
2359
|
+
{ rootMargin: "50% 0px" }
|
|
2360
|
+
);
|
|
2361
|
+
this.intersection.observe(element);
|
|
2362
|
+
document.addEventListener("visibilitychange", () => this.syncLoop(), { signal });
|
|
2363
|
+
window.addEventListener("resize", () => this.layoutBox(), { signal });
|
|
2364
|
+
window.addEventListener("online", () => this.retry(), { signal });
|
|
2365
|
+
this.canvas.addEventListener("webglcontextlost", (e) => e.preventDefault(), { signal });
|
|
2366
|
+
this.canvas.addEventListener("webglcontextrestored", () => this.needsRender = true, { signal });
|
|
2367
|
+
this.bindKeyboard();
|
|
2368
|
+
this.applyLabel();
|
|
2369
|
+
this.remount(options.startPage ?? 0);
|
|
2370
|
+
this.syncLoop();
|
|
2371
|
+
}
|
|
2372
|
+
// --- public API -----------------------------------------------------------------
|
|
2373
|
+
/** First visible page, 0-based. */
|
|
2374
|
+
get page() {
|
|
2375
|
+
return this.detail().page;
|
|
2376
|
+
}
|
|
2377
|
+
get pageCount() {
|
|
2378
|
+
return this.options.pages.length;
|
|
2379
|
+
}
|
|
2380
|
+
get layout() {
|
|
2381
|
+
return this.view?.settings.layout ?? this.pickLayout();
|
|
2382
|
+
}
|
|
2383
|
+
get visiblePages() {
|
|
2384
|
+
return this.detail().visiblePages;
|
|
2385
|
+
}
|
|
2386
|
+
/** True while showing flat pages: the `plain` option is on, or the browser can't run WebGL. */
|
|
2387
|
+
get plain() {
|
|
2388
|
+
return !this.renderer || !!this.options.plain;
|
|
2389
|
+
}
|
|
2390
|
+
/** True while a page is being dragged or is completing its turn. */
|
|
2391
|
+
get turning() {
|
|
2392
|
+
return this.view?.engine.turning ?? false;
|
|
2393
|
+
}
|
|
2394
|
+
/** Turns to the next page. Returns false at the end. */
|
|
2395
|
+
next() {
|
|
2396
|
+
this.stopRiffle();
|
|
2397
|
+
return this.view?.engine.flip(true) ?? false;
|
|
2398
|
+
}
|
|
2399
|
+
/** Turns to the previous page. Returns false at the start. */
|
|
2400
|
+
prev() {
|
|
2401
|
+
this.stopRiffle();
|
|
2402
|
+
return this.view?.engine.flip(false) ?? false;
|
|
2403
|
+
}
|
|
2404
|
+
/** Shows `page` (0-based), jumping there or turning the pages one by one. */
|
|
2405
|
+
goTo(page, { animate = false } = {}) {
|
|
2406
|
+
if (!this.view || Number.isNaN(page)) return;
|
|
2407
|
+
this.stopRiffle();
|
|
2408
|
+
const engine = this.view.engine;
|
|
2409
|
+
const target = this.pageToPosition(page, this.view.settings.layout);
|
|
2410
|
+
if (!animate) {
|
|
2411
|
+
engine.jumpTo(target);
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
if (engine.position === target) return;
|
|
2415
|
+
this.riffle = { target, interval: 140 / this.view.settings.motion.speed, last: -Infinity };
|
|
2416
|
+
this.needsRender = true;
|
|
2417
|
+
}
|
|
2418
|
+
/** Loads the pages that failed again. Also happens by itself when the browser comes back online. */
|
|
2419
|
+
retry() {
|
|
2420
|
+
this.loader?.retry();
|
|
2421
|
+
if (this.plain) this.content.refresh();
|
|
2422
|
+
}
|
|
2423
|
+
/** Changes some options and keeps the rest. Structural changes rebuild the scene and keep the current page. */
|
|
2424
|
+
update(options) {
|
|
2425
|
+
this.applyOptions({ ...this.options, ...options });
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Replaces every option at once: anything left out goes back to its default. Bindings
|
|
2429
|
+
* use this, since props describe the whole state. Structural changes rebuild the scene
|
|
2430
|
+
* and keep the current page.
|
|
2431
|
+
*/
|
|
2432
|
+
setOptions(options) {
|
|
2433
|
+
this.applyOptions(options);
|
|
2434
|
+
}
|
|
2435
|
+
applyOptions(options) {
|
|
2436
|
+
if (this.disposed) return;
|
|
2437
|
+
const previous = this.options;
|
|
2438
|
+
const next = this.options = snapshot(options);
|
|
2439
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(next)]);
|
|
2440
|
+
const changed = [...keys].filter((key) => !deepEqual(previous[key], next[key]));
|
|
2441
|
+
if (!changed.length) return;
|
|
2442
|
+
if (changed.includes("label") || changed.includes("formatPageLabel")) this.applyLabel();
|
|
2443
|
+
if (changed.includes("spreadQuery")) this.watchSpreadQuery();
|
|
2444
|
+
const page = this.page;
|
|
2445
|
+
let rebuild = changed.some((key) => !LIVE_KEYS.has(key));
|
|
2446
|
+
if (changed.includes("pages") && this.pagesKey(next) !== this.lastPagesKey) {
|
|
2447
|
+
this.loader?.dispose();
|
|
2448
|
+
this.loader = null;
|
|
2449
|
+
this.resetReady();
|
|
2450
|
+
rebuild = true;
|
|
2451
|
+
}
|
|
2452
|
+
if (changed.includes("preload")) this.loader?.setRadius(this.preloadRadius());
|
|
2453
|
+
if (rebuild) this.remount(page);
|
|
2454
|
+
}
|
|
2455
|
+
addEventListener(type, listener, options) {
|
|
2456
|
+
super.addEventListener(type, listener, options);
|
|
2457
|
+
}
|
|
2458
|
+
removeEventListener(type, listener, options) {
|
|
2459
|
+
super.removeEventListener(type, listener, options);
|
|
2460
|
+
}
|
|
2461
|
+
dispose() {
|
|
2462
|
+
if (this.disposed) return;
|
|
2463
|
+
this.disposed = true;
|
|
2464
|
+
this.stopRiffle();
|
|
2465
|
+
this.setLoop(null);
|
|
2466
|
+
this.abort.abort();
|
|
2467
|
+
this.spreadMedia?.abort.abort();
|
|
2468
|
+
this.resizeObserver.disconnect();
|
|
2469
|
+
this.intersection.disconnect();
|
|
2470
|
+
this.view?.dispose();
|
|
2471
|
+
this.view = null;
|
|
2472
|
+
this.loader?.dispose();
|
|
2473
|
+
this.pattern?.texture.dispose();
|
|
2474
|
+
this.blank.dispose();
|
|
2475
|
+
this.backBlank.texture.dispose();
|
|
2476
|
+
this.renderer?.dispose();
|
|
2477
|
+
this.renderer?.forceContextLoss();
|
|
2478
|
+
this.element.remove();
|
|
2479
|
+
}
|
|
2480
|
+
// --- layout and scene -------------------------------------------------------------
|
|
2481
|
+
pickLayout() {
|
|
2482
|
+
const layout = this.options.layout ?? "auto";
|
|
2483
|
+
if (layout !== "auto") return layout;
|
|
2484
|
+
return this.spreadMedia?.list.matches ? "spread" : "single";
|
|
2485
|
+
}
|
|
2486
|
+
watchSpreadQuery() {
|
|
2487
|
+
this.spreadMedia?.abort.abort();
|
|
2488
|
+
const list = window.matchMedia(this.options.spreadQuery ?? DEFAULT_SPREAD_QUERY);
|
|
2489
|
+
const abort = new AbortController();
|
|
2490
|
+
list.addEventListener("change", () => this.remount(), { signal: abort.signal });
|
|
2491
|
+
this.spreadMedia = { list, abort };
|
|
2492
|
+
}
|
|
2493
|
+
remount(page = this.page) {
|
|
2494
|
+
if (this.disposed) return;
|
|
2495
|
+
this.stopRiffle();
|
|
2496
|
+
const wasPlain = this.view?.engine instanceof PlainEngine;
|
|
2497
|
+
this.view?.dispose();
|
|
2498
|
+
const layout = this.pickLayout();
|
|
2499
|
+
const settings = resolveSettings(this.options, layout, this.motionMedia.matches);
|
|
2500
|
+
this.element.dataset.layout = layout;
|
|
2501
|
+
this.lastPagesKey = this.pagesKey(this.options);
|
|
2502
|
+
this.view = this.renderer && !this.options.plain ? this.createScene(this.renderer, page, layout, settings) : this.createPlain(page, layout, settings, wasPlain);
|
|
2503
|
+
this.layoutBox();
|
|
2504
|
+
this.needsRender = true;
|
|
2505
|
+
}
|
|
2506
|
+
createPlain(page, layout, settings, wasPlain) {
|
|
2507
|
+
this.loader?.dispose();
|
|
2508
|
+
this.loader = null;
|
|
2509
|
+
if (!wasPlain) this.resetReady();
|
|
2510
|
+
this.element.dataset.plain = "";
|
|
2511
|
+
this.canvas.style.display = "none";
|
|
2512
|
+
const { drag, click } = settings.interaction;
|
|
2513
|
+
this.hitArea.style.cursor = drag || click ? "pointer" : "default";
|
|
2514
|
+
const engine = new PlainEngine({
|
|
2515
|
+
hitArea: this.hitArea,
|
|
2516
|
+
settings,
|
|
2517
|
+
pageCount: this.options.pages.length,
|
|
2518
|
+
position: this.pageToPosition(page, layout)
|
|
2519
|
+
});
|
|
2520
|
+
return {
|
|
2521
|
+
engine,
|
|
2522
|
+
scene: null,
|
|
2523
|
+
camera: null,
|
|
2524
|
+
framing: plainFramingFor(layout, settings.pageAspect),
|
|
2525
|
+
settings,
|
|
2526
|
+
dispose: () => engine.dispose()
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
createScene(renderer, page, layout, settings) {
|
|
2530
|
+
delete this.element.dataset.plain;
|
|
2531
|
+
this.canvas.style.display = "block";
|
|
2532
|
+
this.content.place(null);
|
|
2533
|
+
if (!this.loader) this.loadPages(renderer, page, layout);
|
|
2534
|
+
this.loader?.setWrap(layout === "single" && settings.loop);
|
|
2535
|
+
const framing = framingFor(layout, settings.pageAspect);
|
|
2536
|
+
const { drag, click, hold } = settings.interaction;
|
|
2537
|
+
this.hitArea.style.cursor = drag ? "grab" : click || hold ? "pointer" : "default";
|
|
2538
|
+
const scene = new THREE10.Scene();
|
|
2539
|
+
const camera = new THREE10.PerspectiveCamera(40, 1, Math.max(0.1, framing.distance - 1.8), framing.distance + 2);
|
|
2540
|
+
camera.position.set(framing.eye.x, framing.eye.y, framing.distance);
|
|
2541
|
+
camera.lookAt(framing.eye.x, framing.eye.y, 0);
|
|
2542
|
+
const { ambient, sun: sunOptions, shadows } = settings.lighting;
|
|
2543
|
+
scene.add(new THREE10.HemisphereLight(ambient.sky, ambient.ground, ambient.intensity));
|
|
2544
|
+
const sun = new THREE10.DirectionalLight(sunOptions.color, sunOptions.intensity);
|
|
2545
|
+
sun.position.set(...sunOptions.position);
|
|
2546
|
+
sun.castShadow = shadows;
|
|
2547
|
+
if (layout === "single") sun.shadow.intensity = 0.4;
|
|
2548
|
+
sun.shadow.mapSize.set(settings.lighting.shadowMapSize, settings.lighting.shadowMapSize);
|
|
2549
|
+
Object.assign(sun.shadow.camera, framing.shadow, { near: 0.5, far: 12 });
|
|
2550
|
+
sun.shadow.camera.updateProjectionMatrix();
|
|
2551
|
+
sun.shadow.bias = -1e-3;
|
|
2552
|
+
scene.add(sun, sun.target);
|
|
2553
|
+
const shading = {
|
|
2554
|
+
spineContact: { value: new THREE10.Vector3(1, 0, 0) },
|
|
2555
|
+
lightDirection: { value: sun.position.clone().sub(sun.target.position).normalize() }
|
|
2556
|
+
};
|
|
2557
|
+
let floor = null;
|
|
2558
|
+
if (layout === "spread" && shadows && settings.lighting.floorShadow > 0) {
|
|
2559
|
+
floor = new THREE10.Mesh(new THREE10.PlaneGeometry(8, 8), new THREE10.ShadowMaterial({ opacity: settings.lighting.floorShadow }));
|
|
2560
|
+
floor.position.z = -1e-5;
|
|
2561
|
+
floor.receiveShadow = true;
|
|
2562
|
+
scene.add(floor);
|
|
2563
|
+
}
|
|
2564
|
+
const pages = this.options.pages;
|
|
2565
|
+
const textures = pages.map((_, i) => this.loader?.textures[i] ?? this.blank);
|
|
2566
|
+
const backColor = colorKey(settings.paper.backColor);
|
|
2567
|
+
if (backColor !== this.backBlank.color) {
|
|
2568
|
+
this.backBlank.texture.dispose();
|
|
2569
|
+
this.backBlank.texture = solidTexture(settings.paper.backColor);
|
|
2570
|
+
this.backBlank.color = backColor;
|
|
2571
|
+
}
|
|
2572
|
+
const ctx = {
|
|
2573
|
+
camera,
|
|
2574
|
+
canvas: this.canvas,
|
|
2575
|
+
hitArea: this.hitArea,
|
|
2576
|
+
settings,
|
|
2577
|
+
textures,
|
|
2578
|
+
blank: layout === "single" ? this.backBlank.texture : this.blank,
|
|
2579
|
+
pattern: this.patternFor(settings, renderer),
|
|
2580
|
+
position: this.pageToPosition(page, layout),
|
|
2581
|
+
metrics: this.metrics,
|
|
2582
|
+
shading
|
|
2583
|
+
};
|
|
2584
|
+
const engine = layout === "spread" ? new SpreadEngine(ctx) : new SingleEngine(ctx);
|
|
2585
|
+
scene.add(engine.group);
|
|
2586
|
+
return {
|
|
2587
|
+
engine,
|
|
2588
|
+
scene,
|
|
2589
|
+
camera,
|
|
2590
|
+
framing,
|
|
2591
|
+
settings,
|
|
2592
|
+
dispose: () => {
|
|
2593
|
+
engine.dispose();
|
|
2594
|
+
sun.shadow.dispose();
|
|
2595
|
+
floor?.geometry.dispose();
|
|
2596
|
+
floor?.material.dispose();
|
|
2597
|
+
}
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
patternFor(settings, renderer) {
|
|
2601
|
+
const grain = settings.paper.grain;
|
|
2602
|
+
const key = grain === true || grain === void 0 ? "builtin" : grain === false ? "none" : grain;
|
|
2603
|
+
if (this.pattern?.key === key) return this.pattern.texture;
|
|
2604
|
+
this.pattern?.texture.dispose();
|
|
2605
|
+
let texture;
|
|
2606
|
+
if (key === "builtin") texture = createPaperPattern(renderer);
|
|
2607
|
+
else if (key === "none") texture = solidTexture("#000000");
|
|
2608
|
+
else {
|
|
2609
|
+
texture = solidTexture("#000000");
|
|
2610
|
+
loadGrain(key).then(
|
|
2611
|
+
(loaded) => {
|
|
2612
|
+
if (this.pattern?.key !== key || this.disposed) return loaded.dispose();
|
|
2613
|
+
this.pattern.texture.dispose();
|
|
2614
|
+
this.pattern.texture = loaded;
|
|
2615
|
+
this.remount();
|
|
2616
|
+
},
|
|
2617
|
+
(error) => {
|
|
2618
|
+
if (this.pattern?.key === key && !this.disposed) this.fail(error);
|
|
2619
|
+
}
|
|
2620
|
+
);
|
|
2621
|
+
}
|
|
2622
|
+
this.pattern = { texture, key };
|
|
2623
|
+
return texture;
|
|
2624
|
+
}
|
|
2625
|
+
// sizes the canvas and hit area to the container
|
|
2626
|
+
layoutBox() {
|
|
2627
|
+
if (!this.view) return;
|
|
2628
|
+
const { element } = this;
|
|
2629
|
+
const { framing, settings, camera } = this.view;
|
|
2630
|
+
element.style.height = "100%";
|
|
2631
|
+
const width = element.clientWidth;
|
|
2632
|
+
const height = element.clientHeight;
|
|
2633
|
+
if (!width) return;
|
|
2634
|
+
const placed = fit(framing, width, height, settings.bleed);
|
|
2635
|
+
if (!height) element.style.height = `${Math.round(placed.height)}px`;
|
|
2636
|
+
this.metrics.dragWidth = framing.dragWidth * placed.scale;
|
|
2637
|
+
this.hitBox = placed.box;
|
|
2638
|
+
this.placeHitArea();
|
|
2639
|
+
this.needsRender = true;
|
|
2640
|
+
if (!camera || !this.renderer) {
|
|
2641
|
+
this.content.place(placed.box);
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
const root = element.getBoundingClientRect();
|
|
2645
|
+
const scaleX = root.width / element.offsetWidth || 1;
|
|
2646
|
+
const scaleY = root.height / element.offsetHeight || 1;
|
|
2647
|
+
const pageLeft = root.left + window.scrollX;
|
|
2648
|
+
const pageTop = root.top + window.scrollY;
|
|
2649
|
+
const documentWidth = document.documentElement.clientWidth;
|
|
2650
|
+
const left = Math.max(placed.view.x, -pageLeft / scaleX);
|
|
2651
|
+
const right = Math.min(placed.view.x + placed.view.width, (documentWidth - pageLeft) / scaleX);
|
|
2652
|
+
const top = Math.max(placed.view.y, -pageTop / scaleY);
|
|
2653
|
+
const bottom = placed.view.y + placed.view.height;
|
|
2654
|
+
const canvas = { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) };
|
|
2655
|
+
const px = (n) => `${Math.round(n * 100) / 100}px`;
|
|
2656
|
+
Object.assign(this.canvas.style, {
|
|
2657
|
+
left: px(canvas.x),
|
|
2658
|
+
top: px(canvas.y),
|
|
2659
|
+
width: px(canvas.width),
|
|
2660
|
+
height: px(canvas.height)
|
|
2661
|
+
});
|
|
2662
|
+
const maxRatio = Math.min(devicePixelRatio, settings.maxPixelRatio);
|
|
2663
|
+
const budget = Math.sqrt(15e6 / Math.max(1, canvas.width * canvas.height));
|
|
2664
|
+
this.renderer.setPixelRatio(Math.min(maxRatio, budget));
|
|
2665
|
+
this.renderer.setSize(Math.max(1, Math.round(canvas.width)), Math.max(1, Math.round(canvas.height)), false);
|
|
2666
|
+
const { eye, view, distance } = framing;
|
|
2667
|
+
const halfWidth = Math.max(eye.x - view.x, view.x + view.width - eye.x);
|
|
2668
|
+
const halfHeight = Math.max(eye.y - view.y, view.y + view.height - eye.y);
|
|
2669
|
+
const scale = placed.scale;
|
|
2670
|
+
camera.fov = 2 * Math.atan(halfHeight / distance) * 180 / Math.PI;
|
|
2671
|
+
camera.aspect = halfWidth / halfHeight;
|
|
2672
|
+
const frustumLeft = placed.view.x + (eye.x - halfWidth - view.x) * scale;
|
|
2673
|
+
const frustumTop = placed.view.y + (view.y + view.height - (eye.y + halfHeight)) * scale;
|
|
2674
|
+
camera.setViewOffset(
|
|
2675
|
+
2 * halfWidth * scale,
|
|
2676
|
+
2 * halfHeight * scale,
|
|
2677
|
+
canvas.x - frustumLeft,
|
|
2678
|
+
canvas.y - frustumTop,
|
|
2679
|
+
canvas.width,
|
|
2680
|
+
canvas.height
|
|
2681
|
+
);
|
|
2682
|
+
camera.updateProjectionMatrix();
|
|
2683
|
+
}
|
|
2684
|
+
// --- pages ---------------------------------------------------------------------
|
|
2685
|
+
// what the pages show; their alt and text don't count
|
|
2686
|
+
pagesKey(options) {
|
|
2687
|
+
return options.pages.map((p) => sourceKey(pageImage(p))).join("\n");
|
|
2688
|
+
}
|
|
2689
|
+
preloadRadius() {
|
|
2690
|
+
const preload = this.options.preload ?? DEFAULT_PRELOAD;
|
|
2691
|
+
return Number.isNaN(preload) ? DEFAULT_PRELOAD : Math.max(0, preload);
|
|
2692
|
+
}
|
|
2693
|
+
loadPages(renderer, around, layout) {
|
|
2694
|
+
this.loader?.dispose();
|
|
2695
|
+
const loader = new PageLoader(this.options.pages.map(pageImage), renderer, this.preloadRadius(), {
|
|
2696
|
+
onLoad: (page, texture) => {
|
|
2697
|
+
if (this.loader !== loader) return;
|
|
2698
|
+
this.view?.engine.setPageTexture(page, texture);
|
|
2699
|
+
this.needsRender = true;
|
|
2700
|
+
this.pageLoaded(page);
|
|
2701
|
+
},
|
|
2702
|
+
onUnload: (page) => {
|
|
2703
|
+
if (this.loader !== loader) return;
|
|
2704
|
+
this.view?.engine.setPageTexture(page, this.blank);
|
|
2705
|
+
this.needsRender = true;
|
|
2706
|
+
},
|
|
2707
|
+
onError: (page, error) => this.fail(error, page)
|
|
2708
|
+
});
|
|
2709
|
+
this.loader = loader;
|
|
2710
|
+
this.resetReady();
|
|
2711
|
+
const first = [around - 1, around, around + 1, around + 2];
|
|
2712
|
+
const visible = this.pagesAt(this.pageToPosition(around, layout), layout);
|
|
2713
|
+
loader.start(visible[0] ?? around, visible[visible.length - 1] ?? around, first).then(() => {
|
|
2714
|
+
if (this.loader !== loader || this.disposed) return;
|
|
2715
|
+
this.ready = true;
|
|
2716
|
+
this.needsRender = true;
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
resetReady() {
|
|
2720
|
+
this.ready = false;
|
|
2721
|
+
this.element.dataset.state = "loading";
|
|
2722
|
+
}
|
|
2723
|
+
pageLoaded(page) {
|
|
2724
|
+
this.options.onPageLoad?.(page);
|
|
2725
|
+
this.dispatchEvent(new CustomEvent("pageload", { detail: { page } }));
|
|
2726
|
+
}
|
|
2727
|
+
fail(error, page) {
|
|
2728
|
+
if (this.options.onError) this.options.onError(error, page);
|
|
2729
|
+
else console.warn("[leafthrough]", error);
|
|
2730
|
+
this.dispatchEvent(new CustomEvent("error", { detail: { error, page } }));
|
|
2731
|
+
}
|
|
2732
|
+
// --- pages ↔ engine positions -------------------------------------------------------
|
|
2733
|
+
pageToPosition(page, layout) {
|
|
2734
|
+
const count = this.options.pages.length;
|
|
2735
|
+
const p = THREE10.MathUtils.clamp(Math.round(page) || 0, 0, Math.max(count - 1, 0));
|
|
2736
|
+
return layout === "spread" ? Math.ceil(p / 2) : p;
|
|
2737
|
+
}
|
|
2738
|
+
positionToPage(position, layout) {
|
|
2739
|
+
return layout === "spread" ? Math.max(0, 2 * position - 1) : position;
|
|
2740
|
+
}
|
|
2741
|
+
// the pages showing at an engine position
|
|
2742
|
+
pagesAt(position, layout) {
|
|
2743
|
+
const candidates = layout === "spread" ? [2 * position - 1, 2 * position] : [position];
|
|
2744
|
+
return candidates.filter((p) => p >= 0 && p < this.options.pages.length);
|
|
2745
|
+
}
|
|
2746
|
+
detail() {
|
|
2747
|
+
const count = this.options.pages.length;
|
|
2748
|
+
const layout = this.view?.settings.layout ?? this.pickLayout();
|
|
2749
|
+
const position = this.view?.engine.position ?? 0;
|
|
2750
|
+
const visiblePages = this.pagesAt(position, layout);
|
|
2751
|
+
const page = visiblePages.length ? visiblePages[0] : Math.max(0, Math.min(this.positionToPage(position, layout), count - 1));
|
|
2752
|
+
return { page, visiblePages, pageCount: count, layout };
|
|
2753
|
+
}
|
|
2754
|
+
lastDetailKey = "";
|
|
2755
|
+
emitPageChange() {
|
|
2756
|
+
const detail = this.detail();
|
|
2757
|
+
const key = `${detail.layout}:${detail.visiblePages.join(",")}/${detail.pageCount}`;
|
|
2758
|
+
if (key === this.lastDetailKey) return;
|
|
2759
|
+
const first = !this.lastDetailKey;
|
|
2760
|
+
this.lastDetailKey = key;
|
|
2761
|
+
this.live.textContent = (this.options.formatPageLabel ?? defaultPageLabel)(detail);
|
|
2762
|
+
const { visiblePages: visible } = detail;
|
|
2763
|
+
this.loader?.focus(visible[0] ?? detail.page, visible[visible.length - 1] ?? detail.page);
|
|
2764
|
+
if (first) return;
|
|
2765
|
+
this.options.onPageChange?.(detail);
|
|
2766
|
+
this.dispatchEvent(new CustomEvent("pagechange", { detail }));
|
|
2767
|
+
}
|
|
2768
|
+
// the visible pages' content, for screen readers and the plain view
|
|
2769
|
+
syncContent(view) {
|
|
2770
|
+
const { visiblePages, pageCount, layout } = this.detail();
|
|
2771
|
+
const format = this.options.formatPageLabel ?? defaultPageLabel;
|
|
2772
|
+
this.content.show({
|
|
2773
|
+
pages: this.options.pages,
|
|
2774
|
+
visiblePages,
|
|
2775
|
+
layout,
|
|
2776
|
+
plain: this.plain,
|
|
2777
|
+
label: (page) => format({ page, visiblePages: [page], pageCount, layout }),
|
|
2778
|
+
paper: `#${colorKey(view.settings.paper.backColor)}`
|
|
2779
|
+
});
|
|
2780
|
+
}
|
|
2781
|
+
applyLabel() {
|
|
2782
|
+
this.element.setAttribute("aria-label", this.options.label ?? "Magazine");
|
|
2783
|
+
this.lastDetailKey = "";
|
|
2784
|
+
}
|
|
2785
|
+
// --- input and loop -----------------------------------------------------------------
|
|
2786
|
+
bindKeyboard() {
|
|
2787
|
+
const handler = (e) => {
|
|
2788
|
+
const mode = this.view?.settings.interaction.keyboard;
|
|
2789
|
+
if (!mode || e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
2790
|
+
if (mode === "global") {
|
|
2791
|
+
const target = e.target;
|
|
2792
|
+
if (target?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) return;
|
|
2793
|
+
} else if (e.currentTarget !== this.element) return;
|
|
2794
|
+
const last = this.pageCount - 1;
|
|
2795
|
+
const actions = {
|
|
2796
|
+
ArrowRight: () => this.next(),
|
|
2797
|
+
PageDown: () => this.next(),
|
|
2798
|
+
ArrowLeft: () => this.prev(),
|
|
2799
|
+
PageUp: () => this.prev(),
|
|
2800
|
+
Home: () => this.goTo(0),
|
|
2801
|
+
End: () => this.goTo(last)
|
|
2802
|
+
};
|
|
2803
|
+
const action = actions[e.key];
|
|
2804
|
+
if (!action) return;
|
|
2805
|
+
e.preventDefault();
|
|
2806
|
+
action();
|
|
2807
|
+
};
|
|
2808
|
+
this.element.addEventListener("keydown", handler, { signal: this.abort.signal });
|
|
2809
|
+
window.addEventListener(
|
|
2810
|
+
"keydown",
|
|
2811
|
+
(e) => {
|
|
2812
|
+
if (this.view?.settings.interaction.keyboard === "global" && !this.element.contains(e.target)) handler(e);
|
|
2813
|
+
},
|
|
2814
|
+
{ signal: this.abort.signal }
|
|
2815
|
+
);
|
|
2816
|
+
}
|
|
2817
|
+
stopRiffle() {
|
|
2818
|
+
this.riffle = null;
|
|
2819
|
+
}
|
|
2820
|
+
stepRiffle(now, engine) {
|
|
2821
|
+
const riffle = this.riffle;
|
|
2822
|
+
if (!riffle || now - riffle.last < riffle.interval) return;
|
|
2823
|
+
if (engine.position === riffle.target) {
|
|
2824
|
+
this.riffle = null;
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
riffle.last = now;
|
|
2828
|
+
if (!engine.flip(engine.position < riffle.target)) this.riffle = null;
|
|
2829
|
+
}
|
|
2830
|
+
// only animate while the magazine is on screen and the tab is visible
|
|
2831
|
+
syncLoop() {
|
|
2832
|
+
const run = this.visible && !document.hidden && !this.disposed;
|
|
2833
|
+
if (run === this.running) return;
|
|
2834
|
+
this.running = run;
|
|
2835
|
+
this.setLoop(run ? (now) => this.frame(now) : null);
|
|
2836
|
+
if (run) this.needsRender = true;
|
|
2837
|
+
}
|
|
2838
|
+
// the renderer's animation loop, or plain requestAnimationFrame without one
|
|
2839
|
+
setLoop(callback) {
|
|
2840
|
+
if (this.renderer) return this.renderer.setAnimationLoop(callback);
|
|
2841
|
+
cancelAnimationFrame(this.frameRequest);
|
|
2842
|
+
if (!callback) return;
|
|
2843
|
+
const tick = (now) => {
|
|
2844
|
+
this.frameRequest = requestAnimationFrame(tick);
|
|
2845
|
+
callback(now);
|
|
2846
|
+
};
|
|
2847
|
+
this.frameRequest = requestAnimationFrame(tick);
|
|
2848
|
+
}
|
|
2849
|
+
// The hit area covers the pages; a closed magazine only takes input on its cover side.
|
|
2850
|
+
// This is the only place that positions it.
|
|
2851
|
+
placeHitArea() {
|
|
2852
|
+
const box = this.hitBox;
|
|
2853
|
+
const side = this.view?.engine.activeSide ?? "both";
|
|
2854
|
+
if (!box) return;
|
|
2855
|
+
this.hitSide = side;
|
|
2856
|
+
const half = side === "both" ? 1 : 0.5;
|
|
2857
|
+
const px = (n) => `${Math.round(n * 100) / 100}px`;
|
|
2858
|
+
Object.assign(this.hitArea.style, {
|
|
2859
|
+
left: px(side === "right" ? box.x + box.width / 2 : box.x),
|
|
2860
|
+
top: px(box.y),
|
|
2861
|
+
width: px(box.width * half),
|
|
2862
|
+
height: px(box.height)
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
frame(now) {
|
|
2866
|
+
const view = this.view;
|
|
2867
|
+
if (!view) return;
|
|
2868
|
+
this.stepRiffle(now, view.engine);
|
|
2869
|
+
const moved = view.engine.update(now);
|
|
2870
|
+
if (view.engine.activeSide !== this.hitSide) this.placeHitArea();
|
|
2871
|
+
if (moved || this.needsRender) {
|
|
2872
|
+
if (this.renderer && view.scene && view.camera) {
|
|
2873
|
+
this.renderer.shadowMap.needsUpdate = true;
|
|
2874
|
+
this.renderer.render(view.scene, view.camera);
|
|
2875
|
+
}
|
|
2876
|
+
this.needsRender = false;
|
|
2877
|
+
if (this.ready && this.element.dataset.state !== "ready") {
|
|
2878
|
+
this.element.dataset.state = "ready";
|
|
2879
|
+
this.canvas.style.opacity = "1";
|
|
2880
|
+
this.options.onReady?.();
|
|
2881
|
+
this.dispatchEvent(new CustomEvent("ready"));
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
this.emitPageChange();
|
|
2885
|
+
this.syncContent(view);
|
|
2886
|
+
}
|
|
2887
|
+
};
|
|
2888
|
+
function colorKey(color) {
|
|
2889
|
+
return new THREE10.Color(color).getHexString();
|
|
2890
|
+
}
|
|
2891
|
+
function createRenderer(canvas) {
|
|
2892
|
+
let renderer;
|
|
2893
|
+
try {
|
|
2894
|
+
renderer = new THREE10.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
|
2895
|
+
} catch {
|
|
2896
|
+
return null;
|
|
2897
|
+
}
|
|
2898
|
+
renderer.outputColorSpace = THREE10.SRGBColorSpace;
|
|
2899
|
+
renderer.shadowMap.enabled = true;
|
|
2900
|
+
renderer.shadowMap.type = THREE10.PCFShadowMap;
|
|
2901
|
+
renderer.shadowMap.autoUpdate = false;
|
|
2902
|
+
return renderer;
|
|
2903
|
+
}
|
|
2904
|
+
function snapshot(value) {
|
|
2905
|
+
if (typeof value !== "object" || value === null) return value;
|
|
2906
|
+
if (Array.isArray(value)) return value.map(snapshot);
|
|
2907
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
|
|
2908
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, snapshot(item)]));
|
|
2909
|
+
}
|
|
2910
|
+
function deepEqual(a, b) {
|
|
2911
|
+
if (Object.is(a, b)) return true;
|
|
2912
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
2913
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
2914
|
+
const protoA = Object.getPrototypeOf(a);
|
|
2915
|
+
if (protoA !== Object.prototype && protoA !== Array.prototype) return false;
|
|
2916
|
+
if (protoA !== Object.getPrototypeOf(b)) return false;
|
|
2917
|
+
const keysA = Object.keys(a);
|
|
2918
|
+
const keysB = Object.keys(b);
|
|
2919
|
+
if (keysA.length !== keysB.length) return false;
|
|
2920
|
+
return keysA.every((key) => deepEqual(a[key], b[key]));
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
export {
|
|
2924
|
+
DEFAULT_SPREAD_QUERY,
|
|
2925
|
+
DEFAULT_PAGE_ASPECT,
|
|
2926
|
+
defaultPageLabel,
|
|
2927
|
+
LeafThrough
|
|
2928
|
+
};
|
|
2929
|
+
//# sourceMappingURL=chunk-U2OBPSZN.js.map
|