hd-garden 0.8.4 → 0.9.1
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/app/bookshelf/load.mjs +96 -0
- package/app/bookshelf/load.ts +16 -0
- package/app/bookshelf/page.tsx +52 -0
- package/app/components/SiteChrome.tsx +17 -3
- package/app/components/SiteHeader.tsx +3 -3
- package/app/components/bookshelf/Bookshelf.tsx +117 -0
- package/app/components/bookshelf/bookshelf.css +1288 -0
- package/app/components/bookshelf/catalog.mjs +116 -0
- package/app/components/bookshelf/catalog.ts +23 -0
- package/app/components/bookshelf/demo-data.ts +93 -0
- package/app/components/bookshelf/density.ts +32 -0
- package/app/components/bookshelf/engine.ts +2690 -0
- package/app/components/bookshelf/index.ts +12 -0
- package/app/components/bookshelf/model.ts +16 -0
- package/app/components/bookshelf/schema.mjs +86 -0
- package/app/components/bookshelf/schema.ts +68 -0
- package/app/components/bookshelf/types.ts +55 -0
- package/app/garden-modules.d.ts +6 -0
- package/app/layout.tsx +2 -1
- package/app/lib/i18n.ts +2 -2
- package/app/sitemap.ts +3 -0
- package/docs/handbook/config.md +41 -2
- package/docs/handbook/en/config.md +41 -2
- package/package.json +2 -1
- package/public/covers/accelerate.jpg +0 -0
- package/public/covers/clean-architecture.jpg +0 -0
- package/public/covers/clean-code.jpg +0 -0
- package/public/covers/continuous-delivery.jpg +0 -0
- package/public/covers/ddd.jpg +0 -0
- package/public/covers/ddia.jpg +0 -0
- package/public/covers/design-patterns.jpg +0 -0
- package/public/covers/eaa.jpg +0 -0
- package/public/covers/fundamentals-sa.jpg +0 -0
- package/public/covers/implementing-ddd.jpg +0 -0
- package/public/covers/lean-startup.jpg +0 -0
- package/public/covers/microservices.jpg +0 -0
- package/public/covers/mythical-man-month.jpg +0 -0
- package/public/covers/phoenix-project.jpg +0 -0
- package/public/covers/pragmatic-programmer.jpg +0 -0
- package/public/covers/refactoring.jpg +0 -0
- package/public/covers/release-it.jpg +0 -0
- package/public/covers/sre.jpg +0 -0
- package/public/covers/systems-performance.jpg +0 -0
- package/public/covers/tdd.jpg +0 -0
- package/public/covers/team-topologies.jpg +0 -0
- package/scripts/build-content.mjs +17 -1
- package/scripts/handbook-facts.mjs +2 -1
- package/ui/garden/site-nav.mjs +12 -0
- package/vite.config.ts +12 -3
|
@@ -0,0 +1,2690 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
// Tip ux26 held-book-s10-ux26-20261028. Motion copied from that app.js. Do not retune it.
|
|
4
|
+
import { DEMO_CATALOG, DEMO_NOTES } from "./demo-data";
|
|
5
|
+
|
|
6
|
+
function cssIdent(id) {
|
|
7
|
+
return String(id).replace(/[^a-zA-Z0-9_-]/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function coverSrc(book) {
|
|
11
|
+
if (typeof book.cover === "string" && book.cover) return book.cover;
|
|
12
|
+
return `/covers/${book.id}.jpg`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function injectExtraCovers(catalog) {
|
|
16
|
+
const rules = [];
|
|
17
|
+
for (const cat of catalog) {
|
|
18
|
+
for (const book of cat.books) {
|
|
19
|
+
const id = cssIdent(book.id);
|
|
20
|
+
if (!id) continue;
|
|
21
|
+
if (typeof book.cover === "string" && book.cover) {
|
|
22
|
+
const url = book.cover.replace(/["'\\\n\r]/g, "");
|
|
23
|
+
rules.push(`.bookshelf-demo .img-${id}{background-image:url("${url}")}`);
|
|
24
|
+
rules.push(`.bookshelf-demo .spine.is-expanded.img-${id}{background-image:url("${url}") !important}`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof book.color === "string" && book.color) {
|
|
27
|
+
const color = book.color.replace(/[^#(),.%a-zA-Z0-9 ]/g, "");
|
|
28
|
+
rules.push(`.bookshelf-demo .bk-${id}{background-color:${color}}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!rules.length) return () => {};
|
|
33
|
+
const style = document.createElement("style");
|
|
34
|
+
style.setAttribute("data-bookshelf-covers", "");
|
|
35
|
+
style.textContent = rules.join("\n");
|
|
36
|
+
document.head.appendChild(style);
|
|
37
|
+
return () => style.remove();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function mountBookshelf(root, options = {}) {
|
|
41
|
+
// A passed catalog is a site shelf. Demo notes and demo copy stay on the
|
|
42
|
+
// package preview, which mounts this with no catalog.
|
|
43
|
+
const CATALOG = options.catalog ?? DEMO_CATALOG;
|
|
44
|
+
const NOTES = options.notes ?? (options.catalog ? {} : DEMO_NOTES);
|
|
45
|
+
const usingDemo = !options.catalog;
|
|
46
|
+
const removeExtraCovers = injectExtraCovers(CATALOG);
|
|
47
|
+
|
|
48
|
+
// ux26: per-book cover dominant (= existing bk-* spine colors) for open-shell + page wash
|
|
49
|
+
const COVER_SHELL = {
|
|
50
|
+
"clean-code": "#0d1b2a",
|
|
51
|
+
"refactoring": "#7a0d1b",
|
|
52
|
+
"design-patterns": "#0d1b1b",
|
|
53
|
+
"continuous-delivery": "#be2828",
|
|
54
|
+
"phoenix-project": "#3a7ab8",
|
|
55
|
+
"mythical-man-month": "#1b3688",
|
|
56
|
+
"pragmatic-programmer": "#1a1a1a",
|
|
57
|
+
"tdd": "#513688",
|
|
58
|
+
"lean-startup": "#3a3a3a",
|
|
59
|
+
"release-it": "#5a6a88",
|
|
60
|
+
"sre": "#00367a",
|
|
61
|
+
"team-topologies": "#2a6a88",
|
|
62
|
+
"accelerate": "#36445f",
|
|
63
|
+
"ddd": "#1e4a7a",
|
|
64
|
+
"microservices": "#a38851",
|
|
65
|
+
"eaa": "#950d1b",
|
|
66
|
+
"ddia": "#8b3a2f",
|
|
67
|
+
"fundamentals-sa": "#44881b",
|
|
68
|
+
"implementing-ddd": "#b03644",
|
|
69
|
+
"systems-performance": "#00447a",
|
|
70
|
+
"clean-architecture": "#0d1b44",
|
|
71
|
+
sand: "#c4b7a2",
|
|
72
|
+
cream: "#d2c4ae",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function hexToRgb(hex) {
|
|
76
|
+
const h = String(hex || "").replace("#", "").trim();
|
|
77
|
+
if (h.length === 3) {
|
|
78
|
+
const r = parseInt(h[0] + h[0], 16);
|
|
79
|
+
const g = parseInt(h[1] + h[1], 16);
|
|
80
|
+
const b = parseInt(h[2] + h[2], 16);
|
|
81
|
+
return { r, g, b };
|
|
82
|
+
}
|
|
83
|
+
if (h.length !== 6) return { r: 90, g: 106, b: 136 };
|
|
84
|
+
return {
|
|
85
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
86
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
87
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function rgbToHex({ r, g, b }) {
|
|
92
|
+
const c = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
|
|
93
|
+
return `#${c(r)}${c(g)}${c(b)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mixRgb(a, b, t) {
|
|
97
|
+
return {
|
|
98
|
+
r: a.r + (b.r - a.r) * t,
|
|
99
|
+
g: a.g + (b.g - a.g) * t,
|
|
100
|
+
b: a.b + (b.b - a.b) * t,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function bookShellHex(book) {
|
|
105
|
+
if (!book) return "#5a6a88";
|
|
106
|
+
if (book.filler === "sand" || book.filler === true) return COVER_SHELL.sand;
|
|
107
|
+
if (book.filler === "cream") return COVER_SHELL.cream;
|
|
108
|
+
// Site books carry the sampled cover color. Demo jackets keep COVER_SHELL.
|
|
109
|
+
const color = typeof book.color === "string" ? book.color.trim() : "";
|
|
110
|
+
if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) return color;
|
|
111
|
+
return COVER_SHELL[book.id] || "#5a6a88";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function bookPagePalette(book) {
|
|
115
|
+
const shell = bookShellHex(book);
|
|
116
|
+
const s = hexToRgb(shell);
|
|
117
|
+
const cream = { r: 255, g: 252, b: 246 };
|
|
118
|
+
const paper = { r: 247, g: 244, b: 239 };
|
|
119
|
+
const paperLo = { r: 239, g: 232, b: 220 };
|
|
120
|
+
// Soft tint: mostly paper, hint of cover
|
|
121
|
+
const pageHi = mixRgb(cream, s, 0.08);
|
|
122
|
+
const page = mixRgb(paper, s, 0.12);
|
|
123
|
+
const pageLo = mixRgb(paperLo, s, 0.18);
|
|
124
|
+
const shellHi = mixRgb(s, { r: 255, g: 255, b: 255 }, 0.22);
|
|
125
|
+
const shellLo = mixRgb(s, { r: 0, g: 0, b: 0 }, 0.28);
|
|
126
|
+
const edgeA = mixRgb(cream, s, 0.28);
|
|
127
|
+
const edgeB = mixRgb(paper, s, 0.38);
|
|
128
|
+
const edgeC = mixRgb(paperLo, s, 0.48);
|
|
129
|
+
// Dark ink on tinted page (pages stay light)
|
|
130
|
+
const ink = mixRgb({ r: 32, g: 28, b: 22 }, s, 0.18);
|
|
131
|
+
const inkMuted = mixRgb({ r: 90, g: 83, b: 72 }, s, 0.12);
|
|
132
|
+
return {
|
|
133
|
+
shell,
|
|
134
|
+
shellHi: rgbToHex(shellHi),
|
|
135
|
+
shellLo: rgbToHex(shellLo),
|
|
136
|
+
page: rgbToHex(page),
|
|
137
|
+
pageHi: rgbToHex(pageHi),
|
|
138
|
+
pageLo: rgbToHex(pageLo),
|
|
139
|
+
edgeA: rgbToHex(edgeA),
|
|
140
|
+
edgeB: rgbToHex(edgeB),
|
|
141
|
+
edgeC: rgbToHex(edgeC),
|
|
142
|
+
ink: rgbToHex(ink),
|
|
143
|
+
inkMuted: rgbToHex(inkMuted),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function applyFlyerPalette(flyer, book) {
|
|
148
|
+
if (!flyer) return;
|
|
149
|
+
const p = bookPagePalette(book);
|
|
150
|
+
flyer.style.setProperty("--shell", p.shell);
|
|
151
|
+
flyer.style.setProperty("--shell-hi", p.shellHi);
|
|
152
|
+
flyer.style.setProperty("--shell-lo", p.shellLo);
|
|
153
|
+
flyer.style.setProperty("--page", p.page);
|
|
154
|
+
flyer.style.setProperty("--page-hi", p.pageHi);
|
|
155
|
+
flyer.style.setProperty("--page-lo", p.pageLo);
|
|
156
|
+
flyer.style.setProperty("--edge-a", p.edgeA);
|
|
157
|
+
flyer.style.setProperty("--edge-b", p.edgeB);
|
|
158
|
+
flyer.style.setProperty("--edge-c", p.edgeC);
|
|
159
|
+
flyer.style.setProperty("--ink", p.ink);
|
|
160
|
+
flyer.style.setProperty("--ink-muted", p.inkMuted);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function escapeHtml(s) {
|
|
164
|
+
return String(s || "")
|
|
165
|
+
.replace(/&/g, "&")
|
|
166
|
+
.replace(/</g, "<")
|
|
167
|
+
.replace(/>/g, ">")
|
|
168
|
+
.replace(/"/g, """);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function plainFromHtml(html) {
|
|
172
|
+
const tmp = document.createElement("div");
|
|
173
|
+
tmp.innerHTML = String(html || "");
|
|
174
|
+
return (tmp.textContent || tmp.innerText || "").replace(/\s+/g, " ").trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function noteSummaryText(book) {
|
|
178
|
+
const key = book && book.note;
|
|
179
|
+
const note = key ? NOTES[key] : null;
|
|
180
|
+
if (note && typeof note.summary === "string" && note.summary.trim()) {
|
|
181
|
+
return note.summary.trim().replace(/\s+/g, " ");
|
|
182
|
+
}
|
|
183
|
+
if (note && note.body) {
|
|
184
|
+
let text = plainFromHtml(note.body);
|
|
185
|
+
// Prefer first real sentence-ish chunk after stripping headings noise
|
|
186
|
+
text = text.replace(/^(为什么还在读这本|整洁不是洁癖|为什么这本难跳过|几条带走的|记下的几条|带走的框架)\s*/g, "");
|
|
187
|
+
if (text.length > 140) text = text.slice(0, 138).replace(/\s+\S*$/, "") + "…";
|
|
188
|
+
if (text.length >= 24) return text;
|
|
189
|
+
}
|
|
190
|
+
const who = book && book.author ? ` · ${book.author}` : "";
|
|
191
|
+
if (!usingDemo) return (book && (book.author || book.title)) || "";
|
|
192
|
+
return `《${book.title}》的读书笔记摘要${who}。正式站会换成完整笔记;Demo 先占这一页。`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function leftPageHtml(book) {
|
|
196
|
+
const title = escapeHtml(book.title || "");
|
|
197
|
+
const author = book.author ? `<p class="flyer-page-author">${escapeHtml(book.author)}</p>` : "";
|
|
198
|
+
return `<div class="flyer-page-title">${title}</div>${author}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function rightPageHtml(book) {
|
|
202
|
+
const summary = escapeHtml(noteSummaryText(book));
|
|
203
|
+
const href = book && typeof book.href === "string" ? book.href : "";
|
|
204
|
+
const more = href
|
|
205
|
+
? `<a class="flyer-page-more" href="${escapeHtml(href)}">阅读全文</a>`
|
|
206
|
+
: "";
|
|
207
|
+
return `<div class="flyer-page-kicker">读书笔记</div><p class="flyer-page-summary">${summary}</p>${more}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
const $ = (sel, scope = root) => scope.querySelector(sel);
|
|
212
|
+
const $$ = (sel, scope = root) => [...scope.querySelectorAll(sel)];
|
|
213
|
+
|
|
214
|
+
const unlisten = [];
|
|
215
|
+
function listen(target, type, fn, opts) {
|
|
216
|
+
if (!target) return;
|
|
217
|
+
target.addEventListener(type, fn, opts);
|
|
218
|
+
unlisten.push(() => target.removeEventListener(type, fn, opts));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const shelfView = $("#shelfView");
|
|
222
|
+
const noteView = $("#noteView");
|
|
223
|
+
const baysEl = $("#bays");
|
|
224
|
+
const stage = $("#stage");
|
|
225
|
+
const openVeil = $("#openVeil");
|
|
226
|
+
const openVeilBlob = $("#openVeilBlob");
|
|
227
|
+
const openBackdrop = $("#openBackdrop");
|
|
228
|
+
const modeHint = $("#modeHint");
|
|
229
|
+
const footMode = $("#footMode");
|
|
230
|
+
|
|
231
|
+
let pageMode = "spines"; // covers | spines
|
|
232
|
+
// Hedon lock: density decided once on enter; resize must not flip covers↔spines.
|
|
233
|
+
// ux21: mobile-primary enter pins covers (全封面); desktop keeps ux19 autoDecide pin.
|
|
234
|
+
let densityPinned = null;
|
|
235
|
+
let modeOverride = null; // auto | covers | spines | null→from query
|
|
236
|
+
let busy = false;
|
|
237
|
+
let expandedId = null;
|
|
238
|
+
let openBook = null;
|
|
239
|
+
let openFromExpanded = false;
|
|
240
|
+
let touchExpandedId = null;
|
|
241
|
+
|
|
242
|
+
const reduceMotion = () =>
|
|
243
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
244
|
+
|
|
245
|
+
const wait = (ms) =>
|
|
246
|
+
new Promise((r) => setTimeout(r, reduceMotion() ? 0 : ms));
|
|
247
|
+
|
|
248
|
+
const cssMs = (name, fallback) => {
|
|
249
|
+
if (reduceMotion()) return 0;
|
|
250
|
+
const raw = getComputedStyle(root).getPropertyValue(name).trim();
|
|
251
|
+
if (!raw) return fallback;
|
|
252
|
+
if (raw.endsWith("ms")) return parseFloat(raw);
|
|
253
|
+
if (raw.endsWith("s")) return parseFloat(raw) * 1000;
|
|
254
|
+
return fallback;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
function bookById(id) {
|
|
258
|
+
for (const cat of CATALOG) {
|
|
259
|
+
const b = cat.books.find((x) => x.id === id);
|
|
260
|
+
if (b) return { book: b, cat };
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function coverClass(book) {
|
|
266
|
+
if (!book.cover) return "";
|
|
267
|
+
return `img-${book.id}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
// Natural cover aspect (width/height). Seeded from demo JPGs; refined on preload.
|
|
272
|
+
const COVER_NAT = {
|
|
273
|
+
"accelerate": [299, 300],
|
|
274
|
+
"clean-architecture": [506, 685],
|
|
275
|
+
"clean-code": [1080, 1357],
|
|
276
|
+
"continuous-delivery": [394, 500],
|
|
277
|
+
"ddd": [600, 754],
|
|
278
|
+
"ddia": [1080, 1080],
|
|
279
|
+
"design-patterns": [408, 576],
|
|
280
|
+
"eaa": [720, 1024],
|
|
281
|
+
"fundamentals-sa": [800, 800],
|
|
282
|
+
"implementing-ddd": [1080, 1401],
|
|
283
|
+
"lean-startup": [1080, 1401],
|
|
284
|
+
"microservices": [1080, 1368],
|
|
285
|
+
"mythical-man-month": [408, 580],
|
|
286
|
+
"phoenix-project": [700, 992],
|
|
287
|
+
"pragmatic-programmer": [398, 500],
|
|
288
|
+
"refactoring": [1080, 1404],
|
|
289
|
+
"release-it": [417, 500],
|
|
290
|
+
"sre": [374, 500],
|
|
291
|
+
"systems-performance": [1080, 1382],
|
|
292
|
+
"tdd": [432, 544],
|
|
293
|
+
"team-topologies": [800, 800],
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
function coverWidthPx() {
|
|
297
|
+
return window.innerWidth <= 640 ? 102 : 118;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function bookNat(book) {
|
|
301
|
+
if (!book || !book.cover) return null;
|
|
302
|
+
const pair = COVER_NAT[book.id];
|
|
303
|
+
if (!pair || !pair[0] || !pair[1]) return null;
|
|
304
|
+
return { w: pair[0], h: pair[1] };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Fixed width; height from real cover aspect. Fallback ~2:3 if unknown.
|
|
308
|
+
function bookFaceHeight(book) {
|
|
309
|
+
const w = coverWidthPx();
|
|
310
|
+
const nat = bookNat(book);
|
|
311
|
+
if (!nat) return Math.round(w / 0.68);
|
|
312
|
+
return Math.max(96, Math.round(w * (nat.h / nat.w)));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function applyFaceSize(el, book, { expanded = false } = {}) {
|
|
316
|
+
if (!el || !book) return;
|
|
317
|
+
const faceH = bookFaceHeight(book);
|
|
318
|
+
if (el.classList.contains("cover") || expanded) {
|
|
319
|
+
const w = coverWidthPx();
|
|
320
|
+
el.style.width = w + "px";
|
|
321
|
+
el.style.height = faceH + "px";
|
|
322
|
+
el.style.backgroundSize = "contain";
|
|
323
|
+
el.style.backgroundPosition = "center top";
|
|
324
|
+
el.style.backgroundRepeat = "no-repeat";
|
|
325
|
+
} else if (el.classList.contains("spine")) {
|
|
326
|
+
// Resting spine: same height as that book's cover face; width from size class.
|
|
327
|
+
el.style.height = faceH + "px";
|
|
328
|
+
el.style.removeProperty("width"); // keep CSS size class width
|
|
329
|
+
el.dataset.faceH = String(faceH);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function parseForceMode() {
|
|
334
|
+
const q = new URLSearchParams(location.search);
|
|
335
|
+
const f = q.get("force");
|
|
336
|
+
if (f === "covers" || f === "spines") return f;
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ux21 lock: mobile resting = all covers (全封面).
|
|
341
|
+
// Match CSS mobile shelf: (hover:none) OR max-width 640.
|
|
342
|
+
// DevTools「手机 393」often keeps hover:hover — width gate is required or autoDecide→spines.
|
|
343
|
+
function isMobilePrimary() {
|
|
344
|
+
return (
|
|
345
|
+
window.matchMedia("(hover: none)").matches ||
|
|
346
|
+
window.matchMedia("(max-width: 640px)").matches
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Density: compute once on enter (pin), unless ?force= QA or explicit B spines.
|
|
351
|
+
// ux21: mobile-primary → pin "covers" (skip fit→spines autoDecide).
|
|
352
|
+
// Desktop → autoDecide once (ux19). Resize must NOT re-classify device class.
|
|
353
|
+
function pinnedDensityMode({ recompute = false } = {}) {
|
|
354
|
+
if (recompute || !densityPinned) {
|
|
355
|
+
densityPinned = isMobilePrimary() ? "covers" : autoDecideMode();
|
|
356
|
+
}
|
|
357
|
+
return densityPinned;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function resolveRestingMode(pref, { recompute = false } = {}) {
|
|
361
|
+
const force = parseForceMode();
|
|
362
|
+
if (force) return force;
|
|
363
|
+
if (pref === "spines") return "spines";
|
|
364
|
+
// covers / auto / default: pinned resting (mobile→covers; desktop→density)
|
|
365
|
+
return pinnedDensityMode({ recompute });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function applyCoverImage(el, book) {
|
|
369
|
+
if (!el || !book) return;
|
|
370
|
+
// ux24: NEVER preset wood/brown plate behind cover — transparent only
|
|
371
|
+
el.style.backgroundColor = "transparent";
|
|
372
|
+
el.style.backgroundSize = "contain";
|
|
373
|
+
el.style.backgroundPosition = "center top";
|
|
374
|
+
el.style.backgroundRepeat = "no-repeat";
|
|
375
|
+
// Durable <img> — CSS multi-layer background often drops inside preserve-3d mid-flip
|
|
376
|
+
let img = el.querySelector(":scope > .flyer-cover-img");
|
|
377
|
+
if (book.cover) {
|
|
378
|
+
const src = coverSrc(book);
|
|
379
|
+
el.style.backgroundImage =
|
|
380
|
+
`linear-gradient(90deg, rgba(0,0,0,0.18) 0%, transparent 10%),` +
|
|
381
|
+
`linear-gradient(180deg, rgba(255,255,255,0.06), transparent 40%),` +
|
|
382
|
+
`url("${src}")`;
|
|
383
|
+
if (!img) {
|
|
384
|
+
img = document.createElement("img");
|
|
385
|
+
img.className = "flyer-cover-img";
|
|
386
|
+
img.alt = "";
|
|
387
|
+
img.draggable = false;
|
|
388
|
+
el.insertBefore(img, el.firstChild);
|
|
389
|
+
}
|
|
390
|
+
if (img.getAttribute("src") !== src) img.src = src;
|
|
391
|
+
img.style.display = "block";
|
|
392
|
+
} else {
|
|
393
|
+
el.style.backgroundImage =
|
|
394
|
+
`linear-gradient(90deg, rgba(0,0,0,0.18) 0%, transparent 10%),` +
|
|
395
|
+
`linear-gradient(180deg, rgba(255,255,255,0.06), transparent 40%)`;
|
|
396
|
+
if (img) img.style.display = "none";
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function bkClass(book) {
|
|
401
|
+
if (book.filler === "sand" || book.filler === true) return "bk-sand";
|
|
402
|
+
if (book.filler === "cream") return "bk-cream";
|
|
403
|
+
return `bk-${book.id}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function parseQuery() {
|
|
407
|
+
const q = new URLSearchParams(location.search);
|
|
408
|
+
const m = q.get("mode");
|
|
409
|
+
if (m === "covers" || m === "a") return "covers";
|
|
410
|
+
if (m === "spines" || m === "b") return "spines";
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function setMode(mode, { push = false } = {}) {
|
|
415
|
+
pageMode = mode === "covers" ? "covers" : "spines";
|
|
416
|
+
expandedId = null;
|
|
417
|
+
touchExpandedId = null;
|
|
418
|
+
renderShelf();
|
|
419
|
+
updateModeUI();
|
|
420
|
+
if (push) {
|
|
421
|
+
const url = new URL(location.href);
|
|
422
|
+
url.searchParams.set("mode", pageMode);
|
|
423
|
+
history.replaceState(null, "", url);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function enableDebugChrome() {
|
|
428
|
+
const q = new URLSearchParams(location.search);
|
|
429
|
+
const on = q.get("debug") === "1" || location.hash === "#qa";
|
|
430
|
+
document.body.classList.toggle("debug-on", on);
|
|
431
|
+
["lede", "qaBar", "footBar"].forEach((id) => {
|
|
432
|
+
const el = root.querySelector("#" + id);
|
|
433
|
+
if (!el) return;
|
|
434
|
+
if (on) el.removeAttribute("hidden");
|
|
435
|
+
else el.setAttribute("hidden", "");
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function updateModeUI() {
|
|
440
|
+
$$(".qa-btn").forEach((btn) => {
|
|
441
|
+
const m = btn.dataset.mode;
|
|
442
|
+
const active =
|
|
443
|
+
(m === "auto" && modeOverride === "auto") ||
|
|
444
|
+
(m === pageMode && modeOverride === pageMode) ||
|
|
445
|
+
(m === pageMode && !modeOverride && parseQuery() === pageMode);
|
|
446
|
+
btn.classList.toggle("is-active", !!active || (m === pageMode && modeOverride === m));
|
|
447
|
+
});
|
|
448
|
+
// clearer active: mark exact override or current page mode button
|
|
449
|
+
$$(".qa-btn").forEach((btn) => btn.classList.remove("is-active"));
|
|
450
|
+
if (modeOverride === "auto") {
|
|
451
|
+
$("#modeAuto")?.classList.add("is-active");
|
|
452
|
+
} else if (pageMode === "covers") {
|
|
453
|
+
$("#modeCovers")?.classList.add("is-active");
|
|
454
|
+
} else {
|
|
455
|
+
$("#modeSpines")?.classList.add("is-active");
|
|
456
|
+
}
|
|
457
|
+
const force = parseForceMode();
|
|
458
|
+
const auto = autoDecideMode();
|
|
459
|
+
let label = pageMode === "covers" ? "A · 全封面" : "B · 全立脊";
|
|
460
|
+
let tag = "";
|
|
461
|
+
if (force) tag = "(强制对照)";
|
|
462
|
+
else if (modeOverride === "auto") tag = "(自动)";
|
|
463
|
+
else if (modeOverride === "covers") tag = auto === "covers" ? "(封面优先)" : "(放不下→立脊)";
|
|
464
|
+
else if (modeOverride === "spines") tag = "(手动)";
|
|
465
|
+
else if (densityPinned === "covers" && isMobilePrimary() && pageMode === "covers") tag = "(手机默认)";
|
|
466
|
+
else tag = pageMode === "spines" ? "(默认)" : "";
|
|
467
|
+
modeHint.textContent = label + tag;
|
|
468
|
+
footMode.textContent = label;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function autoDecideMode() {
|
|
472
|
+
// Page-level density: if any category can't fit covers in --col, all spines.
|
|
473
|
+
// Approximate: desktop covers need ~118+14 gap * n; with pad.
|
|
474
|
+
const col = Math.min(window.innerWidth - 32, 740);
|
|
475
|
+
const coverW = window.innerWidth <= 640 ? 102 + 10 : 118 + 14;
|
|
476
|
+
for (const cat of CATALOG) {
|
|
477
|
+
const n = cat.books.filter((b) => b.cover).length;
|
|
478
|
+
// Use cover-count for mode A layout (locked refs use subset on mobile)
|
|
479
|
+
const need = n * coverW;
|
|
480
|
+
if (need > col - 8) return "spines";
|
|
481
|
+
}
|
|
482
|
+
return "covers";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function applyModePreference() {
|
|
486
|
+
let pref = modeOverride;
|
|
487
|
+
if (!pref) {
|
|
488
|
+
const q = parseQuery();
|
|
489
|
+
if (q) pref = q;
|
|
490
|
+
}
|
|
491
|
+
const mode = resolveRestingMode(pref);
|
|
492
|
+
setMode(mode);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function renderShelf() {
|
|
496
|
+
rememberScrollHome();
|
|
497
|
+
baysEl.innerHTML = "";
|
|
498
|
+
for (const cat of CATALOG) {
|
|
499
|
+
const section = document.createElement("section");
|
|
500
|
+
section.className = "bay";
|
|
501
|
+
section.dataset.cat = cat.id;
|
|
502
|
+
|
|
503
|
+
const label = document.createElement("div");
|
|
504
|
+
label.className = "row-label";
|
|
505
|
+
label.innerHTML = `<span class="name">${cat.name}</span>`;
|
|
506
|
+
section.appendChild(label);
|
|
507
|
+
|
|
508
|
+
const ledge = document.createElement("div");
|
|
509
|
+
ledge.className = "ledge";
|
|
510
|
+
|
|
511
|
+
if (pageMode === "covers") {
|
|
512
|
+
const row = document.createElement("div");
|
|
513
|
+
row.className = "covers";
|
|
514
|
+
for (const book of cat.books) {
|
|
515
|
+
if (!book.cover) continue; // covers mode: real covers only (locked A)
|
|
516
|
+
const btn = document.createElement("button");
|
|
517
|
+
btn.type = "button";
|
|
518
|
+
btn.className = `cover ${coverClass(book)} ${bkClass(book)}`;
|
|
519
|
+
btn.dataset.bookId = book.id;
|
|
520
|
+
btn.setAttribute("aria-label", book.title);
|
|
521
|
+
applyFaceSize(btn, book);
|
|
522
|
+
// ux24: durable cover art — ban resting bk-* solid shells if CSS bg drops
|
|
523
|
+
const art = document.createElement("img");
|
|
524
|
+
art.className = "cover-art";
|
|
525
|
+
art.src = coverSrc(book);
|
|
526
|
+
art.alt = "";
|
|
527
|
+
art.draggable = false;
|
|
528
|
+
btn.appendChild(art);
|
|
529
|
+
btn.addEventListener("click", () => onCoverClick(book.id, btn));
|
|
530
|
+
row.appendChild(btn);
|
|
531
|
+
}
|
|
532
|
+
ledge.appendChild(row);
|
|
533
|
+
} else {
|
|
534
|
+
const row = document.createElement("div");
|
|
535
|
+
row.className = "spines";
|
|
536
|
+
for (const book of cat.books) {
|
|
537
|
+
const btn = document.createElement("button");
|
|
538
|
+
btn.type = "button";
|
|
539
|
+
const sizeCls = book.size === "thin" ? "thin dark-text" : `${book.size} light-text`;
|
|
540
|
+
btn.className = `spine ${bkClass(book)} ${sizeCls}`;
|
|
541
|
+
if (book.cover) btn.classList.add(coverClass(book));
|
|
542
|
+
btn.dataset.bookId = book.id;
|
|
543
|
+
btn.setAttribute("aria-label", book.title);
|
|
544
|
+
btn.innerHTML = `<span>${book.spine}</span>`;
|
|
545
|
+
if (book.cover && !book.filler) applyFaceSize(btn, book);
|
|
546
|
+
else if (book.filler) { /* thin filler keeps CSS height */ }
|
|
547
|
+
|
|
548
|
+
if (book.cover && !book.filler) {
|
|
549
|
+
btn.addEventListener("pointerenter", (e) => {
|
|
550
|
+
if (e.pointerType === "touch") return;
|
|
551
|
+
expandSpine(book.id);
|
|
552
|
+
});
|
|
553
|
+
btn.addEventListener("pointerleave", (e) => {
|
|
554
|
+
if (e.pointerType === "touch") return;
|
|
555
|
+
if (!busy) collapseSpine(book.id);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
btn.addEventListener("click", (e) => onSpineClick(book.id, btn, e));
|
|
560
|
+
row.appendChild(btn);
|
|
561
|
+
}
|
|
562
|
+
ledge.appendChild(row);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const rail = document.createElement("div");
|
|
566
|
+
rail.className = "rail";
|
|
567
|
+
ledge.appendChild(rail);
|
|
568
|
+
section.appendChild(ledge);
|
|
569
|
+
baysEl.appendChild(section);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// restore expanded visual if needed
|
|
573
|
+
if (pageMode === "spines" && expandedId) {
|
|
574
|
+
const el = $(`.spine[data-book-id="${expandedId}"]`);
|
|
575
|
+
if (el) el.classList.add("is-expanded");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ux18: resting after expand — drop class + inline face width (inline beats CSS collapse).
|
|
580
|
+
function restoreRestingSpine(el, bookId) {
|
|
581
|
+
if (!el) return;
|
|
582
|
+
el.classList.remove("is-expanded", "is-collapsing");
|
|
583
|
+
const found = bookById(bookId);
|
|
584
|
+
if (found && found.book.cover && !found.book.filler) {
|
|
585
|
+
applyFaceSize(el, found.book, { expanded: false });
|
|
586
|
+
} else {
|
|
587
|
+
el.style.removeProperty("width");
|
|
588
|
+
el.style.removeProperty("height");
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function clearOrphanExpandedFaces(exceptId) {
|
|
593
|
+
$$(".spine[data-book-id]").forEach((el) => {
|
|
594
|
+
const id = el.dataset.bookId;
|
|
595
|
+
if (!id || id === exceptId) return;
|
|
596
|
+
if (
|
|
597
|
+
el.classList.contains("is-expanded") ||
|
|
598
|
+
el.classList.contains("is-collapsing") ||
|
|
599
|
+
el.style.width
|
|
600
|
+
) {
|
|
601
|
+
restoreRestingSpine(el, id);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function expandSpine(id) {
|
|
607
|
+
if (busy || pageMode !== "spines") return;
|
|
608
|
+
pinScrollHome();
|
|
609
|
+
const found = bookById(id);
|
|
610
|
+
if (!found || !found.book.cover || found.book.filler) return;
|
|
611
|
+
cancelCollapse();
|
|
612
|
+
// Sweep orphans first — rapid hover left inline widths under ux17.
|
|
613
|
+
clearOrphanExpandedFaces(id);
|
|
614
|
+
if (expandedId && expandedId !== id) {
|
|
615
|
+
const prev = $(`.spine[data-book-id="${expandedId}"]`);
|
|
616
|
+
if (prev) restoreRestingSpine(prev, expandedId);
|
|
617
|
+
}
|
|
618
|
+
expandedId = id;
|
|
619
|
+
const el = $(`.spine[data-book-id="${id}"]`);
|
|
620
|
+
if (el) {
|
|
621
|
+
el.classList.remove("is-collapsing");
|
|
622
|
+
el.classList.add("is-expanded");
|
|
623
|
+
applyFaceSize(el, found.book, { expanded: true });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let collapseToken = 0;
|
|
628
|
+
|
|
629
|
+
async function collapseSpine(id) {
|
|
630
|
+
if (pageMode !== "spines") return;
|
|
631
|
+
if (expandedId !== id) return;
|
|
632
|
+
const el = $(`.spine[data-book-id="${id}"]`);
|
|
633
|
+
touchExpandedId = null;
|
|
634
|
+
if (!el) return;
|
|
635
|
+
// Keep is-expanded + expandedId during shrink so a click mid-collapse still takes C path.
|
|
636
|
+
// Drop inline width FIRST — ux17 face size would otherwise beat .is-collapsing CSS width.
|
|
637
|
+
el.style.removeProperty("width");
|
|
638
|
+
el.classList.add("is-collapsing");
|
|
639
|
+
const token = ++collapseToken;
|
|
640
|
+
await wait(cssMs("--t-collapse", 230));
|
|
641
|
+
if (token !== collapseToken) return; // cancelled by open or re-expand
|
|
642
|
+
if (expandedId !== id) return;
|
|
643
|
+
expandedId = null;
|
|
644
|
+
restoreRestingSpine(el, id);
|
|
645
|
+
// Restore home before unpin — undoes SIV that fired during hover/click.
|
|
646
|
+
writeScroll(scrollHome);
|
|
647
|
+
unpinScrollHome();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function cancelCollapse() {
|
|
651
|
+
collapseToken += 1;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function onCoverClick(id, el) {
|
|
655
|
+
if (busy) return;
|
|
656
|
+
const found = bookById(id);
|
|
657
|
+
if (!found || found.book.filler) return;
|
|
658
|
+
await openBookFlow(found.book, found.cat, el, { fromExpanded: false, asSpine: false });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function onSpineClick(id, el, ev) {
|
|
662
|
+
if (busy) return;
|
|
663
|
+
const found = bookById(id);
|
|
664
|
+
if (!found) return;
|
|
665
|
+
if (found.book.filler || !found.book.cover) return;
|
|
666
|
+
|
|
667
|
+
const isTouch = ev && (ev.pointerType === "touch" || ("ontouchstart" in window && window.matchMedia("(hover: none)").matches));
|
|
668
|
+
|
|
669
|
+
// Mobile (Hedon Oct 28): one tap → open animation. No cover-peek then second tap.
|
|
670
|
+
if (isTouch || window.matchMedia("(hover: none)").matches) {
|
|
671
|
+
touchExpandedId = null;
|
|
672
|
+
if (expandedId) await collapseSpine(expandedId);
|
|
673
|
+
await openBookFlow(found.book, found.cat, el, { fromExpanded: false, asSpine: true });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Desktop: expanded OR mid-collapse → C path (retract+pull continuous from current footprint)
|
|
678
|
+
const fromExpanded =
|
|
679
|
+
(expandedId === id && el.classList.contains("is-expanded")) ||
|
|
680
|
+
el.classList.contains("is-collapsing");
|
|
681
|
+
await openBookFlow(found.book, found.cat, el, { fromExpanded, asSpine: true });
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function fillNote(book, cat) {
|
|
685
|
+
$("#noteCat").textContent = cat.name;
|
|
686
|
+
$("#noteTitle").textContent = book.title;
|
|
687
|
+
$("#noteAuthor").textContent = book.author || "";
|
|
688
|
+
const coverEl = $("#noteCover");
|
|
689
|
+
coverEl.className = `note-cover ${bkClass(book)} ${coverClass(book)}`;
|
|
690
|
+
const note = NOTES[book.note];
|
|
691
|
+
const read = $("#noteRead");
|
|
692
|
+
if (read) {
|
|
693
|
+
if (book.href) {
|
|
694
|
+
read.href = book.href;
|
|
695
|
+
read.hidden = false;
|
|
696
|
+
} else {
|
|
697
|
+
read.hidden = true;
|
|
698
|
+
read.removeAttribute("href");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (note) {
|
|
702
|
+
$("#noteBody").innerHTML = note.body;
|
|
703
|
+
} else if (usingDemo) {
|
|
704
|
+
$("#noteBody").innerHTML = `<p class="placeholder">这本还没有完整笔记正文。Demo 占位:返回书架继续抽翻验收。</p><p>${book.title}${book.author ? " · " + book.author : ""}</p>`;
|
|
705
|
+
} else {
|
|
706
|
+
$("#noteBody").innerHTML = `<p class="placeholder">这本还没有笔记正文。</p><p>${escapeHtml(book.title)}${book.author ? " · " + escapeHtml(book.author) : ""}</p>`;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function rectOf(el) {
|
|
711
|
+
const r = el.getBoundingClientRect();
|
|
712
|
+
return {
|
|
713
|
+
left: Math.round(r.left),
|
|
714
|
+
top: Math.round(r.top),
|
|
715
|
+
width: Math.round(r.width),
|
|
716
|
+
height: Math.round(r.height),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Mid flyer = dual-page SPREAD ≈ article column width (§9).
|
|
721
|
+
// Close put-back still targets the real shelf slot via startRect/endRect.
|
|
722
|
+
const EASE = "cubic-bezier(0.32, 0.72, 0, 1)"; // kept for non-clock UI; OPEN uses linear
|
|
723
|
+
const OPEN_MS = 2600; // §12 ux12: open ≤2.8s (keep 2600; extract still ~0.35–0.55s readable)
|
|
724
|
+
const CLOSE_MS = 1900; // §12 ux12: close wall ~1.8–2.2s; no center full-size dwell
|
|
725
|
+
let flowSeq = 0; // invalidate open finally when close starts
|
|
726
|
+
|
|
727
|
+
function midSize(book) {
|
|
728
|
+
// ux25: page aspect = THIS book's natural cover — ban fixed 2:3 preset taller plate
|
|
729
|
+
// behind shorter/square covers (object-fit:contain letterbox ghost).
|
|
730
|
+
const col = Math.min(window.innerWidth - 48, 700);
|
|
731
|
+
const pageW = Math.round(Math.min(col * 0.48, window.innerWidth * 0.4, 340));
|
|
732
|
+
let aspect = 1.45; // fallback ~2:3 only when nat unknown
|
|
733
|
+
const nat = bookNat(book);
|
|
734
|
+
if (nat && nat.w > 0 && nat.h > 0) aspect = nat.h / nat.w;
|
|
735
|
+
const pageH = Math.round(Math.min(pageW * aspect, window.innerHeight * 0.68));
|
|
736
|
+
return { w: pageW * 2, h: Math.max(120, pageH) };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function midRect(book) {
|
|
740
|
+
const { w, h } = midSize(book);
|
|
741
|
+
return {
|
|
742
|
+
left: Math.round(window.innerWidth / 2 - w / 2),
|
|
743
|
+
top: Math.round(window.innerHeight / 2 - h / 2 - 16),
|
|
744
|
+
width: w,
|
|
745
|
+
height: h,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function flipTransform(fromRect, toRect, extra = "") {
|
|
750
|
+
const sx = fromRect.width / toRect.width;
|
|
751
|
+
const sy = fromRect.height / toRect.height;
|
|
752
|
+
const tx = fromRect.left - toRect.left;
|
|
753
|
+
const ty = fromRect.top - toRect.top;
|
|
754
|
+
return `translate(${tx}px, ${ty}px) scale(${sx}, ${sy})${extra ? " " + extra : ""}`;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Closed book: cover on RIGHT half.
|
|
758
|
+
// Covers stay uniform (sx ≈ sy). A narrow spine must NOT share that one scale:
|
|
759
|
+
// min(spineW / halfSpread, spineH / spreadH) ≈ 0.09 crushes height to ~45px.
|
|
760
|
+
// Spine slot: scale X and Y apart so the flyer box height stays the resting spine.
|
|
761
|
+
function closedCoverTransform(slotRect, mid) {
|
|
762
|
+
const halfW = mid.width / 2;
|
|
763
|
+
const sx = slotRect.width / halfW;
|
|
764
|
+
const sy = slotRect.height / mid.height;
|
|
765
|
+
const narrow = slotRect.width / Math.max(1, slotRect.height) < 0.42;
|
|
766
|
+
if (narrow) {
|
|
767
|
+
const tx = slotRect.left - mid.left - halfW * sx;
|
|
768
|
+
const ty = slotRect.top - mid.top;
|
|
769
|
+
return `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px) scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
|
|
770
|
+
}
|
|
771
|
+
const s = Math.min(sx, sy);
|
|
772
|
+
const scaledHalfW = halfW * s;
|
|
773
|
+
const scaledH = mid.height * s;
|
|
774
|
+
const tx = slotRect.left - mid.left - halfW * s + (slotRect.width - scaledHalfW) / 2;
|
|
775
|
+
const ty = slotRect.top - mid.top + (slotRect.height - scaledH) / 2;
|
|
776
|
+
return `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px) scale(${s.toFixed(4)})`;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Flat cover-card → slot (close path). No dual-leaf / half-board math.
|
|
780
|
+
function cardToSlotTransform(slotRect, cardRect) {
|
|
781
|
+
const sx = slotRect.width / cardRect.width;
|
|
782
|
+
const sy = slotRect.height / cardRect.height;
|
|
783
|
+
const s = Math.min(sx, sy);
|
|
784
|
+
const scaledW = cardRect.width * s;
|
|
785
|
+
const scaledH = cardRect.height * s;
|
|
786
|
+
const tx = slotRect.left - cardRect.left + (slotRect.width - scaledW) / 2;
|
|
787
|
+
const ty = slotRect.top - cardRect.top + (slotRect.height - scaledH) / 2;
|
|
788
|
+
return `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px) scale(${s.toFixed(4)})`;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function coverCardRect(mid) {
|
|
792
|
+
const w = Math.round(mid.width / 2);
|
|
793
|
+
const h = mid.height;
|
|
794
|
+
return {
|
|
795
|
+
left: Math.round(mid.left + (mid.width - w) / 2),
|
|
796
|
+
top: mid.top,
|
|
797
|
+
width: w,
|
|
798
|
+
height: h,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// §13/ux14: shared shelf↔center waypoints — open forward, close reverses SAME list.
|
|
803
|
+
function lerpRect(a, b, t) {
|
|
804
|
+
const u = Math.max(0, Math.min(1, t));
|
|
805
|
+
return {
|
|
806
|
+
left: Math.round(a.left + (b.left - a.left) * u),
|
|
807
|
+
top: Math.round(a.top + (b.top - a.top) * u),
|
|
808
|
+
width: Math.round(a.width + (b.width - a.width) * u),
|
|
809
|
+
height: Math.round(a.height + (b.height - a.height) * u),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function copyRect(r) {
|
|
814
|
+
return {
|
|
815
|
+
left: Math.round(r.left),
|
|
816
|
+
top: Math.round(r.top),
|
|
817
|
+
width: Math.round(r.width),
|
|
818
|
+
height: Math.round(r.height),
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
// ux16: FULL-HEIGHT standing spine at slot mouth — exact homeRect size.
|
|
824
|
+
// Root cause of ux15 purple stub: closedCoverTransform uses min(sx,sy) uniform
|
|
825
|
+
// scale of mid-flyer onto narrow spine → height collapses (~40px) + label → "…".
|
|
826
|
+
// Fix: dedicated spine-slot flyer matching getBoundingClientRect of the shelf spine
|
|
827
|
+
// (full height, spine width). Dual mid-flyer stays on ux14 path but hidden until
|
|
828
|
+
// after leaving the slot / before seating.
|
|
829
|
+
function makeSpineSlotFlyer(book, homeRect, { opacity = 1 } = {}) {
|
|
830
|
+
const el = document.createElement("div");
|
|
831
|
+
el.className = ("flyer flyer-spine-slot " + bkClass(book)).trim();
|
|
832
|
+
el.setAttribute("aria-hidden", "true");
|
|
833
|
+
const w = Math.max(1, Math.round(homeRect.width));
|
|
834
|
+
const h = Math.max(1, Math.round(homeRect.height));
|
|
835
|
+
el.style.left = Math.round(homeRect.left) + "px";
|
|
836
|
+
el.style.top = Math.round(homeRect.top) + "px";
|
|
837
|
+
el.style.width = w + "px";
|
|
838
|
+
el.style.height = h + "px";
|
|
839
|
+
el.style.transform = "translate3d(0px, 0px, 0)";
|
|
840
|
+
el.style.opacity = String(opacity);
|
|
841
|
+
el.style.zIndex = "80";
|
|
842
|
+
el.style.pointerEvents = "none";
|
|
843
|
+
const label = document.createElement("span");
|
|
844
|
+
label.className = "flyer-spine-slot-label";
|
|
845
|
+
label.textContent = book.spine || book.title || "";
|
|
846
|
+
el.appendChild(label);
|
|
847
|
+
stage.appendChild(el);
|
|
848
|
+
return el;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function disposeSpineSlot(el) {
|
|
852
|
+
if (!el) return;
|
|
853
|
+
try {
|
|
854
|
+
el.getAnimations({ subtree: true }).forEach((a) => {
|
|
855
|
+
try { a.cancel(); } catch (_) {}
|
|
856
|
+
});
|
|
857
|
+
} catch (_) {}
|
|
858
|
+
try { el.remove(); } catch (_) {}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function spineSlotOutDelta(homeRect, mid) {
|
|
862
|
+
const homeCx = homeRect.left + homeRect.width / 2;
|
|
863
|
+
const homeCy = homeRect.top + homeRect.height / 2;
|
|
864
|
+
const midCx = mid.left + mid.width / 2;
|
|
865
|
+
const midCy = mid.top + mid.height / 2;
|
|
866
|
+
// ~12% toward screen center — readable "slide out of slot" without jumping.
|
|
867
|
+
return {
|
|
868
|
+
x: (midCx - homeCx) * 0.12,
|
|
869
|
+
y: (midCy - homeCy) * 0.12,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Kept for compatibility; ux16 no longer paints spine as inset plate on scaled dual.
|
|
874
|
+
function attachSpineOverlay(coverEl, book, { opacity = 0 } = {}) {
|
|
875
|
+
if (!coverEl || !book) return { plate: null, label: null };
|
|
876
|
+
return { plate: null, label: null };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Readable CLOSED book footprint (cover aspect). Used as MORPH TARGET only — never
|
|
880
|
+
// snap frame-0 to this size (ux14: gradual across lift→extract).
|
|
881
|
+
function closedBookRect(slotRect, book) {
|
|
882
|
+
const w0 = Math.round(slotRect.width);
|
|
883
|
+
const h0 = Math.round(slotRect.height);
|
|
884
|
+
const ratio = w0 / Math.max(1, h0);
|
|
885
|
+
// Already cover-shaped (covers path or expanded): keep footprint.
|
|
886
|
+
if (ratio >= 0.42) {
|
|
887
|
+
return { left: Math.round(slotRect.left), top: Math.round(slotRect.top), width: w0, height: h0 };
|
|
888
|
+
}
|
|
889
|
+
// Spine seat → morph to that book's natural cover face (fixed width, nat height).
|
|
890
|
+
const w = coverWidthPx();
|
|
891
|
+
const h = book ? bookFaceHeight(book) : Math.round(Math.min(Math.max(h0, 120), 180));
|
|
892
|
+
return {
|
|
893
|
+
left: Math.round(slotRect.left + w0 / 2 - w / 2),
|
|
894
|
+
top: Math.round(slotRect.top + h0 - h), // sit on same rail
|
|
895
|
+
width: w,
|
|
896
|
+
height: h,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function centerClosedRect(seatR) {
|
|
901
|
+
return {
|
|
902
|
+
left: Math.round(window.innerWidth / 2 - seatR.width / 2),
|
|
903
|
+
top: Math.round(window.innerHeight / 2 - seatR.height / 2 - 16),
|
|
904
|
+
width: seatR.width,
|
|
905
|
+
height: seatR.height,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ux14: ONE transform math for shelf↔center on open AND close (dual mid + right-half cover).
|
|
910
|
+
function pathTransform(slotRect, mid) {
|
|
911
|
+
return closedCoverTransform(slotRect, mid);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Parse translate/scale from our path strings; lerp components so grow ≠ hard jump (PATH-SHAKE ~2.2s).
|
|
915
|
+
function parseTS(transform) {
|
|
916
|
+
const s = String(transform || "");
|
|
917
|
+
let tx = 0, ty = 0, sx = 1, sy = 1;
|
|
918
|
+
let m = s.match(/translate3d\(\s*([\d.-]+)px\s*,\s*([\d.-]+)px/);
|
|
919
|
+
if (m) { tx = +m[1]; ty = +m[2]; }
|
|
920
|
+
else {
|
|
921
|
+
m = s.match(/translate\(\s*([\d.-]+)px\s*,\s*([\d.-]+)px/);
|
|
922
|
+
if (m) { tx = +m[1]; ty = +m[2]; }
|
|
923
|
+
}
|
|
924
|
+
m = s.match(/scale\(\s*([\d.-]+)(?:\s*,\s*([\d.-]+))?\s*\)/);
|
|
925
|
+
if (m) {
|
|
926
|
+
sx = +m[1];
|
|
927
|
+
sy = m[2] != null ? +m[2] : sx;
|
|
928
|
+
}
|
|
929
|
+
return { tx, ty, sx, sy };
|
|
930
|
+
}
|
|
931
|
+
function formatTS(p) {
|
|
932
|
+
const sx = p.sx != null ? p.sx : p.s;
|
|
933
|
+
const sy = p.sy != null ? p.sy : sx;
|
|
934
|
+
const scale = Math.abs(sx - sy) < 1e-4
|
|
935
|
+
? `scale(${sx.toFixed(4)})`
|
|
936
|
+
: `scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
|
|
937
|
+
return `translate(${p.tx.toFixed(2)}px, ${p.ty.toFixed(2)}px) ${scale}`;
|
|
938
|
+
}
|
|
939
|
+
function lerpTS(a, b, t) {
|
|
940
|
+
const u = Math.max(0, Math.min(1, t));
|
|
941
|
+
const pa = typeof a === "string" ? parseTS(a) : a;
|
|
942
|
+
const pb = typeof b === "string" ? parseTS(b) : b;
|
|
943
|
+
return formatTS({
|
|
944
|
+
tx: pa.tx + (pb.tx - pa.tx) * u,
|
|
945
|
+
ty: pa.ty + (pb.ty - pa.ty) * u,
|
|
946
|
+
sx: pa.sx + (pb.sx - pa.sx) * u,
|
|
947
|
+
sy: pa.sy + (pb.sy - pa.sy) * u,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Pose for the flyer move. Same tx/ty/sx/sy as the ux26 string, but on the
|
|
952
|
+
// individual translate and scale properties. A combined transform matrix
|
|
953
|
+
// decomposes badly when sx ≠ sy (slot height pin) and the flight stutters.
|
|
954
|
+
function pathPose(transform) {
|
|
955
|
+
const p = parseTS(transform);
|
|
956
|
+
return {
|
|
957
|
+
translate: `${p.tx.toFixed(2)}px ${p.ty.toFixed(2)}px`,
|
|
958
|
+
scale: `${p.sx.toFixed(4)} ${p.sy.toFixed(4)}`,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
function applyPathPose(el, transform) {
|
|
962
|
+
if (!el) return;
|
|
963
|
+
const pose = pathPose(transform);
|
|
964
|
+
el.style.transform = "none";
|
|
965
|
+
el.style.translate = pose.translate;
|
|
966
|
+
el.style.scale = pose.scale;
|
|
967
|
+
}
|
|
968
|
+
function pathKey(offset, transform) {
|
|
969
|
+
return { offset, ...pathPose(transform) };
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
|
|
973
|
+
// X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
|
|
974
|
+
// Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
|
|
975
|
+
function buildSlotWaypoints(homeRect, mid, book) {
|
|
976
|
+
const home = copyRect(homeRect);
|
|
977
|
+
const closedTarget = closedBookRect(home, book || (openBook && openBook.book));
|
|
978
|
+
const centerClosed = centerClosedRect(closedTarget);
|
|
979
|
+
const T0 = pathTransform(home, mid);
|
|
980
|
+
const Tfull = "translate(0.00px, -4.00px) scale(1.0000)";
|
|
981
|
+
// Pure component lerp T0→Tfull: position+scale advance together (no staged L).
|
|
982
|
+
const Tp1 = lerpTS(T0, Tfull, 0.25);
|
|
983
|
+
const Tp2 = lerpTS(T0, Tfull, 0.50);
|
|
984
|
+
const Tp3 = lerpTS(T0, Tfull, 0.75);
|
|
985
|
+
return {
|
|
986
|
+
home,
|
|
987
|
+
centerClosed,
|
|
988
|
+
closedTarget,
|
|
989
|
+
mid: copyRect(mid),
|
|
990
|
+
T0,
|
|
991
|
+
Tp1,
|
|
992
|
+
Tp2,
|
|
993
|
+
Tp3,
|
|
994
|
+
Tfull,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Close-only: single flat cover face + durable <img>. No board / cream / preserve-3d.
|
|
999
|
+
// ux7: keep CSS cover url until <img> is actually decoded — spine-direct Clean Code
|
|
1000
|
+
// used to clear backgroundImage on img-create → gray-brown flat board mid-close.
|
|
1001
|
+
function coverImgReady(img) {
|
|
1002
|
+
return !!(img && img.complete && img.naturalWidth > 0);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function preferImgOverBg(face) {
|
|
1006
|
+
if (!face) return;
|
|
1007
|
+
const img = face.querySelector(":scope > .flyer-cover-img");
|
|
1008
|
+
if (coverImgReady(img)) {
|
|
1009
|
+
face.style.backgroundImage = "none";
|
|
1010
|
+
// ux24: transparent under img — ban #4a3b2f wood plate
|
|
1011
|
+
face.style.backgroundColor = "transparent";
|
|
1012
|
+
img.style.opacity = "1";
|
|
1013
|
+
img.style.visibility = "visible";
|
|
1014
|
+
}
|
|
1015
|
+
// else keep applyCoverImage's url(...) so mid-flight never shows naked board
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async function awaitCloseCoverReady(face, book, ms = 400) {
|
|
1019
|
+
if (!face || !book) return;
|
|
1020
|
+
applyCoverImage(face, book);
|
|
1021
|
+
const img = face.querySelector(":scope > .flyer-cover-img");
|
|
1022
|
+
if (!img) return;
|
|
1023
|
+
if (coverImgReady(img)) {
|
|
1024
|
+
preferImgOverBg(face);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
const decodeOrLoad = img.decode
|
|
1029
|
+
? img.decode()
|
|
1030
|
+
: new Promise((res) => {
|
|
1031
|
+
img.onload = res;
|
|
1032
|
+
img.onerror = res;
|
|
1033
|
+
});
|
|
1034
|
+
await Promise.race([decodeOrLoad.catch(() => {}), wait(ms)]);
|
|
1035
|
+
} catch (_) {}
|
|
1036
|
+
preferImgOverBg(face);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function makeCloseCoverFlyer(book, cardRect) {
|
|
1040
|
+
const flyer = document.createElement("div");
|
|
1041
|
+
flyer.className = "flyer flyer-close-cover";
|
|
1042
|
+
const face = document.createElement("div");
|
|
1043
|
+
face.className = (
|
|
1044
|
+
"flyer-close-face " + bkClass(book) + " " + coverClass(book)
|
|
1045
|
+
).trim();
|
|
1046
|
+
face.classList.add("is-cover-ready");
|
|
1047
|
+
applyCoverImage(face, book);
|
|
1048
|
+
// ux7-spine-close-cover-texture: do NOT drop background until img decoded
|
|
1049
|
+
preferImgOverBg(face);
|
|
1050
|
+
flyer.appendChild(face);
|
|
1051
|
+
flyer._cover = face;
|
|
1052
|
+
flyer._front = face;
|
|
1053
|
+
flyer._left = null;
|
|
1054
|
+
flyer._right = null;
|
|
1055
|
+
flyer._book = null;
|
|
1056
|
+
flyer._gutter = null;
|
|
1057
|
+
flyer._shadow = null;
|
|
1058
|
+
flyer.style.width = cardRect.width + "px";
|
|
1059
|
+
flyer.style.height = cardRect.height + "px";
|
|
1060
|
+
flyer.style.left = cardRect.left + "px";
|
|
1061
|
+
flyer.style.top = cardRect.top + "px";
|
|
1062
|
+
flyer.style.transform = "translate3d(0px, -4px, 0) scale(1)";
|
|
1063
|
+
flyer.style.opacity = "1";
|
|
1064
|
+
stage.appendChild(flyer);
|
|
1065
|
+
return flyer;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function parkShelf() {
|
|
1069
|
+
shelfView.classList.add("is-parked");
|
|
1070
|
+
shelfView.classList.remove("is-dim");
|
|
1071
|
+
shelfView.hidden = false;
|
|
1072
|
+
shelfView.style.opacity = "";
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function unparkShelf() {
|
|
1076
|
+
shelfView.classList.remove("is-parked");
|
|
1077
|
+
shelfView.hidden = false;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// ux6b: face-out (hover-expand → open) left whole shelf ~197px high (rail 361→164)
|
|
1081
|
+
// and stuck after close. Cause: scrollY drift (scrollIntoView / overflow-anchor /
|
|
1082
|
+
// pointerleave re-pin after SIV), not row collapse. Spine-direct never drifted.
|
|
1083
|
+
// Fix: sample idle home; pin FIRST expand from idle (never from drifted Y);
|
|
1084
|
+
// while expanded OR open, reject scroll away from home; restore on collapse+close.
|
|
1085
|
+
// Never call scrollIntoView.
|
|
1086
|
+
let idleScrollHome = { x: 0, y: 0 };
|
|
1087
|
+
let scrollHome = { x: 0, y: 0 };
|
|
1088
|
+
let scrollHomePinned = false; // true from first expand until release/collapse restore
|
|
1089
|
+
let scrollLock = null;
|
|
1090
|
+
let idleScrollTimer = 0;
|
|
1091
|
+
function readScroll() {
|
|
1092
|
+
return {
|
|
1093
|
+
x: window.scrollX || document.documentElement.scrollLeft || 0,
|
|
1094
|
+
y: window.scrollY || document.documentElement.scrollTop || 0,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
function writeScroll(s) {
|
|
1098
|
+
if (!s) return;
|
|
1099
|
+
try { window.scrollTo(s.x, s.y); } catch (_) {}
|
|
1100
|
+
}
|
|
1101
|
+
function sampleIdleScrollHome() {
|
|
1102
|
+
if (scrollLock || scrollHomePinned || busy || expandedId || openBook) return;
|
|
1103
|
+
idleScrollHome = readScroll();
|
|
1104
|
+
scrollHome = { x: idleScrollHome.x, y: idleScrollHome.y };
|
|
1105
|
+
}
|
|
1106
|
+
function rememberScrollHome() {
|
|
1107
|
+
if (scrollLock || scrollHomePinned) return;
|
|
1108
|
+
sampleIdleScrollHome();
|
|
1109
|
+
}
|
|
1110
|
+
function pinScrollHome() {
|
|
1111
|
+
// FIRST pin wins for this expand session. Use last IDLE home — never readScroll()
|
|
1112
|
+
// here (automation scrollIntoView may already have drifted before pointerenter/click).
|
|
1113
|
+
if (scrollHomePinned) return;
|
|
1114
|
+
scrollHome = { x: idleScrollHome.x, y: idleScrollHome.y };
|
|
1115
|
+
scrollHomePinned = true;
|
|
1116
|
+
writeScroll(scrollHome);
|
|
1117
|
+
armExpandedScrollGuard();
|
|
1118
|
+
}
|
|
1119
|
+
function unpinScrollHome() {
|
|
1120
|
+
scrollHomePinned = false;
|
|
1121
|
+
disarmExpandedScrollGuard();
|
|
1122
|
+
}
|
|
1123
|
+
function targetScrollHome() {
|
|
1124
|
+
if (scrollLock) return scrollLock;
|
|
1125
|
+
if (scrollHomePinned) return scrollHome;
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
function onScrollGuard(ev) {
|
|
1129
|
+
const target = targetScrollHome();
|
|
1130
|
+
if (!target) return;
|
|
1131
|
+
const now = readScroll();
|
|
1132
|
+
if (now.x !== target.x || now.y !== target.y) {
|
|
1133
|
+
if (ev && ev.cancelable) {
|
|
1134
|
+
try { ev.preventDefault(); } catch (_) {}
|
|
1135
|
+
}
|
|
1136
|
+
writeScroll(target);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
let expandedGuardArmed = false;
|
|
1140
|
+
let expandedGuardRaf = 0;
|
|
1141
|
+
function expandGuardTick() {
|
|
1142
|
+
expandedGuardRaf = 0;
|
|
1143
|
+
const target = targetScrollHome();
|
|
1144
|
+
if (!target) return;
|
|
1145
|
+
const now = readScroll();
|
|
1146
|
+
if (now.x !== target.x || now.y !== target.y) writeScroll(target);
|
|
1147
|
+
// Keep polling while expanded/open — some SIV paths skip scroll events.
|
|
1148
|
+
if (scrollHomePinned || scrollLock) {
|
|
1149
|
+
expandedGuardRaf = requestAnimationFrame(expandGuardTick);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
function armExpandedScrollGuard() {
|
|
1153
|
+
if (!expandedGuardArmed) {
|
|
1154
|
+
expandedGuardArmed = true;
|
|
1155
|
+
window.addEventListener("scroll", onScrollGuard, { passive: false, capture: true });
|
|
1156
|
+
}
|
|
1157
|
+
if (!expandedGuardRaf) expandedGuardRaf = requestAnimationFrame(expandGuardTick);
|
|
1158
|
+
}
|
|
1159
|
+
function disarmExpandedScrollGuard() {
|
|
1160
|
+
if (scrollLock) return; // open lock owns the listener + rAF
|
|
1161
|
+
if (expandedGuardRaf) {
|
|
1162
|
+
try { cancelAnimationFrame(expandedGuardRaf); } catch (_) {}
|
|
1163
|
+
expandedGuardRaf = 0;
|
|
1164
|
+
}
|
|
1165
|
+
if (!expandedGuardArmed) return;
|
|
1166
|
+
expandedGuardArmed = false;
|
|
1167
|
+
window.removeEventListener("scroll", onScrollGuard, { capture: true });
|
|
1168
|
+
}
|
|
1169
|
+
function armScrollLock() {
|
|
1170
|
+
// Prefer pinned expand home / idle home — undoes pre-click scrollIntoView drift.
|
|
1171
|
+
if (!scrollHomePinned) {
|
|
1172
|
+
scrollHome = { x: idleScrollHome.x, y: idleScrollHome.y };
|
|
1173
|
+
}
|
|
1174
|
+
scrollLock = { x: scrollHome.x, y: scrollHome.y };
|
|
1175
|
+
writeScroll(scrollLock);
|
|
1176
|
+
try { document.documentElement.style.overflowAnchor = "none"; } catch (_) {}
|
|
1177
|
+
if (shelfView) {
|
|
1178
|
+
try { shelfView.style.overflowAnchor = "none"; } catch (_) {}
|
|
1179
|
+
}
|
|
1180
|
+
armExpandedScrollGuard();
|
|
1181
|
+
// Re-assert after layout / focus scrolls settle.
|
|
1182
|
+
requestAnimationFrame(() => writeScroll(scrollLock));
|
|
1183
|
+
requestAnimationFrame(() => requestAnimationFrame(() => writeScroll(scrollLock)));
|
|
1184
|
+
}
|
|
1185
|
+
function releaseScrollLock() {
|
|
1186
|
+
const home = scrollLock
|
|
1187
|
+
? { x: scrollLock.x, y: scrollLock.y }
|
|
1188
|
+
: { x: scrollHome.x, y: scrollHome.y };
|
|
1189
|
+
scrollLock = null;
|
|
1190
|
+
scrollHomePinned = false;
|
|
1191
|
+
expandedGuardArmed = false;
|
|
1192
|
+
if (expandedGuardRaf) {
|
|
1193
|
+
try { cancelAnimationFrame(expandedGuardRaf); } catch (_) {}
|
|
1194
|
+
expandedGuardRaf = 0;
|
|
1195
|
+
}
|
|
1196
|
+
window.removeEventListener("scroll", onScrollGuard, { capture: true });
|
|
1197
|
+
writeScroll(home);
|
|
1198
|
+
scrollHome = home;
|
|
1199
|
+
idleScrollHome = home;
|
|
1200
|
+
try { document.documentElement.style.removeProperty("overflow-anchor"); } catch (_) {}
|
|
1201
|
+
if (shelfView) {
|
|
1202
|
+
try { shelfView.style.removeProperty("overflow-anchor"); } catch (_) {}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
function onWindowScrollIdle() {
|
|
1206
|
+
if (targetScrollHome()) {
|
|
1207
|
+
onScrollGuard(null);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
clearTimeout(idleScrollTimer);
|
|
1211
|
+
idleScrollTimer = setTimeout(sampleIdleScrollHome, 80);
|
|
1212
|
+
}
|
|
1213
|
+
listen(window, "scroll", onWindowScrollIdle, { passive: true, capture: true });
|
|
1214
|
+
|
|
1215
|
+
function animDuration(a) {
|
|
1216
|
+
try {
|
|
1217
|
+
const t = a.effect && a.effect.getTiming ? a.effect.getTiming() : null;
|
|
1218
|
+
const d = t && t.duration;
|
|
1219
|
+
return typeof d === "number" ? d : OPEN_MS;
|
|
1220
|
+
} catch (_) {
|
|
1221
|
+
return OPEN_MS;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Settle finals FIRST, then cancel — never cancel into a pre-anim flash.
|
|
1226
|
+
// Avoid commitStyles (iOS leftovers); caller writes inline finals in `settle`.
|
|
1227
|
+
function runGroup(animations, timeoutMs, settle) {
|
|
1228
|
+
if (!animations.length) {
|
|
1229
|
+
if (typeof settle === "function") settle();
|
|
1230
|
+
return Promise.resolve();
|
|
1231
|
+
}
|
|
1232
|
+
const ms =
|
|
1233
|
+
timeoutMs != null
|
|
1234
|
+
? timeoutMs
|
|
1235
|
+
: Math.max.apply(null, animations.map(animDuration)) + 320;
|
|
1236
|
+
const done = Promise.all(animations.map((a) => a.finished.catch(() => {})));
|
|
1237
|
+
return Promise.race([done, wait(ms)]).then(() => {
|
|
1238
|
+
if (typeof settle === "function") settle();
|
|
1239
|
+
for (const a of animations) {
|
|
1240
|
+
try { a.cancel(); } catch (_) {}
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function disposeFlyer(flyer) {
|
|
1246
|
+
if (!flyer) {
|
|
1247
|
+
if (stage) stage.innerHTML = "";
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
try {
|
|
1251
|
+
flyer.getAnimations({ subtree: true }).forEach((a) => {
|
|
1252
|
+
try { a.cancel(); } catch (_) {}
|
|
1253
|
+
});
|
|
1254
|
+
} catch (_) {}
|
|
1255
|
+
flyer.style.opacity = "0";
|
|
1256
|
+
flyer.style.visibility = "hidden";
|
|
1257
|
+
flyer.style.pointerEvents = "none";
|
|
1258
|
+
demote(flyer);
|
|
1259
|
+
if (flyer._left) demote(flyer._left);
|
|
1260
|
+
if (flyer._right) demote(flyer._right);
|
|
1261
|
+
if (flyer._cover) demote(flyer._cover);
|
|
1262
|
+
if (flyer._book) demote(flyer._book);
|
|
1263
|
+
if (flyer._front) demote(flyer._front);
|
|
1264
|
+
try { flyer.remove(); } catch (_) {}
|
|
1265
|
+
if (stage) stage.innerHTML = "";
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function restoreSlot(el, book) {
|
|
1269
|
+
if (!el) return;
|
|
1270
|
+
try {
|
|
1271
|
+
el.getAnimations({ subtree: true }).forEach((a) => {
|
|
1272
|
+
try { a.cancel(); } catch (_) {}
|
|
1273
|
+
});
|
|
1274
|
+
} catch (_) {}
|
|
1275
|
+
el.classList.remove(
|
|
1276
|
+
"is-placeholder",
|
|
1277
|
+
"is-opening",
|
|
1278
|
+
"is-landing",
|
|
1279
|
+
"is-expanded",
|
|
1280
|
+
"is-collapsing"
|
|
1281
|
+
);
|
|
1282
|
+
// Explicit removeProperty — kills commitStyles leftovers like opacity:0 / transform
|
|
1283
|
+
[
|
|
1284
|
+
"width",
|
|
1285
|
+
"height",
|
|
1286
|
+
"min-width",
|
|
1287
|
+
"min-height",
|
|
1288
|
+
"flex-shrink",
|
|
1289
|
+
"transition",
|
|
1290
|
+
"opacity",
|
|
1291
|
+
"visibility",
|
|
1292
|
+
"transform",
|
|
1293
|
+
"filter",
|
|
1294
|
+
"will-change",
|
|
1295
|
+
"display",
|
|
1296
|
+
"pointer-events",
|
|
1297
|
+
].forEach((prop) => {
|
|
1298
|
+
try { el.style.removeProperty(prop); } catch (_) {}
|
|
1299
|
+
});
|
|
1300
|
+
// ux25: after clearing height, re-pin natural cover face — ban CSS 154/178 taller plate
|
|
1301
|
+
if (book && el.classList.contains("cover") && book.cover) {
|
|
1302
|
+
applyFaceSize(el, book);
|
|
1303
|
+
} else if (book && el.classList.contains("spine") && book.cover && !book.filler) {
|
|
1304
|
+
applyFaceSize(el, book);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function pageLinesHtml(title) {
|
|
1309
|
+
// ux26: kept as thin alias — dual pages use leftPageHtml / rightPageHtml
|
|
1310
|
+
return leftPageHtml({ title: title || "" });
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// §10 held-book flyer: board + dual leaves + gutter + thickness + foot shadow.
|
|
1314
|
+
// Closed: left leaf rotateY(180°) (cover on right half). Open: modest ±Y V + book rotateX.
|
|
1315
|
+
function makeFlyer(book, atRect, { spineShape, spreadOpen = false } = {}) {
|
|
1316
|
+
const flyer = document.createElement("div");
|
|
1317
|
+
flyer.className = "flyer";
|
|
1318
|
+
|
|
1319
|
+
const shadow = document.createElement("div");
|
|
1320
|
+
shadow.className = "flyer-shadow";
|
|
1321
|
+
|
|
1322
|
+
const bookEl = document.createElement("div");
|
|
1323
|
+
bookEl.className = "flyer-book";
|
|
1324
|
+
|
|
1325
|
+
const board = document.createElement("div");
|
|
1326
|
+
board.className = "flyer-board";
|
|
1327
|
+
|
|
1328
|
+
const left = document.createElement("div");
|
|
1329
|
+
left.className = "flyer-leaf flyer-leaf-left";
|
|
1330
|
+
const leftThick = document.createElement("div");
|
|
1331
|
+
leftThick.className = "flyer-thickness";
|
|
1332
|
+
const leftPage = document.createElement("div");
|
|
1333
|
+
leftPage.className = "flyer-face flyer-face-page";
|
|
1334
|
+
leftPage.innerHTML = leftPageHtml(book);
|
|
1335
|
+
const cover = document.createElement("div");
|
|
1336
|
+
cover.className = ("flyer-face flyer-face-cover " + bkClass(book) + " " + coverClass(book)).trim();
|
|
1337
|
+
if (spineShape) {
|
|
1338
|
+
cover.classList.add("is-spine-face");
|
|
1339
|
+
const label = document.createElement("span");
|
|
1340
|
+
label.className = "flyer-spine-label";
|
|
1341
|
+
label.textContent = book.spine;
|
|
1342
|
+
cover.appendChild(label);
|
|
1343
|
+
} else {
|
|
1344
|
+
cover.classList.add("is-cover-ready");
|
|
1345
|
+
}
|
|
1346
|
+
applyCoverImage(cover, book);
|
|
1347
|
+
left.appendChild(leftThick);
|
|
1348
|
+
left.appendChild(leftPage);
|
|
1349
|
+
left.appendChild(cover);
|
|
1350
|
+
|
|
1351
|
+
const right = document.createElement("div");
|
|
1352
|
+
right.className = "flyer-leaf flyer-leaf-right";
|
|
1353
|
+
const rightThick = document.createElement("div");
|
|
1354
|
+
rightThick.className = "flyer-thickness";
|
|
1355
|
+
const rightPage = document.createElement("div");
|
|
1356
|
+
rightPage.className = "flyer-face flyer-face-page";
|
|
1357
|
+
rightPage.innerHTML = rightPageHtml(book);
|
|
1358
|
+
right.appendChild(rightThick);
|
|
1359
|
+
right.appendChild(rightPage);
|
|
1360
|
+
|
|
1361
|
+
const gutter = document.createElement("div");
|
|
1362
|
+
gutter.className = "flyer-gutter";
|
|
1363
|
+
|
|
1364
|
+
bookEl.appendChild(board);
|
|
1365
|
+
bookEl.appendChild(left);
|
|
1366
|
+
bookEl.appendChild(right);
|
|
1367
|
+
bookEl.appendChild(gutter);
|
|
1368
|
+
flyer.appendChild(shadow);
|
|
1369
|
+
flyer.appendChild(bookEl);
|
|
1370
|
+
|
|
1371
|
+
flyer._left = left;
|
|
1372
|
+
flyer._right = right;
|
|
1373
|
+
flyer._cover = cover;
|
|
1374
|
+
flyer._front = cover;
|
|
1375
|
+
flyer._shadow = shadow;
|
|
1376
|
+
flyer._book = bookEl;
|
|
1377
|
+
flyer._gutter = gutter;
|
|
1378
|
+
flyer._board = board;
|
|
1379
|
+
flyer._leftThick = leftThick;
|
|
1380
|
+
flyer._rightThick = rightThick;
|
|
1381
|
+
flyer._leftPage = leftPage;
|
|
1382
|
+
flyer._rightPage = rightPage;
|
|
1383
|
+
applyFlyerPalette(flyer, book);
|
|
1384
|
+
|
|
1385
|
+
const mid = midRect(book);
|
|
1386
|
+
flyer.style.width = mid.width + "px";
|
|
1387
|
+
flyer.style.height = mid.height + "px";
|
|
1388
|
+
flyer.style.left = mid.left + "px";
|
|
1389
|
+
flyer.style.top = mid.top + "px";
|
|
1390
|
+
|
|
1391
|
+
if (spreadOpen) {
|
|
1392
|
+
left.style.transform = "rotateY(8deg)";
|
|
1393
|
+
right.style.transform = "rotateY(-8deg)";
|
|
1394
|
+
bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
|
|
1395
|
+
gutter.style.opacity = "1";
|
|
1396
|
+
applyPathPose(flyer, "translate(0.00px, -4.00px) scale(1)");
|
|
1397
|
+
flyer.style.opacity = "1";
|
|
1398
|
+
} else {
|
|
1399
|
+
// ux10: closed extract must look like ONE cover/spine — hide board + right page
|
|
1400
|
+
// so scaled dual-mid does not show brown+white flattened in the slot (ux9 fail).
|
|
1401
|
+
left.style.transform = "rotateY(180deg)";
|
|
1402
|
+
right.style.transform = "rotateY(0deg)";
|
|
1403
|
+
right.style.opacity = "0";
|
|
1404
|
+
board.style.opacity = "0";
|
|
1405
|
+
leftThick.style.opacity = "0";
|
|
1406
|
+
rightThick.style.opacity = "0";
|
|
1407
|
+
bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
|
|
1408
|
+
gutter.style.opacity = "0";
|
|
1409
|
+
applyPathPose(flyer, closedCoverTransform(atRect, mid));
|
|
1410
|
+
flyer.style.opacity = "1";
|
|
1411
|
+
}
|
|
1412
|
+
stage.appendChild(flyer);
|
|
1413
|
+
return flyer;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function promote(el) {
|
|
1417
|
+
if (!el) return;
|
|
1418
|
+
el.classList.add("is-ready");
|
|
1419
|
+
el.style.willChange = "translate, scale, opacity";
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
function demote(el) {
|
|
1423
|
+
if (!el) return;
|
|
1424
|
+
el.classList.remove("is-ready");
|
|
1425
|
+
el.style.willChange = "auto";
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function setVeilOrigin(x, y) {
|
|
1429
|
+
if (!openVeil) return;
|
|
1430
|
+
openVeil.style.setProperty("--vx", ((x / window.innerWidth) * 100).toFixed(2) + "%");
|
|
1431
|
+
openVeil.style.setProperty("--vy", ((y / window.innerHeight) * 100).toFixed(2) + "%");
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function resetVeil() {
|
|
1435
|
+
if (!openVeil) return;
|
|
1436
|
+
openVeil.classList.remove("is-on");
|
|
1437
|
+
openVeil.style.opacity = "";
|
|
1438
|
+
if (openVeilBlob) {
|
|
1439
|
+
openVeilBlob.style.transform = "";
|
|
1440
|
+
openVeilBlob.style.opacity = "";
|
|
1441
|
+
demote(openVeilBlob);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function armBackdrop({ solid = false } = {}) {
|
|
1446
|
+
if (!openBackdrop) return;
|
|
1447
|
+
openBackdrop.classList.add("is-on");
|
|
1448
|
+
openBackdrop.classList.toggle("is-solid", !!solid);
|
|
1449
|
+
openBackdrop.style.opacity = solid ? "1" : "";
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function resetBackdrop() {
|
|
1453
|
+
if (!openBackdrop) return;
|
|
1454
|
+
openBackdrop.classList.remove("is-on", "is-solid");
|
|
1455
|
+
openBackdrop.style.opacity = "";
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
function armVeilAt(originX, originY, { full = false } = {}) {
|
|
1460
|
+
setVeilOrigin(originX, originY);
|
|
1461
|
+
openVeil.classList.add("is-on");
|
|
1462
|
+
openVeil.style.opacity = "1";
|
|
1463
|
+
if (openVeilBlob) {
|
|
1464
|
+
openVeilBlob.style.transform = full
|
|
1465
|
+
? "translateZ(0) scale(1)"
|
|
1466
|
+
: "translateZ(0) scale(0)";
|
|
1467
|
+
openVeilBlob.style.opacity = "1";
|
|
1468
|
+
promote(openVeilBlob);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
async function prepareNote(book, cat) {
|
|
1473
|
+
fillNote(book, cat);
|
|
1474
|
+
noteView.hidden = false;
|
|
1475
|
+
noteView.classList.add("is-preparing");
|
|
1476
|
+
noteView.classList.remove("is-open");
|
|
1477
|
+
noteView.style.opacity = "0";
|
|
1478
|
+
const coverEl = $("#noteCover");
|
|
1479
|
+
const bg = coverEl && getComputedStyle(coverEl).backgroundImage;
|
|
1480
|
+
const m = bg && bg.match(/url\(["']?([^"')]+)["']?\)/);
|
|
1481
|
+
if (m) {
|
|
1482
|
+
try {
|
|
1483
|
+
const img = new Image();
|
|
1484
|
+
img.src = m[1];
|
|
1485
|
+
const DECODE_MS = 400;
|
|
1486
|
+
const decodeOrLoad = img.decode
|
|
1487
|
+
? img.decode()
|
|
1488
|
+
: new Promise((res) => { img.onload = res; img.onerror = res; });
|
|
1489
|
+
// Never hang open on a stuck decode (e.g. 代码整洁之道).
|
|
1490
|
+
await Promise.race([decodeOrLoad.catch(() => {}), wait(DECODE_MS)]);
|
|
1491
|
+
} catch (_) {}
|
|
1492
|
+
}
|
|
1493
|
+
void noteView.offsetWidth;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function preloadCovers() {
|
|
1497
|
+
for (const cat of CATALOG) {
|
|
1498
|
+
for (const book of cat.books) {
|
|
1499
|
+
if (!book.cover) continue;
|
|
1500
|
+
const img = new Image();
|
|
1501
|
+
img.src = coverSrc(book);
|
|
1502
|
+
const capture = () => {
|
|
1503
|
+
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
1504
|
+
COVER_NAT[book.id] = [img.naturalWidth, img.naturalHeight];
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
img.onload = () => {
|
|
1508
|
+
capture();
|
|
1509
|
+
// Re-apply resting sizes once we know real aspect (no mode flip).
|
|
1510
|
+
if (!openBook && !busy) {
|
|
1511
|
+
document.querySelectorAll(`.cover[data-book-id="${book.id}"], .spine[data-book-id="${book.id}"]`).forEach((el) => {
|
|
1512
|
+
const expanded = el.classList.contains("is-expanded");
|
|
1513
|
+
applyFaceSize(el, book, { expanded });
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
if (img.decode) img.decode().then(capture).catch(() => {});
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function clearGhost(el) {
|
|
1523
|
+
restoreSlot(el);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async function openBookFlow(book, cat, sourceEl, { fromExpanded, asSpine }) {
|
|
1527
|
+
if (busy) return;
|
|
1528
|
+
busy = true;
|
|
1529
|
+
const myFlow = ++flowSeq;
|
|
1530
|
+
document.body.classList.add("is-busy");
|
|
1531
|
+
openBook = { book, cat, asSpine, fromExpanded: !!fromExpanded, openSeatRect: null };
|
|
1532
|
+
openFromExpanded = !!fromExpanded;
|
|
1533
|
+
|
|
1534
|
+
// ux6b: freeze window scroll to idle/expand home (face-out stuck ~197px up).
|
|
1535
|
+
// Face-out: keep FIRST pin from expandSpine (idle Y). Do NOT re-sample here —
|
|
1536
|
+
// scrollIntoView / focus may already have drifted scrollY before this runs.
|
|
1537
|
+
if (!fromExpanded) rememberScrollHome();
|
|
1538
|
+
armScrollLock();
|
|
1539
|
+
try {
|
|
1540
|
+
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
|
1541
|
+
} catch (_) {}
|
|
1542
|
+
|
|
1543
|
+
try {
|
|
1544
|
+
await prepareNote(book, cat);
|
|
1545
|
+
} catch (_) {
|
|
1546
|
+
// Decode/prepare failed — still open; never leave is-busy hung.
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (reduceMotion()) {
|
|
1550
|
+
sourceEl.classList.add("is-placeholder");
|
|
1551
|
+
if (fromExpanded) {
|
|
1552
|
+
expandedId = null;
|
|
1553
|
+
sourceEl.classList.remove("is-expanded", "is-collapsing");
|
|
1554
|
+
}
|
|
1555
|
+
noteView.classList.remove("is-preparing");
|
|
1556
|
+
noteView.classList.add("is-open");
|
|
1557
|
+
noteView.style.opacity = "1";
|
|
1558
|
+
parkShelf();
|
|
1559
|
+
releaseScrollLock();
|
|
1560
|
+
busy = false;
|
|
1561
|
+
document.body.classList.remove("is-busy");
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
try {
|
|
1566
|
+
// ux14 PATH-SHAKE ①: clear hover lift FIRST, then freeze source rect — ban fighting hover coords.
|
|
1567
|
+
cancelCollapse();
|
|
1568
|
+
sourceEl.style.transition = "none";
|
|
1569
|
+
sourceEl.style.transform = "none"; // kill :hover translateY / is-expanded lift
|
|
1570
|
+
sourceEl.classList.add("is-extracting"); // CSS: suppress :hover transform while extracting
|
|
1571
|
+
if (fromExpanded) {
|
|
1572
|
+
sourceEl.classList.remove("is-expanded", "is-collapsing");
|
|
1573
|
+
// Drop the expanded cover's width. Do NOT drop a resting spine's height:
|
|
1574
|
+
// clearing it falls through to .s-mid/.s-short (152/136), shorter than the
|
|
1575
|
+
// face height the shelf is already showing, and that short box becomes
|
|
1576
|
+
// homeRect for extract and put-back.
|
|
1577
|
+
["width", "min-width", "min-height", "flex-shrink"].forEach((p) => {
|
|
1578
|
+
try { sourceEl.style.removeProperty(p); } catch (_) {}
|
|
1579
|
+
});
|
|
1580
|
+
if (sourceEl.classList.contains("spine") && book && book.cover && !book.filler) {
|
|
1581
|
+
applyFaceSize(sourceEl, book);
|
|
1582
|
+
} else {
|
|
1583
|
+
try { sourceEl.style.removeProperty("height"); } catch (_) {}
|
|
1584
|
+
}
|
|
1585
|
+
expandedId = null;
|
|
1586
|
+
touchExpandedId = null;
|
|
1587
|
+
void sourceEl.offsetWidth; // reflow to resting spine (transform already none)
|
|
1588
|
+
} else {
|
|
1589
|
+
// Even spine-direct: pointer may still be hovering → :hover lift would skew freeze.
|
|
1590
|
+
void sourceEl.offsetWidth;
|
|
1591
|
+
}
|
|
1592
|
+
// ux25: pin natural cover face BEFORE measure — frame height stable open→land
|
|
1593
|
+
if (sourceEl.classList.contains("cover") && book && book.cover) {
|
|
1594
|
+
sourceEl.style.transform = "none";
|
|
1595
|
+
applyFaceSize(sourceEl, book);
|
|
1596
|
+
void sourceEl.offsetWidth;
|
|
1597
|
+
}
|
|
1598
|
+
const homeRect = copyRect(rectOf(sourceEl));
|
|
1599
|
+
if (sourceEl.classList.contains("cover") && book && book.cover) {
|
|
1600
|
+
// Force home to natural W×H (ignore hover scale / fallback CSS taller box)
|
|
1601
|
+
homeRect.width = coverWidthPx();
|
|
1602
|
+
homeRect.height = bookFaceHeight(book);
|
|
1603
|
+
homeRect.top = Math.round(homeRect.top + (rectOf(sourceEl).height - homeRect.height));
|
|
1604
|
+
}
|
|
1605
|
+
if (openBook) {
|
|
1606
|
+
openBook.homeRect = { ...homeRect };
|
|
1607
|
+
openBook.homeMode = sourceEl.classList.contains("spine") ? "spines" : "covers";
|
|
1608
|
+
openBook.openSeatRect = { ...homeRect };
|
|
1609
|
+
}
|
|
1610
|
+
const startRect = homeRect;
|
|
1611
|
+
const mid = midRect(book);
|
|
1612
|
+
const originX = mid.left + mid.width / 2;
|
|
1613
|
+
const originY = mid.top + mid.height / 2;
|
|
1614
|
+
void originX;
|
|
1615
|
+
void originY;
|
|
1616
|
+
|
|
1617
|
+
// ux14: ONE continuous dual layer; frame-0 locked to homeRect (no closedBookRect snap).
|
|
1618
|
+
// ux15: spine-wall extract T0 = standing spine overlay; cover morphs in after leaving slot.
|
|
1619
|
+
const wp = buildSlotWaypoints(homeRect, mid);
|
|
1620
|
+
const startAsSpine =
|
|
1621
|
+
!!asSpine ||
|
|
1622
|
+
(sourceEl && sourceEl.classList.contains("spine")) ||
|
|
1623
|
+
(openBook && openBook.homeMode === "spines");
|
|
1624
|
+
const dual = makeFlyer(book, homeRect, { spineShape: false, spreadOpen: false });
|
|
1625
|
+
dual.style.zIndex = "70";
|
|
1626
|
+
dual.style.opacity = "1";
|
|
1627
|
+
promote(dual);
|
|
1628
|
+
dual._cover.classList.add("is-cover-ready");
|
|
1629
|
+
dual._cover.classList.remove("is-spine-face");
|
|
1630
|
+
const spineLabel = dual._cover.querySelector(".flyer-spine-label");
|
|
1631
|
+
if (spineLabel) spineLabel.style.opacity = "0";
|
|
1632
|
+
applyCoverImage(dual._cover, book);
|
|
1633
|
+
// ux24: covers keep bg url under img (no preferImg clear → no naked plate on lift)
|
|
1634
|
+
if (startAsSpine) preferImgOverBg(dual._cover);
|
|
1635
|
+
// ux16: full-height spine-slot flyer at homeRect (not scaled dual stub).
|
|
1636
|
+
let spineSlot = null;
|
|
1637
|
+
let spineOut = { x: 0, y: 0 };
|
|
1638
|
+
const extractCoverImg = dual._cover.querySelector(":scope > .flyer-cover-img");
|
|
1639
|
+
if (startAsSpine) {
|
|
1640
|
+
spineOut = spineSlotOutDelta(homeRect, mid);
|
|
1641
|
+
spineSlot = makeSpineSlotFlyer(book, homeRect, { opacity: 1 });
|
|
1642
|
+
// Dual stays under spine until after leaving slot — hide stub geometry.
|
|
1643
|
+
dual.style.opacity = "0";
|
|
1644
|
+
if (extractCoverImg) extractCoverImg.style.opacity = "0";
|
|
1645
|
+
dual._cover.style.backgroundImage = "none";
|
|
1646
|
+
if (openBook) {
|
|
1647
|
+
openBook.spineOut = { ...spineOut };
|
|
1648
|
+
openBook.spineHome = copyRect(homeRect);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
// ux24: spines may clear to img-only; covers keep applyCoverImage (no wood flash)
|
|
1652
|
+
if (startAsSpine) await awaitCloseCoverReady(dual._cover, book, 400);
|
|
1653
|
+
else {
|
|
1654
|
+
applyCoverImage(dual._cover, book);
|
|
1655
|
+
const img = dual._cover && dual._cover.querySelector(":scope > .flyer-cover-img");
|
|
1656
|
+
if (img) { img.style.opacity = "1"; img.style.visibility = "visible"; }
|
|
1657
|
+
}
|
|
1658
|
+
if (startAsSpine) {
|
|
1659
|
+
if (extractCoverImg) extractCoverImg.style.opacity = "1"; // ready for morph-in
|
|
1660
|
+
// Dual still opacity 0 until spine slides out.
|
|
1661
|
+
dual.style.opacity = "0";
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// Hide slot completely — ux13: opacity 0 (CSS placeholder no longer gray plate).
|
|
1665
|
+
sourceEl.style.width = homeRect.width + "px";
|
|
1666
|
+
sourceEl.style.height = homeRect.height + "px";
|
|
1667
|
+
sourceEl.style.opacity = "0";
|
|
1668
|
+
sourceEl.style.pointerEvents = "none";
|
|
1669
|
+
sourceEl.style.transition = "none";
|
|
1670
|
+
sourceEl.classList.add("is-placeholder");
|
|
1671
|
+
|
|
1672
|
+
// Soft veil only — shelf stays under book (kills “floating on cream void”).
|
|
1673
|
+
armBackdrop({ solid: false });
|
|
1674
|
+
unparkShelf();
|
|
1675
|
+
shelfView.classList.add("is-frozen");
|
|
1676
|
+
shelfView.style.opacity = "1";
|
|
1677
|
+
|
|
1678
|
+
// ux13: rAF can stall in headless/harness — race with short timer
|
|
1679
|
+
await Promise.race([
|
|
1680
|
+
new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
|
|
1681
|
+
wait(32),
|
|
1682
|
+
]);
|
|
1683
|
+
|
|
1684
|
+
// §12+§13 ux13: LINEAR wall clock; single dual layer extract→hinge→note-on-top.
|
|
1685
|
+
const opts = { duration: OPEN_MS, easing: "linear", fill: "forwards" };
|
|
1686
|
+
const optsMotion = { duration: OPEN_MS, easing: "linear", fill: "forwards" };
|
|
1687
|
+
|
|
1688
|
+
// ONE diagonal arc: T0→Tfull with X/Y/scale same beat (ban L: lift then horizontal jump).
|
|
1689
|
+
const T0 = wp.T0;
|
|
1690
|
+
const Tp1 = wp.Tp1;
|
|
1691
|
+
const Tp2 = wp.Tp2;
|
|
1692
|
+
const Tp3 = wp.Tp3;
|
|
1693
|
+
const Tfull = wp.Tfull;
|
|
1694
|
+
// Aliases kept so hinge timing still keys off "arrived near full".
|
|
1695
|
+
const TdualClosed = Tp2; // mid-arc (closed pose still; hinge starts around here)
|
|
1696
|
+
const Tgrow = Tp3;
|
|
1697
|
+
const Tnear = lerpTS(Tp3, Tfull, 0.5);
|
|
1698
|
+
if (openBook) {
|
|
1699
|
+
openBook.pathMid = copyRect(mid);
|
|
1700
|
+
openBook.pathTs = { T0, Tp1, Tp2, Tp3, TdualClosed, Tgrow, Tnear, Tfull };
|
|
1701
|
+
openBook.pathWp = wp;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Same dual: single diagonal to full — no hold-then-slide second segment.
|
|
1705
|
+
const aDualMove = dual.animate(
|
|
1706
|
+
[
|
|
1707
|
+
pathKey(0, T0),
|
|
1708
|
+
pathKey(0.12, Tp1),
|
|
1709
|
+
pathKey(0.28, Tp2),
|
|
1710
|
+
pathKey(0.44, Tp3),
|
|
1711
|
+
pathKey(0.58, Tfull),
|
|
1712
|
+
pathKey(0.78, Tfull),
|
|
1713
|
+
pathKey(1, Tfull),
|
|
1714
|
+
],
|
|
1715
|
+
optsMotion
|
|
1716
|
+
);
|
|
1717
|
+
// Stay visible under note fade; only yield after note is solid (no hard cut).
|
|
1718
|
+
// ux16 spine extract: dual hidden until spine leaves slot (~0.14), then take over.
|
|
1719
|
+
const aDualFade = dual.animate(
|
|
1720
|
+
startAsSpine
|
|
1721
|
+
? [
|
|
1722
|
+
{ offset: 0, opacity: 0 },
|
|
1723
|
+
{ offset: 0.14, opacity: 0 },
|
|
1724
|
+
{ offset: 0.22, opacity: 1 },
|
|
1725
|
+
{ offset: 0.90, opacity: 1 },
|
|
1726
|
+
{ offset: 0.96, opacity: 0.35 },
|
|
1727
|
+
{ offset: 1.0, opacity: 0 },
|
|
1728
|
+
]
|
|
1729
|
+
: [
|
|
1730
|
+
{ offset: 0, opacity: 1 },
|
|
1731
|
+
{ offset: 0.90, opacity: 1 },
|
|
1732
|
+
{ offset: 0.96, opacity: 0.35 },
|
|
1733
|
+
{ offset: 1.0, opacity: 0 },
|
|
1734
|
+
],
|
|
1735
|
+
opts
|
|
1736
|
+
);
|
|
1737
|
+
|
|
1738
|
+
// ux16: full-height spine slides out of slot, then yields to dual cover path.
|
|
1739
|
+
const outXf = `translate3d(${spineOut.x.toFixed(2)}px, ${spineOut.y.toFixed(2)}px, 0)`;
|
|
1740
|
+
const outXf2 = `translate3d(${(spineOut.x * 1.35).toFixed(2)}px, ${(spineOut.y * 1.35).toFixed(2)}px, 0)`;
|
|
1741
|
+
const aSpineSlot = spineSlot
|
|
1742
|
+
? spineSlot.animate(
|
|
1743
|
+
[
|
|
1744
|
+
{ offset: 0, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
|
|
1745
|
+
{ offset: 0.10, transform: outXf, opacity: 1 },
|
|
1746
|
+
{ offset: 0.14, transform: outXf2, opacity: 1 },
|
|
1747
|
+
{ offset: 0.22, transform: outXf2, opacity: 0 },
|
|
1748
|
+
{ offset: 1.0, transform: outXf2, opacity: 0 },
|
|
1749
|
+
],
|
|
1750
|
+
optsMotion
|
|
1751
|
+
)
|
|
1752
|
+
: null;
|
|
1753
|
+
const aExtractPlate = null;
|
|
1754
|
+
const aExtractLabel = null;
|
|
1755
|
+
const aExtractCoverIn = null;
|
|
1756
|
+
|
|
1757
|
+
// Pose fade on SAME layer while diagonal continues — ban origin-swapping phase.
|
|
1758
|
+
const closedFaceKf = [
|
|
1759
|
+
{ offset: 0, opacity: 0 },
|
|
1760
|
+
{ offset: 0.28, opacity: 0 },
|
|
1761
|
+
{ offset: 0.40, opacity: 0.35 },
|
|
1762
|
+
{ offset: 0.52, opacity: 1 },
|
|
1763
|
+
{ offset: 1.0, opacity: 1 },
|
|
1764
|
+
];
|
|
1765
|
+
const aBoard = dual._board ? dual._board.animate(closedFaceKf, opts) : null;
|
|
1766
|
+
const aRightFace = dual._right.animate(
|
|
1767
|
+
[
|
|
1768
|
+
{ offset: 0, opacity: 0, transform: "rotateY(0deg)" },
|
|
1769
|
+
{ offset: 0.28, opacity: 0, transform: "rotateY(0deg)" },
|
|
1770
|
+
{ offset: 0.40, opacity: 0.5, transform: "rotateY(-2deg)" },
|
|
1771
|
+
{ offset: 0.52, opacity: 1, transform: "rotateY(-6deg)" },
|
|
1772
|
+
{ offset: 0.62, opacity: 1, transform: "rotateY(-8deg)" },
|
|
1773
|
+
{ offset: 1.0, opacity: 1, transform: "rotateY(-8deg)" },
|
|
1774
|
+
],
|
|
1775
|
+
optsMotion
|
|
1776
|
+
);
|
|
1777
|
+
const aThickL = dual._leftThick ? dual._leftThick.animate(closedFaceKf, opts) : null;
|
|
1778
|
+
const aThickR = dual._rightThick ? dual._rightThick.animate(closedFaceKf, opts) : null;
|
|
1779
|
+
|
|
1780
|
+
const aLeft = dual._left.animate(
|
|
1781
|
+
[
|
|
1782
|
+
{ offset: 0, transform: "rotateY(180deg)" },
|
|
1783
|
+
{ offset: 0.28, transform: "rotateY(180deg)" },
|
|
1784
|
+
{ offset: 0.38, transform: "rotateY(140deg)" },
|
|
1785
|
+
{ offset: 0.48, transform: "rotateY(70deg)" },
|
|
1786
|
+
{ offset: 0.56, transform: "rotateY(20deg)" },
|
|
1787
|
+
{ offset: 0.64, transform: "rotateY(8deg)" },
|
|
1788
|
+
{ offset: 1.0, transform: "rotateY(8deg)" },
|
|
1789
|
+
],
|
|
1790
|
+
optsMotion
|
|
1791
|
+
);
|
|
1792
|
+
void aRightFace; // hinge tracked as aRightFace in group
|
|
1793
|
+
|
|
1794
|
+
const aGutter = dual._gutter
|
|
1795
|
+
? dual._gutter.animate(
|
|
1796
|
+
[
|
|
1797
|
+
{ offset: 0, opacity: 0 },
|
|
1798
|
+
{ offset: 0.48, opacity: 0 },
|
|
1799
|
+
{ offset: 0.58, opacity: 1 },
|
|
1800
|
+
{ offset: 1.0, opacity: 1 },
|
|
1801
|
+
],
|
|
1802
|
+
opts
|
|
1803
|
+
)
|
|
1804
|
+
: null;
|
|
1805
|
+
|
|
1806
|
+
// §12: note main rise offset 0.70–0.92 (≥1.80s clear). FORBIDDEN: ≥0.95 at 0.34.
|
|
1807
|
+
const aNote = noteView.animate(
|
|
1808
|
+
[
|
|
1809
|
+
{ offset: 0, opacity: 0 },
|
|
1810
|
+
{ offset: 0.65, opacity: 0 },
|
|
1811
|
+
{ offset: 0.70, opacity: 0.15 },
|
|
1812
|
+
{ offset: 0.78, opacity: 0.55 },
|
|
1813
|
+
{ offset: 0.85, opacity: 0.90 },
|
|
1814
|
+
{ offset: 0.90, opacity: 0.99 },
|
|
1815
|
+
{ offset: 0.92, opacity: 1 },
|
|
1816
|
+
{ offset: 1.0, opacity: 1 },
|
|
1817
|
+
],
|
|
1818
|
+
opts
|
|
1819
|
+
);
|
|
1820
|
+
|
|
1821
|
+
const aBack = openBackdrop
|
|
1822
|
+
? openBackdrop.animate(
|
|
1823
|
+
[
|
|
1824
|
+
{ offset: 0, opacity: 0.35 },
|
|
1825
|
+
{ offset: 0.45, opacity: 0.45 },
|
|
1826
|
+
{ offset: 0.73, opacity: 0.55 },
|
|
1827
|
+
{ offset: 0.90, opacity: 0.85 },
|
|
1828
|
+
{ offset: 1.0, opacity: 1 },
|
|
1829
|
+
],
|
|
1830
|
+
opts
|
|
1831
|
+
)
|
|
1832
|
+
: null;
|
|
1833
|
+
|
|
1834
|
+
const group = [aDualMove, aDualFade, aLeft, aRightFace, aNote].filter(Boolean);
|
|
1835
|
+
if (aBoard) group.push(aBoard);
|
|
1836
|
+
if (aThickL) group.push(aThickL);
|
|
1837
|
+
if (aThickR) group.push(aThickR);
|
|
1838
|
+
if (aGutter) group.push(aGutter);
|
|
1839
|
+
if (aBack) group.push(aBack);
|
|
1840
|
+
if (aExtractPlate) group.push(aExtractPlate);
|
|
1841
|
+
if (aExtractLabel) group.push(aExtractLabel);
|
|
1842
|
+
if (aExtractCoverIn) group.push(aExtractCoverIn);
|
|
1843
|
+
if (aSpineSlot) group.push(aSpineSlot);
|
|
1844
|
+
|
|
1845
|
+
let flyerAlive = true;
|
|
1846
|
+
let clicksUnlocked = false;
|
|
1847
|
+
const settleNoteSolid = () => {
|
|
1848
|
+
noteView.style.opacity = "1";
|
|
1849
|
+
noteView.classList.remove("is-preparing");
|
|
1850
|
+
noteView.classList.add("is-open");
|
|
1851
|
+
};
|
|
1852
|
+
const unlockNoteClicks = () => {
|
|
1853
|
+
if (clicksUnlocked) return;
|
|
1854
|
+
clicksUnlocked = true;
|
|
1855
|
+
settleNoteSolid();
|
|
1856
|
+
// ux13: do NOT parkShelf here — parking mid-flight is the ~3–4s hard cut
|
|
1857
|
+
// (shelf vanishes while book layer still handing off). Keep shelf under note.
|
|
1858
|
+
if (myFlow === flowSeq) {
|
|
1859
|
+
document.body.classList.remove("is-busy");
|
|
1860
|
+
busy = false;
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
const holdFlyerUnderNote = () => {
|
|
1864
|
+
if (!flyerAlive) return;
|
|
1865
|
+
flyerAlive = false;
|
|
1866
|
+
try {
|
|
1867
|
+
// Commit end pose so close can time-reverse the SAME layer (PATH-SHAKE ②).
|
|
1868
|
+
dual.getAnimations({ subtree: true }).forEach((a) => {
|
|
1869
|
+
try { a.commitStyles(); } catch (_) {}
|
|
1870
|
+
try { a.cancel(); } catch (_) {}
|
|
1871
|
+
});
|
|
1872
|
+
} catch (_) {}
|
|
1873
|
+
// commitStyles does not keep the individual scale/translate pose. Park at
|
|
1874
|
+
// the open spread so close shrinks from 672×504 instead of jumping there.
|
|
1875
|
+
applyPathPose(dual, Tfull);
|
|
1876
|
+
dual.style.opacity = "0";
|
|
1877
|
+
dual.style.pointerEvents = "none";
|
|
1878
|
+
dual.style.visibility = "visible";
|
|
1879
|
+
if (openBook) openBook.heldFlyer = dual;
|
|
1880
|
+
};
|
|
1881
|
+
|
|
1882
|
+
// Unlock once note is solid (≥0.99 ≈ offset 0.90 → ~2340ms; §12 ≥2.20s)
|
|
1883
|
+
const unlockAt = Math.round(OPEN_MS * 0.90);
|
|
1884
|
+
wait(unlockAt).then(() => {
|
|
1885
|
+
if (myFlow !== flowSeq) return;
|
|
1886
|
+
unlockNoteClicks();
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
try {
|
|
1890
|
+
await runGroup(group, OPEN_MS + 400, () => {
|
|
1891
|
+
if (myFlow === flowSeq) {
|
|
1892
|
+
settleNoteSolid();
|
|
1893
|
+
unlockNoteClicks();
|
|
1894
|
+
}
|
|
1895
|
+
});
|
|
1896
|
+
} finally {
|
|
1897
|
+
// ux14: keep SAME dual under note (opacity 0) — ban dispose→new-flyer layer swap on close.
|
|
1898
|
+
disposeSpineSlot(spineSlot);
|
|
1899
|
+
spineSlot = null;
|
|
1900
|
+
if (myFlow === flowSeq) {
|
|
1901
|
+
settleNoteSolid();
|
|
1902
|
+
unlockNoteClicks();
|
|
1903
|
+
holdFlyerUnderNote();
|
|
1904
|
+
parkShelf();
|
|
1905
|
+
restoreSlot(sourceEl, book);
|
|
1906
|
+
sourceEl.classList.remove("is-extracting");
|
|
1907
|
+
// Keep sourceEl as invisible reserved seat while note open (no gray plate).
|
|
1908
|
+
sourceEl.classList.add("is-placeholder");
|
|
1909
|
+
sourceEl.style.opacity = "0";
|
|
1910
|
+
sourceEl.style.pointerEvents = "none";
|
|
1911
|
+
resetVeil();
|
|
1912
|
+
resetBackdrop();
|
|
1913
|
+
shelfView.classList.remove("is-frozen", "is-dim");
|
|
1914
|
+
busy = false;
|
|
1915
|
+
document.body.classList.remove("is-busy");
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
if (myFlow === flowSeq) {
|
|
1920
|
+
try { resetVeil(); } catch (_) {}
|
|
1921
|
+
try { resetBackdrop(); } catch (_) {}
|
|
1922
|
+
try { noteView.classList.remove("is-preparing"); } catch (_) {}
|
|
1923
|
+
try { shelfView.classList.remove("is-frozen", "is-dim"); } catch (_) {}
|
|
1924
|
+
try { releaseScrollLock(); } catch (_) {}
|
|
1925
|
+
busy = false;
|
|
1926
|
+
document.body.classList.remove("is-busy");
|
|
1927
|
+
}
|
|
1928
|
+
throw err;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async function closeBookFlow() {
|
|
1933
|
+
if (busy || !openBook) return;
|
|
1934
|
+
busy = true;
|
|
1935
|
+
const myFlow = ++flowSeq;
|
|
1936
|
+
document.body.classList.add("is-busy");
|
|
1937
|
+
|
|
1938
|
+
const { book } = openBook;
|
|
1939
|
+
const targetMode = pageMode;
|
|
1940
|
+
|
|
1941
|
+
if (reduceMotion()) {
|
|
1942
|
+
noteView.classList.remove("is-open", "is-preparing");
|
|
1943
|
+
noteView.hidden = true;
|
|
1944
|
+
noteView.style.opacity = "";
|
|
1945
|
+
expandedId = null;
|
|
1946
|
+
openFromExpanded = false;
|
|
1947
|
+
unparkShelf();
|
|
1948
|
+
renderShelf();
|
|
1949
|
+
clearGhost(
|
|
1950
|
+
targetMode === "covers"
|
|
1951
|
+
? $(`.cover[data-book-id="${book.id}"]`)
|
|
1952
|
+
: $(`.spine[data-book-id="${book.id}"]`)
|
|
1953
|
+
);
|
|
1954
|
+
openBook = null;
|
|
1955
|
+
releaseScrollLock();
|
|
1956
|
+
busy = false;
|
|
1957
|
+
document.body.classList.remove("is-busy");
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// Clock first. Shelf measure, freeze, and rAF used to run before this and
|
|
1962
|
+
// the 0.12 Tfull keyframe then held 672×504 for ~240ms. Stash from open is
|
|
1963
|
+
// enough to start the return; height pin is already in those samples.
|
|
1964
|
+
const closeMotion = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
|
|
1965
|
+
const closeOpts = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
|
|
1966
|
+
const stashEarly = openBook.pathTs;
|
|
1967
|
+
let earlyMove = null;
|
|
1968
|
+
let earlyNote = null;
|
|
1969
|
+
let earlyLeft = null;
|
|
1970
|
+
let earlyRight = null;
|
|
1971
|
+
const heldEarly = openBook.heldFlyer;
|
|
1972
|
+
if (
|
|
1973
|
+
heldEarly &&
|
|
1974
|
+
heldEarly.isConnected &&
|
|
1975
|
+
stashEarly &&
|
|
1976
|
+
stashEarly.Tfull &&
|
|
1977
|
+
stashEarly.T0 &&
|
|
1978
|
+
stashEarly.Tp1 &&
|
|
1979
|
+
stashEarly.Tp2 &&
|
|
1980
|
+
stashEarly.Tp3
|
|
1981
|
+
) {
|
|
1982
|
+
heldEarly.style.visibility = "visible";
|
|
1983
|
+
heldEarly.style.zIndex = "70";
|
|
1984
|
+
heldEarly.style.opacity = "1";
|
|
1985
|
+
promote(heldEarly);
|
|
1986
|
+
earlyMove = heldEarly.animate(
|
|
1987
|
+
[
|
|
1988
|
+
pathKey(0, stashEarly.Tfull),
|
|
1989
|
+
pathKey(0.28, stashEarly.Tp3),
|
|
1990
|
+
pathKey(0.44, stashEarly.Tp2),
|
|
1991
|
+
pathKey(0.60, stashEarly.Tp1),
|
|
1992
|
+
pathKey(0.78, stashEarly.T0),
|
|
1993
|
+
pathKey(0.90, stashEarly.T0),
|
|
1994
|
+
pathKey(1, stashEarly.T0),
|
|
1995
|
+
],
|
|
1996
|
+
closeMotion
|
|
1997
|
+
);
|
|
1998
|
+
earlyNote = noteView.animate(
|
|
1999
|
+
[
|
|
2000
|
+
{ offset: 0, opacity: 1 },
|
|
2001
|
+
{ offset: 0.06, opacity: 0.85 },
|
|
2002
|
+
{ offset: 0.12, opacity: 0.45 },
|
|
2003
|
+
{ offset: 0.22, opacity: 0 },
|
|
2004
|
+
{ offset: 1.0, opacity: 0 },
|
|
2005
|
+
],
|
|
2006
|
+
closeOpts
|
|
2007
|
+
);
|
|
2008
|
+
if (heldEarly._left) {
|
|
2009
|
+
earlyLeft = heldEarly._left.animate(
|
|
2010
|
+
[
|
|
2011
|
+
{ offset: 0, transform: "rotateY(8deg)" },
|
|
2012
|
+
{ offset: 0.08, transform: "rotateY(20deg)" },
|
|
2013
|
+
{ offset: 0.16, transform: "rotateY(70deg)" },
|
|
2014
|
+
{ offset: 0.24, transform: "rotateY(140deg)" },
|
|
2015
|
+
{ offset: 0.32, transform: "rotateY(180deg)" },
|
|
2016
|
+
{ offset: 1.0, transform: "rotateY(180deg)" },
|
|
2017
|
+
],
|
|
2018
|
+
closeMotion
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
if (heldEarly._right) {
|
|
2022
|
+
earlyRight = heldEarly._right.animate(
|
|
2023
|
+
[
|
|
2024
|
+
{ offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
|
|
2025
|
+
{ offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
|
|
2026
|
+
{ offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
|
|
2027
|
+
{ offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
|
|
2028
|
+
],
|
|
2029
|
+
closeMotion
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
const mid = midRect(book);
|
|
2035
|
+
const originX = mid.left + mid.width / 2;
|
|
2036
|
+
const originY = mid.top + mid.height / 2;
|
|
2037
|
+
|
|
2038
|
+
// Keep shelf in layout (park ≠ display:none).
|
|
2039
|
+
// ux12: HIDE slot BEFORE unpark — kills resting-spine flash on face-out close.
|
|
2040
|
+
// ux6b: re-arm scroll lock before unpark — open may have released early.
|
|
2041
|
+
if (!scrollLock) armScrollLock();
|
|
2042
|
+
else writeScroll(scrollLock);
|
|
2043
|
+
|
|
2044
|
+
const slotSel =
|
|
2045
|
+
targetMode === "covers"
|
|
2046
|
+
? `.cover[data-book-id="${book.id}"]`
|
|
2047
|
+
: `.spine[data-book-id="${book.id}"]`;
|
|
2048
|
+
let slot = $(slotSel);
|
|
2049
|
+
if (!slot) {
|
|
2050
|
+
// Still parked — safe to render without a visible flash
|
|
2051
|
+
unparkShelf();
|
|
2052
|
+
shelfView.style.opacity = "1";
|
|
2053
|
+
renderShelf();
|
|
2054
|
+
slot = $(slotSel);
|
|
2055
|
+
}
|
|
2056
|
+
if (!slot) {
|
|
2057
|
+
try { earlyMove && earlyMove.cancel(); } catch (_) {}
|
|
2058
|
+
try { earlyNote && earlyNote.cancel(); } catch (_) {}
|
|
2059
|
+
try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
|
|
2060
|
+
try { earlyRight && earlyRight.cancel(); } catch (_) {}
|
|
2061
|
+
noteView.classList.remove("is-open", "is-preparing");
|
|
2062
|
+
noteView.hidden = true;
|
|
2063
|
+
noteView.style.opacity = "";
|
|
2064
|
+
openBook = null;
|
|
2065
|
+
busy = false;
|
|
2066
|
+
document.body.classList.remove("is-busy");
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// Pin seat geometry WHILE still parked (invisible), then unpark.
|
|
2071
|
+
// ux13 gate⑤: ALWAYS land as resting spine — never keep hover face-out foot.
|
|
2072
|
+
// Clear open-path inline size FIRST (face-out open pins expanded W×H) before measure.
|
|
2073
|
+
slot.style.transition = "none";
|
|
2074
|
+
[
|
|
2075
|
+
"width",
|
|
2076
|
+
"height",
|
|
2077
|
+
"min-width",
|
|
2078
|
+
"min-height",
|
|
2079
|
+
"flex-shrink",
|
|
2080
|
+
"transform",
|
|
2081
|
+
].forEach((p) => {
|
|
2082
|
+
try { slot.style.removeProperty(p); } catch (_) {}
|
|
2083
|
+
});
|
|
2084
|
+
slot.classList.remove("is-collapsing", "is-placeholder", "is-expanded");
|
|
2085
|
+
expandedId = null;
|
|
2086
|
+
// Spines: re-pin the resting face BEFORE reading the seat. A cleared inline
|
|
2087
|
+
// height is the shorter size-class box, and put-back would keep it.
|
|
2088
|
+
if (targetMode === "spines" && book && book.cover && !book.filler) {
|
|
2089
|
+
applyFaceSize(slot, book);
|
|
2090
|
+
}
|
|
2091
|
+
void slot.offsetWidth; // reflow to resting spine after class/inline clear
|
|
2092
|
+
const naturalW = slot.offsetWidth;
|
|
2093
|
+
const naturalH = slot.offsetHeight;
|
|
2094
|
+
slot.style.flexShrink = "0";
|
|
2095
|
+
slot.style.width = naturalW + "px";
|
|
2096
|
+
slot.style.minWidth = naturalW + "px";
|
|
2097
|
+
slot.style.height = naturalH + "px";
|
|
2098
|
+
slot.style.minHeight = naturalH + "px";
|
|
2099
|
+
slot.style.transform = "none";
|
|
2100
|
+
slot.style.opacity = "0";
|
|
2101
|
+
slot.style.pointerEvents = "none";
|
|
2102
|
+
|
|
2103
|
+
// Pin EVERY cover/spine row to resting height so unfreeze cannot reflow.
|
|
2104
|
+
shelfView.querySelectorAll(".covers, .spines").forEach((r) => {
|
|
2105
|
+
const h = Math.round(r.getBoundingClientRect().height);
|
|
2106
|
+
if (h > 0) r.style.minHeight = h + "px";
|
|
2107
|
+
});
|
|
2108
|
+
const shelfPinH = Math.round(shelfView.getBoundingClientRect().height);
|
|
2109
|
+
if (shelfPinH > 0) shelfView.style.minHeight = shelfPinH + "px";
|
|
2110
|
+
shelfView.querySelectorAll(".cover, .spine").forEach((el) => {
|
|
2111
|
+
if (el === slot) return;
|
|
2112
|
+
el.style.transition = "none";
|
|
2113
|
+
el.style.transform = "none";
|
|
2114
|
+
// ux13: pin neighbor opacity at 1 for whole close clock — ban ~6s opacity snap
|
|
2115
|
+
el.style.opacity = "1";
|
|
2116
|
+
});
|
|
2117
|
+
shelfView.classList.add("is-frozen");
|
|
2118
|
+
|
|
2119
|
+
// ux13: prepare shelf+flyer UNDER opaque note — ban route hard-cut then mid-air resume.
|
|
2120
|
+
// Keep note is-open + opacity 1 until reverse clock starts.
|
|
2121
|
+
noteView.classList.add("is-open");
|
|
2122
|
+
noteView.classList.remove("is-preparing");
|
|
2123
|
+
noteView.hidden = false;
|
|
2124
|
+
noteView.style.opacity = "1";
|
|
2125
|
+
noteView.style.zIndex = "75";
|
|
2126
|
+
|
|
2127
|
+
unparkShelf();
|
|
2128
|
+
shelfView.style.opacity = "1";
|
|
2129
|
+
await Promise.race([
|
|
2130
|
+
new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
|
|
2131
|
+
wait(32),
|
|
2132
|
+
]);
|
|
2133
|
+
// ux14 MOTION LOCK: prefer frozen homeRect from open — never remeasure mid-flight for path.
|
|
2134
|
+
// Remeasure only if open stash missing; path end keyframe MUST be that homeRect.
|
|
2135
|
+
const frozenHome =
|
|
2136
|
+
(openBook && openBook.homeRect && openBook.homeRect.width > 0)
|
|
2137
|
+
? copyRect(openBook.homeRect)
|
|
2138
|
+
: copyRect(rectOf(slot));
|
|
2139
|
+
// ux25: covers seat = natural face only — never taller CSS/preset box at land
|
|
2140
|
+
if (targetMode === "covers" && book && book.cover) {
|
|
2141
|
+
const natW = coverWidthPx();
|
|
2142
|
+
const natH = bookFaceHeight(book);
|
|
2143
|
+
const bottom = frozenHome.top + frozenHome.height;
|
|
2144
|
+
frozenHome.width = natW;
|
|
2145
|
+
frozenHome.height = natH;
|
|
2146
|
+
frozenHome.top = Math.round(bottom - natH);
|
|
2147
|
+
} else if (targetMode === "spines" && naturalH > 0) {
|
|
2148
|
+
// Seat and flyer use the resting spine just measured, not a short
|
|
2149
|
+
// cover-frame stash from extract.
|
|
2150
|
+
const bottom = frozenHome.top + frozenHome.height;
|
|
2151
|
+
frozenHome.width = naturalW;
|
|
2152
|
+
frozenHome.height = naturalH;
|
|
2153
|
+
frozenHome.top = Math.round(bottom - naturalH);
|
|
2154
|
+
if (openBook) {
|
|
2155
|
+
openBook.homeRect = copyRect(frozenHome);
|
|
2156
|
+
openBook.spineHome = copyRect(frozenHome);
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
slot.style.width = frozenHome.width + "px";
|
|
2160
|
+
slot.style.minWidth = frozenHome.width + "px";
|
|
2161
|
+
slot.style.height = frozenHome.height + "px";
|
|
2162
|
+
slot.style.minHeight = frozenHome.height + "px";
|
|
2163
|
+
const endRect = frozenHome;
|
|
2164
|
+
|
|
2165
|
+
armBackdrop({ solid: false });
|
|
2166
|
+
|
|
2167
|
+
// ux14: SAME dual layer + pathTransform as open (ban cardToSlot / approach seek chain).
|
|
2168
|
+
const pathMid = (openBook && openBook.pathMid) ? copyRect(openBook.pathMid) : mid;
|
|
2169
|
+
const wp =
|
|
2170
|
+
(openBook && openBook.pathWp)
|
|
2171
|
+
? openBook.pathWp
|
|
2172
|
+
: buildSlotWaypoints(frozenHome, pathMid);
|
|
2173
|
+
const stashTs = openBook && openBook.pathTs;
|
|
2174
|
+
const T0 = stashTs ? stashTs.T0 : (wp.T0 || pathTransform(frozenHome, pathMid));
|
|
2175
|
+
const Tp1 = stashTs ? stashTs.Tp1 : (wp.Tp1 || lerpTS(T0, wp.Tfull || "translate(0.00px, -4.00px) scale(1.0000)", 0.25));
|
|
2176
|
+
const Tp2 = stashTs ? stashTs.Tp2 : (wp.Tp2 || lerpTS(T0, wp.Tfull || "translate(0.00px, -4.00px) scale(1.0000)", 0.50));
|
|
2177
|
+
const Tp3 = stashTs ? stashTs.Tp3 : (wp.Tp3 || lerpTS(T0, wp.Tfull || "translate(0.00px, -4.00px) scale(1.0000)", 0.75));
|
|
2178
|
+
const Tfull = stashTs ? stashTs.Tfull : (wp.Tfull || "translate(0.00px, -4.00px) scale(1.0000)");
|
|
2179
|
+
const TdualClosed = stashTs ? stashTs.TdualClosed : Tp2;
|
|
2180
|
+
const Tgrow = stashTs ? stashTs.Tgrow : Tp3;
|
|
2181
|
+
const Tnear = stashTs ? stashTs.Tnear : lerpTS(Tp3, Tfull, 0.5);
|
|
2182
|
+
void TdualClosed;
|
|
2183
|
+
void Tgrow;
|
|
2184
|
+
void Tnear;
|
|
2185
|
+
void endRect;
|
|
2186
|
+
|
|
2187
|
+
// ux14 PATH-SHAKE ②: reuse SAME dual held under note — ban new-layer origin pop (~6.2s).
|
|
2188
|
+
let flyer = openBook && openBook.heldFlyer;
|
|
2189
|
+
const reused = !!(flyer && flyer.isConnected);
|
|
2190
|
+
if (!reused) {
|
|
2191
|
+
flyer = makeFlyer(book, frozenHome, { spineShape: false, spreadOpen: true });
|
|
2192
|
+
}
|
|
2193
|
+
flyer.style.zIndex = "70";
|
|
2194
|
+
flyer.style.visibility = "visible";
|
|
2195
|
+
if (!earlyMove) {
|
|
2196
|
+
flyer.style.opacity = "0";
|
|
2197
|
+
applyPathPose(flyer, Tfull); // start at open end pose (transform-aligned)
|
|
2198
|
+
}
|
|
2199
|
+
promote(flyer);
|
|
2200
|
+
if (flyer._cover) {
|
|
2201
|
+
flyer._cover.classList.add("is-cover-ready");
|
|
2202
|
+
flyer._cover.classList.remove("is-spine-face");
|
|
2203
|
+
applyCoverImage(flyer._cover, book);
|
|
2204
|
+
// ux23: covers-mode keep CSS/bg url under <img> — fading img must not expose brown board
|
|
2205
|
+
if (targetMode === "spines") preferImgOverBg(flyer._cover);
|
|
2206
|
+
else {
|
|
2207
|
+
const img = flyer._cover.querySelector(":scope > .flyer-cover-img");
|
|
2208
|
+
if (img) {
|
|
2209
|
+
img.style.opacity = "1";
|
|
2210
|
+
img.style.visibility = "visible";
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
// Ensure open-spread leaf pose at Tfull so reverse hinge is continuous (under note).
|
|
2215
|
+
if (flyer._left) flyer._left.style.transform = "rotateY(8deg)";
|
|
2216
|
+
if (flyer._right) {
|
|
2217
|
+
flyer._right.style.transform = "rotateY(-8deg)";
|
|
2218
|
+
flyer._right.style.opacity = "1";
|
|
2219
|
+
}
|
|
2220
|
+
if (flyer._board) flyer._board.style.opacity = "1";
|
|
2221
|
+
if (flyer._leftThick) flyer._leftThick.style.opacity = "1";
|
|
2222
|
+
if (flyer._rightThick) flyer._rightThick.style.opacity = "1";
|
|
2223
|
+
if (flyer._gutter) flyer._gutter.style.opacity = "1";
|
|
2224
|
+
const closeSpineLabel = flyer._cover && flyer._cover.querySelector(".flyer-spine-label");
|
|
2225
|
+
if (closeSpineLabel) closeSpineLabel.style.opacity = "0";
|
|
2226
|
+
// ux23: spines may swap to img-only; covers keep applyCoverImage bg under img
|
|
2227
|
+
const readyPulse =
|
|
2228
|
+
targetMode === "spines"
|
|
2229
|
+
? awaitCloseCoverReady(flyer._cover, book, 150)
|
|
2230
|
+
: (async () => {
|
|
2231
|
+
applyCoverImage(flyer._cover, book);
|
|
2232
|
+
const img = flyer._cover && flyer._cover.querySelector(":scope > .flyer-cover-img");
|
|
2233
|
+
if (img) {
|
|
2234
|
+
img.style.opacity = "1";
|
|
2235
|
+
img.style.visibility = "visible";
|
|
2236
|
+
}
|
|
2237
|
+
})();
|
|
2238
|
+
const coverImgEl = flyer._cover && flyer._cover.querySelector(".flyer-cover-img");
|
|
2239
|
+
void coverImgEl;
|
|
2240
|
+
void reused;
|
|
2241
|
+
|
|
2242
|
+
let landLabel = null;
|
|
2243
|
+
let landPlate = null;
|
|
2244
|
+
// ux16: full-height spine-slot flyer for insert — not scaled dual stub plate.
|
|
2245
|
+
if (flyer._cover) {
|
|
2246
|
+
flyer._cover
|
|
2247
|
+
.querySelectorAll(":scope > .flyer-spine-land-plate, :scope > .flyer-spine-label")
|
|
2248
|
+
.forEach((n) => {
|
|
2249
|
+
try { n.remove(); } catch (_) {}
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
let spineSlotClose = null;
|
|
2253
|
+
let spineOutClose = { x: 0, y: 0 };
|
|
2254
|
+
if (targetMode === "spines") {
|
|
2255
|
+
const spineHome =
|
|
2256
|
+
(openBook && openBook.spineHome && openBook.spineHome.width > 0)
|
|
2257
|
+
? copyRect(openBook.spineHome)
|
|
2258
|
+
: copyRect(frozenHome);
|
|
2259
|
+
spineOutClose =
|
|
2260
|
+
(openBook && openBook.spineOut)
|
|
2261
|
+
? { ...openBook.spineOut }
|
|
2262
|
+
: spineSlotOutDelta(spineHome, pathMid);
|
|
2263
|
+
spineSlotClose = makeSpineSlotFlyer(book, spineHome, { opacity: 0 });
|
|
2264
|
+
spineSlotClose.style.transform =
|
|
2265
|
+
`translate3d(${spineOutClose.x.toFixed(2)}px, ${spineOutClose.y.toFixed(2)}px, 0)`;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// Start the reverse on this frame. A pre-clock rAF left the spread parked at
|
|
2269
|
+
// 672×504 while the note was still up (the ~240ms close-start hitch).
|
|
2270
|
+
void readyPulse;
|
|
2271
|
+
if (!earlyMove) flyer.style.opacity = "1";
|
|
2272
|
+
|
|
2273
|
+
// ux14: TIME-REVERSE of open pathTs — ban separately computed return / end-segment seek.
|
|
2274
|
+
const PUT_MS = 380;
|
|
2275
|
+
const XFADE_MS = 200;
|
|
2276
|
+
const putFrom = 1 - PUT_MS / CLOSE_MS; // ~0.80
|
|
2277
|
+
const xfadeFrom = 1 - XFADE_MS / CLOSE_MS; // ~0.89
|
|
2278
|
+
const slotInAt = Math.max(0.86, xfadeFrom - 0.04);
|
|
2279
|
+
const flyerFadeAt = Math.min(0.97, slotInAt + 0.06);
|
|
2280
|
+
|
|
2281
|
+
resetVeil();
|
|
2282
|
+
|
|
2283
|
+
// ux13: note fades ON the book layer while reverse shrink starts — ban instant note unmount.
|
|
2284
|
+
const aNoteOut = earlyNote || noteView.animate(
|
|
2285
|
+
[
|
|
2286
|
+
{ offset: 0, opacity: 1 },
|
|
2287
|
+
{ offset: 0.06, opacity: 0.85 },
|
|
2288
|
+
{ offset: 0.12, opacity: 0.45 },
|
|
2289
|
+
{ offset: 0.22, opacity: 0 },
|
|
2290
|
+
{ offset: 1.0, opacity: 0 },
|
|
2291
|
+
],
|
|
2292
|
+
closeOpts
|
|
2293
|
+
);
|
|
2294
|
+
|
|
2295
|
+
// Shrink starts at offset 0. The old 0.12 Tfull key was ~228ms parked at 672×504.
|
|
2296
|
+
const aFlyerMove = earlyMove || flyer.animate(
|
|
2297
|
+
[
|
|
2298
|
+
pathKey(0, Tfull),
|
|
2299
|
+
pathKey(0.28, Tp3),
|
|
2300
|
+
pathKey(0.44, Tp2),
|
|
2301
|
+
pathKey(0.60, Tp1),
|
|
2302
|
+
pathKey(0.78, T0),
|
|
2303
|
+
pathKey(0.90, T0),
|
|
2304
|
+
pathKey(1, T0),
|
|
2305
|
+
],
|
|
2306
|
+
closeMotion
|
|
2307
|
+
);
|
|
2308
|
+
// Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
|
|
2309
|
+
const aLeftClose = earlyLeft || (flyer._left
|
|
2310
|
+
? flyer._left.animate(
|
|
2311
|
+
[
|
|
2312
|
+
{ offset: 0, transform: "rotateY(8deg)" },
|
|
2313
|
+
{ offset: 0.08, transform: "rotateY(20deg)" },
|
|
2314
|
+
{ offset: 0.16, transform: "rotateY(70deg)" },
|
|
2315
|
+
{ offset: 0.24, transform: "rotateY(140deg)" },
|
|
2316
|
+
{ offset: 0.32, transform: "rotateY(180deg)" },
|
|
2317
|
+
{ offset: 1.0, transform: "rotateY(180deg)" },
|
|
2318
|
+
],
|
|
2319
|
+
closeMotion
|
|
2320
|
+
)
|
|
2321
|
+
: null);
|
|
2322
|
+
const aRightClose = earlyRight || (flyer._right
|
|
2323
|
+
? flyer._right.animate(
|
|
2324
|
+
[
|
|
2325
|
+
{ offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
|
|
2326
|
+
{ offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
|
|
2327
|
+
{ offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
|
|
2328
|
+
{ offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
|
|
2329
|
+
],
|
|
2330
|
+
closeMotion
|
|
2331
|
+
)
|
|
2332
|
+
: null);
|
|
2333
|
+
const closedFaceOut = [
|
|
2334
|
+
{ offset: 0, opacity: 1 },
|
|
2335
|
+
{ offset: 0.18, opacity: 0.7 },
|
|
2336
|
+
{ offset: 0.28, opacity: 0 },
|
|
2337
|
+
{ offset: 1.0, opacity: 0 },
|
|
2338
|
+
];
|
|
2339
|
+
const aBoardClose = flyer._board ? flyer._board.animate(closedFaceOut, closeOpts) : null;
|
|
2340
|
+
const aThickLClose = flyer._leftThick ? flyer._leftThick.animate(closedFaceOut, closeOpts) : null;
|
|
2341
|
+
const aThickRClose = flyer._rightThick ? flyer._rightThick.animate(closedFaceOut, closeOpts) : null;
|
|
2342
|
+
const aGutterClose = flyer._gutter
|
|
2343
|
+
? flyer._gutter.animate(
|
|
2344
|
+
[
|
|
2345
|
+
{ offset: 0, opacity: 1 },
|
|
2346
|
+
{ offset: 0.24, opacity: 0 },
|
|
2347
|
+
{ offset: 1.0, opacity: 0 },
|
|
2348
|
+
],
|
|
2349
|
+
closeOpts
|
|
2350
|
+
)
|
|
2351
|
+
: null;
|
|
2352
|
+
// ux17: foot shadow must be gone by ~50% close — no dark plate on the rail.
|
|
2353
|
+
// Kill leftover open-path shadows (can leave a second .flyer-shadow stuck at 0.4).
|
|
2354
|
+
const closeShadowEl =
|
|
2355
|
+
flyer._shadow ||
|
|
2356
|
+
(flyer.querySelector && flyer.querySelector(":scope > .flyer-shadow"));
|
|
2357
|
+
try {
|
|
2358
|
+
document.querySelectorAll(".flyer-shadow").forEach((sh) => {
|
|
2359
|
+
if (sh !== closeShadowEl) {
|
|
2360
|
+
try { sh.getAnimations().forEach((a) => a.cancel()); } catch (_) {}
|
|
2361
|
+
sh.style.opacity = "0";
|
|
2362
|
+
}
|
|
2363
|
+
});
|
|
2364
|
+
} catch (_) {}
|
|
2365
|
+
const aShadowClose = closeShadowEl
|
|
2366
|
+
? closeShadowEl.animate(
|
|
2367
|
+
[
|
|
2368
|
+
{ offset: 0, opacity: 0.4 },
|
|
2369
|
+
{ offset: 0.22, opacity: 0.22 },
|
|
2370
|
+
{ offset: 0.42, opacity: 0.06 },
|
|
2371
|
+
{ offset: 0.50, opacity: 0 },
|
|
2372
|
+
{ offset: 1.0, opacity: 0 },
|
|
2373
|
+
],
|
|
2374
|
+
closeOpts
|
|
2375
|
+
)
|
|
2376
|
+
: null;
|
|
2377
|
+
const aFlyerFade = flyer.animate(
|
|
2378
|
+
targetMode === "spines"
|
|
2379
|
+
? [
|
|
2380
|
+
{ offset: 0, opacity: 1 },
|
|
2381
|
+
{ offset: 0.50, opacity: 1 },
|
|
2382
|
+
{ offset: 0.58, opacity: 0 },
|
|
2383
|
+
{ offset: 1.0, opacity: 0 },
|
|
2384
|
+
]
|
|
2385
|
+
: [
|
|
2386
|
+
{ offset: 0, opacity: 1 },
|
|
2387
|
+
{ offset: flyerFadeAt, opacity: 1 },
|
|
2388
|
+
{ offset: 1.0, opacity: 0 },
|
|
2389
|
+
],
|
|
2390
|
+
closeOpts
|
|
2391
|
+
);
|
|
2392
|
+
|
|
2393
|
+
const coverPulse = wait(Math.round(CLOSE_MS * 0.18)).then(() => {
|
|
2394
|
+
if (myFlow !== flowSeq) return;
|
|
2395
|
+
try {
|
|
2396
|
+
applyCoverImage(flyer._cover, book);
|
|
2397
|
+
if (targetMode === "spines") preferImgOverBg(flyer._cover);
|
|
2398
|
+
else {
|
|
2399
|
+
const img = flyer._cover && flyer._cover.querySelector(":scope > .flyer-cover-img");
|
|
2400
|
+
if (img) {
|
|
2401
|
+
img.style.opacity = "1";
|
|
2402
|
+
img.style.visibility = "visible";
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
} catch (_) {}
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
// ux16: full-height spine appears near slot, then slides INTO homeRect (insert).
|
|
2409
|
+
const outClose = `translate3d(${spineOutClose.x.toFixed(2)}px, ${spineOutClose.y.toFixed(2)}px, 0)`;
|
|
2410
|
+
const aSpineSlotClose = spineSlotClose
|
|
2411
|
+
? spineSlotClose.animate(
|
|
2412
|
+
[
|
|
2413
|
+
{ offset: 0, transform: outClose, opacity: 0 },
|
|
2414
|
+
{ offset: 0.50, transform: outClose, opacity: 0 },
|
|
2415
|
+
{ offset: 0.58, transform: outClose, opacity: 1 },
|
|
2416
|
+
{ offset: 0.78, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
|
|
2417
|
+
{ offset: 0.90, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
|
|
2418
|
+
{ offset: 1.0, transform: "translate3d(0px, 0px, 0)", opacity: 0 },
|
|
2419
|
+
],
|
|
2420
|
+
closeMotion
|
|
2421
|
+
)
|
|
2422
|
+
: null;
|
|
2423
|
+
const aLabel = null;
|
|
2424
|
+
const aLandPlate = null;
|
|
2425
|
+
const coverImg = flyer._cover && flyer._cover.querySelector(".flyer-cover-img");
|
|
2426
|
+
// ux23: covers-mode keep real cover opaque until seat handover (no mid-close brown plate)
|
|
2427
|
+
const aCoverOut = coverImg
|
|
2428
|
+
? coverImg.animate(
|
|
2429
|
+
targetMode === "spines"
|
|
2430
|
+
? [
|
|
2431
|
+
{ offset: 0, opacity: 1 },
|
|
2432
|
+
{ offset: 0.50, opacity: 1 },
|
|
2433
|
+
{ offset: 0.58, opacity: 0 },
|
|
2434
|
+
{ offset: 1, opacity: 0 },
|
|
2435
|
+
]
|
|
2436
|
+
: [
|
|
2437
|
+
{ offset: 0, opacity: 1 },
|
|
2438
|
+
{ offset: flyerFadeAt, opacity: 1 },
|
|
2439
|
+
{ offset: 1, opacity: 0 },
|
|
2440
|
+
],
|
|
2441
|
+
closeOpts
|
|
2442
|
+
)
|
|
2443
|
+
: null;
|
|
2444
|
+
|
|
2445
|
+
// ux23: covers seat must show real cover at handover — never naked #brown board
|
|
2446
|
+
if (targetMode === "covers" && book && book.cover) {
|
|
2447
|
+
const src = coverSrc(book);
|
|
2448
|
+
slot.style.backgroundImage = `url("${src}")`;
|
|
2449
|
+
slot.style.backgroundSize = "contain";
|
|
2450
|
+
slot.style.backgroundPosition = "center top";
|
|
2451
|
+
slot.style.backgroundRepeat = "no-repeat";
|
|
2452
|
+
slot.style.backgroundColor = "";
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
const aSlotIn = slot.animate(
|
|
2456
|
+
[
|
|
2457
|
+
{ offset: 0, opacity: 0 },
|
|
2458
|
+
{ offset: slotInAt, opacity: 0 },
|
|
2459
|
+
{ offset: flyerFadeAt, opacity: 1 },
|
|
2460
|
+
{ offset: 1, opacity: 1 },
|
|
2461
|
+
],
|
|
2462
|
+
closeOpts
|
|
2463
|
+
);
|
|
2464
|
+
|
|
2465
|
+
const aBack = openBackdrop
|
|
2466
|
+
? openBackdrop.animate(
|
|
2467
|
+
[
|
|
2468
|
+
{ offset: 0, opacity: 0.45 },
|
|
2469
|
+
{ offset: 0.4, opacity: 0.35 },
|
|
2470
|
+
{ offset: putFrom, opacity: 0.12 },
|
|
2471
|
+
{ offset: flyerFadeAt, opacity: 0 },
|
|
2472
|
+
{ offset: 1, opacity: 0 },
|
|
2473
|
+
],
|
|
2474
|
+
closeOpts
|
|
2475
|
+
)
|
|
2476
|
+
: null;
|
|
2477
|
+
|
|
2478
|
+
const group = [aNoteOut, aFlyerMove, aFlyerFade, aSlotIn];
|
|
2479
|
+
if (aLeftClose) group.push(aLeftClose);
|
|
2480
|
+
if (aRightClose) group.push(aRightClose);
|
|
2481
|
+
if (aBoardClose) group.push(aBoardClose);
|
|
2482
|
+
if (aThickLClose) group.push(aThickLClose);
|
|
2483
|
+
if (aThickRClose) group.push(aThickRClose);
|
|
2484
|
+
if (aGutterClose) group.push(aGutterClose);
|
|
2485
|
+
if (aShadowClose) group.push(aShadowClose);
|
|
2486
|
+
if (aLabel) group.push(aLabel);
|
|
2487
|
+
if (aLandPlate) group.push(aLandPlate);
|
|
2488
|
+
if (aCoverOut) group.push(aCoverOut);
|
|
2489
|
+
if (aSpineSlotClose) group.push(aSpineSlotClose);
|
|
2490
|
+
if (aBack) group.push(aBack);
|
|
2491
|
+
void coverPulse;
|
|
2492
|
+
void originX;
|
|
2493
|
+
void originY;
|
|
2494
|
+
|
|
2495
|
+
try {
|
|
2496
|
+
await runGroup(group, CLOSE_MS + 400, () => {
|
|
2497
|
+
slot.style.opacity = "1";
|
|
2498
|
+
flyer.style.opacity = "0";
|
|
2499
|
+
noteView.style.opacity = "0";
|
|
2500
|
+
});
|
|
2501
|
+
} finally {
|
|
2502
|
+
// PATH-SHAKE ③: final frame still transform-aligned to slot (T0), hand back without vertical drop.
|
|
2503
|
+
try {
|
|
2504
|
+
flyer.getAnimations({ subtree: true }).forEach((a) => {
|
|
2505
|
+
try { a.commitStyles(); } catch (_) {}
|
|
2506
|
+
try { a.cancel(); } catch (_) {}
|
|
2507
|
+
});
|
|
2508
|
+
} catch (_) {}
|
|
2509
|
+
applyPathPose(flyer, T0); // stay locked on frozen home until slot fully owns pixels
|
|
2510
|
+
flyer.style.opacity = "0";
|
|
2511
|
+
slot.style.opacity = "1";
|
|
2512
|
+
slot.style.transform = "none";
|
|
2513
|
+
noteView.classList.remove("is-open", "is-preparing");
|
|
2514
|
+
noteView.hidden = true;
|
|
2515
|
+
noteView.style.opacity = "";
|
|
2516
|
+
noteView.style.zIndex = "";
|
|
2517
|
+
// One paint with slot visible + flyer invisible@T0, THEN remove flyer (no layout snap seek).
|
|
2518
|
+
await Promise.race([
|
|
2519
|
+
new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
|
|
2520
|
+
wait(48),
|
|
2521
|
+
]);
|
|
2522
|
+
if (openBook) openBook.heldFlyer = null;
|
|
2523
|
+
disposeSpineSlot(spineSlotClose);
|
|
2524
|
+
spineSlotClose = null;
|
|
2525
|
+
disposeFlyer(flyer);
|
|
2526
|
+
resetVeil();
|
|
2527
|
+
resetBackdrop();
|
|
2528
|
+
// Settle with pins + freeze held, then release — no min-height snap.
|
|
2529
|
+
// ux13 gate⑤: settle ALWAYS as resting spine (collapse any face-out / is-expanded).
|
|
2530
|
+
await Promise.race([
|
|
2531
|
+
new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
|
|
2532
|
+
wait(48),
|
|
2533
|
+
]);
|
|
2534
|
+
expandedId = null;
|
|
2535
|
+
openFromExpanded = false;
|
|
2536
|
+
restoreSlot(slot, book);
|
|
2537
|
+
try { slot.classList.remove("is-extracting"); } catch (_) {}
|
|
2538
|
+
// Pointer is often still over the returned spine. :hover is translateY(-8px)
|
|
2539
|
+
// scale(1.06), which measures ~8–11px taller than neighbors. Hold that off
|
|
2540
|
+
// until the pointer leaves; drop immediately if it is not hovering.
|
|
2541
|
+
if (targetMode === "spines" && slot) {
|
|
2542
|
+
slot.classList.add("is-seat");
|
|
2543
|
+
const releaseSeat = () => {
|
|
2544
|
+
slot.classList.remove("is-seat");
|
|
2545
|
+
slot.removeEventListener("pointerleave", releaseSeat);
|
|
2546
|
+
};
|
|
2547
|
+
slot.addEventListener("pointerleave", releaseSeat);
|
|
2548
|
+
requestAnimationFrame(() => {
|
|
2549
|
+
if (!slot.isConnected) return;
|
|
2550
|
+
if (!slot.matches(":hover")) releaseSeat();
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
// ux23: drop close-path inline cover pin; resting .img-* CSS owns the seat again
|
|
2554
|
+
if (targetMode === "covers" && slot) {
|
|
2555
|
+
["background-image", "background-size", "background-position", "background-repeat", "background-color"].forEach((p) => {
|
|
2556
|
+
try { slot.style.removeProperty(p); } catch (_) {}
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
const slot2 = $(slotSel);
|
|
2560
|
+
if (slot2 && slot2 !== slot) {
|
|
2561
|
+
restoreSlot(slot2, book);
|
|
2562
|
+
if (targetMode === "covers") {
|
|
2563
|
+
["background-image", "background-size", "background-position", "background-repeat", "background-color"].forEach((p) => {
|
|
2564
|
+
try { slot2.style.removeProperty(p); } catch (_) {}
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
shelfView.querySelectorAll(".cover, .spine").forEach((el) => {
|
|
2569
|
+
["transition", "transform", "opacity"].forEach((p) => {
|
|
2570
|
+
try { el.style.removeProperty(p); } catch (_) {}
|
|
2571
|
+
});
|
|
2572
|
+
});
|
|
2573
|
+
// Drop freeze first while row min-heights still pinned at resting size
|
|
2574
|
+
shelfView.classList.remove("is-dim", "is-frozen", "is-parked");
|
|
2575
|
+
await Promise.race([
|
|
2576
|
+
new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
|
|
2577
|
+
wait(48),
|
|
2578
|
+
]);
|
|
2579
|
+
shelfView.querySelectorAll(".covers, .spines").forEach((r) => {
|
|
2580
|
+
try { r.style.removeProperty("min-height"); } catch (_) {}
|
|
2581
|
+
});
|
|
2582
|
+
try { shelfView.style.removeProperty("min-height"); } catch (_) {}
|
|
2583
|
+
shelfView.style.opacity = "";
|
|
2584
|
+
openBook = null;
|
|
2585
|
+
openFromExpanded = false;
|
|
2586
|
+
releaseScrollLock();
|
|
2587
|
+
if (myFlow === flowSeq) {
|
|
2588
|
+
busy = false;
|
|
2589
|
+
document.body.classList.remove("is-busy");
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// Mode buttons
|
|
2595
|
+
listen($("#modeAuto"), "click", () => {
|
|
2596
|
+
modeOverride = "auto";
|
|
2597
|
+
// Drop force so density wins
|
|
2598
|
+
const url = new URL(location.href);
|
|
2599
|
+
url.searchParams.delete("force");
|
|
2600
|
+
history.replaceState(null, "", url);
|
|
2601
|
+
densityPinned = null;
|
|
2602
|
+
setMode(resolveRestingMode("auto", { recompute: true }), { push: true });
|
|
2603
|
+
});
|
|
2604
|
+
listen($("#modeCovers"), "click", () => {
|
|
2605
|
+
modeOverride = "covers";
|
|
2606
|
+
const url = new URL(location.href);
|
|
2607
|
+
url.searchParams.delete("force");
|
|
2608
|
+
history.replaceState(null, "", url);
|
|
2609
|
+
densityPinned = null;
|
|
2610
|
+
setMode(resolveRestingMode("covers", { recompute: true }), { push: true });
|
|
2611
|
+
});
|
|
2612
|
+
listen($("#modeSpines"), "click", () => {
|
|
2613
|
+
modeOverride = "spines";
|
|
2614
|
+
const url = new URL(location.href);
|
|
2615
|
+
url.searchParams.delete("force");
|
|
2616
|
+
history.replaceState(null, "", url);
|
|
2617
|
+
setMode(resolveRestingMode("spines"), { push: true });
|
|
2618
|
+
});
|
|
2619
|
+
|
|
2620
|
+
listen($("#backBtn"), "click", () => closeBookFlow());
|
|
2621
|
+
|
|
2622
|
+
// Browser Back from the full note restores this page from bfcache, still open.
|
|
2623
|
+
// Land on the shelf at the scroll we saved, without replaying the close clock.
|
|
2624
|
+
listen(window, "pageshow", (event) => {
|
|
2625
|
+
if (!event.persisted || !openBook) return;
|
|
2626
|
+
const home = scrollLock
|
|
2627
|
+
? { x: scrollLock.x, y: scrollLock.y }
|
|
2628
|
+
: { x: scrollHome.x, y: scrollHome.y };
|
|
2629
|
+
try { releaseScrollLock(); } catch (_) {}
|
|
2630
|
+
openBook = null;
|
|
2631
|
+
openFromExpanded = false;
|
|
2632
|
+
expandedId = null;
|
|
2633
|
+
touchExpandedId = null;
|
|
2634
|
+
busy = false;
|
|
2635
|
+
flowSeq += 1;
|
|
2636
|
+
document.body.classList.remove("is-busy");
|
|
2637
|
+
noteView.classList.remove("is-open", "is-preparing");
|
|
2638
|
+
noteView.hidden = true;
|
|
2639
|
+
noteView.style.opacity = "";
|
|
2640
|
+
noteView.style.zIndex = "";
|
|
2641
|
+
if (stage) stage.replaceChildren();
|
|
2642
|
+
resetVeil();
|
|
2643
|
+
resetBackdrop();
|
|
2644
|
+
unparkShelf();
|
|
2645
|
+
shelfView.classList.remove("is-frozen", "is-dim", "is-parked");
|
|
2646
|
+
shelfView.style.opacity = "";
|
|
2647
|
+
try { shelfView.style.removeProperty("min-height"); } catch (_) {}
|
|
2648
|
+
renderShelf();
|
|
2649
|
+
writeScroll(home);
|
|
2650
|
+
requestAnimationFrame(() => writeScroll(home));
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
// Leaving for the post page: remember the shelf scroll so Back lands on it.
|
|
2654
|
+
listen(root, "click", (event) => {
|
|
2655
|
+
const target = event.target;
|
|
2656
|
+
if (!target || !target.closest) return;
|
|
2657
|
+
const link = target.closest("a.flyer-page-more, a.note-read");
|
|
2658
|
+
if (!link || !root.contains(link)) return;
|
|
2659
|
+
const y = scrollLock ? scrollLock.y : readScroll().y;
|
|
2660
|
+
try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
// Escape closes note
|
|
2664
|
+
listen(window, "keydown", (e) => {
|
|
2665
|
+
if (e.key === "Escape" && openBook && !busy) closeBookFlow();
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2668
|
+
// Resize: density stays pinned (Hedon). No mode flip, no face re-measure.
|
|
2669
|
+
// ux21: once enter classified mobile→covers (or desktop density), do not re-detect
|
|
2670
|
+
// isMobilePrimary / autoDecide on rotate or window drag.
|
|
2671
|
+
listen(window, "resize", () => {
|
|
2672
|
+
/* intentionally empty — enter-time density + face sizes stay put */
|
|
2673
|
+
});
|
|
2674
|
+
|
|
2675
|
+
// init
|
|
2676
|
+
const q = parseQuery();
|
|
2677
|
+
if (q) modeOverride = q;
|
|
2678
|
+
enableDebugChrome();
|
|
2679
|
+
applyModePreference();
|
|
2680
|
+
preloadCovers();
|
|
2681
|
+
|
|
2682
|
+
return () => {
|
|
2683
|
+
for (const fn of unlisten) fn();
|
|
2684
|
+
clearTimeout(idleScrollTimer);
|
|
2685
|
+
try { releaseScrollLock(); } catch (_) {}
|
|
2686
|
+
try { disarmExpandedScrollGuard(); } catch (_) {}
|
|
2687
|
+
document.body.classList.remove("is-busy", "debug-on");
|
|
2688
|
+
removeExtraCovers();
|
|
2689
|
+
};
|
|
2690
|
+
}
|