hd-garden 0.9.3 → 0.9.4
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/[bookId]/page.tsx +20 -3
- package/app/components/bookshelf/Bookshelf.tsx +16 -3
- package/app/components/bookshelf/bookshelf.css +78 -60
- package/app/components/bookshelf/engine.ts +401 -117
- package/app/components/bookshelf/schema.ts +4 -4
- package/app/components/bookshelf/types.ts +5 -0
- package/docs/handbook/config.md +1 -1
- package/docs/handbook/en/config.md +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
2
|
import { notFound } from "next/navigation";
|
|
3
3
|
import { bookshelf } from "@garden/bookshelf";
|
|
4
|
-
import {
|
|
4
|
+
import { Bookshelf } from "../../components/bookshelf";
|
|
5
5
|
import { pageAlternates } from "../../lib/page-alternates";
|
|
6
|
+
import { siteConfig } from "../../site.config";
|
|
6
7
|
import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
|
|
7
8
|
import type { BookshelfBook } from "../../components/bookshelf/types";
|
|
8
9
|
|
|
@@ -47,9 +48,25 @@ export default async function BookHubPage({
|
|
|
47
48
|
}) {
|
|
48
49
|
const { bookId } = await params;
|
|
49
50
|
const found = hubBooks().find((item) => item.book.id === bookId);
|
|
50
|
-
if (!found) {
|
|
51
|
+
if (!found || !bookshelf) {
|
|
51
52
|
if (isStaticPlaceholder(bookId)) return null;
|
|
52
53
|
notFound();
|
|
53
54
|
}
|
|
54
|
-
return
|
|
55
|
+
return (
|
|
56
|
+
<main className="sh-main bookshelf-page" id="content">
|
|
57
|
+
<header className="sh-head">
|
|
58
|
+
{bookshelf.eyebrow ? <p className="sh-kicker">{bookshelf.eyebrow}</p> : null}
|
|
59
|
+
<h1 id="sh-title">{bookshelf.title}</h1>
|
|
60
|
+
{bookshelf.description ? <p className="sh-deck">{bookshelf.description}</p> : null}
|
|
61
|
+
</header>
|
|
62
|
+
<Bookshelf
|
|
63
|
+
embedded
|
|
64
|
+
catalog={bookshelf.catalog}
|
|
65
|
+
notes={bookshelf.notes}
|
|
66
|
+
title={bookshelf.title}
|
|
67
|
+
eyebrow={bookshelf.eyebrow ?? siteConfig.name}
|
|
68
|
+
initialHub={found.book.id}
|
|
69
|
+
/>
|
|
70
|
+
</main>
|
|
71
|
+
);
|
|
55
72
|
}
|
|
@@ -79,6 +79,7 @@ function shelfShell(brand: string, title: string) {
|
|
|
79
79
|
<div class="note-view" id="noteView" hidden>
|
|
80
80
|
<article class="note">
|
|
81
81
|
<button type="button" class="back-btn" id="backBtn">← 返回书架</button>
|
|
82
|
+
<div class="hub-panel" id="hubPanel" hidden></div>
|
|
82
83
|
<div class="note-hero">
|
|
83
84
|
<div class="note-cover" id="noteCover"></div>
|
|
84
85
|
<div class="note-meta">
|
|
@@ -101,7 +102,14 @@ function shelfShell(brand: string, title: string) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
/** Approved bookshelf. Pass `catalog` and `notes` for a site shelf; omit them for the package demo. */
|
|
104
|
-
export function Bookshelf({
|
|
105
|
+
export function Bookshelf({
|
|
106
|
+
catalog,
|
|
107
|
+
notes,
|
|
108
|
+
title,
|
|
109
|
+
eyebrow,
|
|
110
|
+
embedded = false,
|
|
111
|
+
initialHub,
|
|
112
|
+
}: BookshelfProps) {
|
|
105
113
|
const ref = useRef<HTMLDivElement>(null);
|
|
106
114
|
useLayoutEffect(() => {
|
|
107
115
|
const root = ref.current;
|
|
@@ -111,13 +119,18 @@ export function Bookshelf({ catalog, notes, title, eyebrow, embedded = false }:
|
|
|
111
119
|
if (saved != null) window.scrollTo(0, saved);
|
|
112
120
|
};
|
|
113
121
|
applyScroll();
|
|
114
|
-
const unmount = mountBookshelf(root, {
|
|
122
|
+
const unmount = mountBookshelf(root, {
|
|
123
|
+
catalog: withNoteLinks(catalog),
|
|
124
|
+
notes,
|
|
125
|
+
initialHub,
|
|
126
|
+
shelfHref: siteHref("/bookshelf"),
|
|
127
|
+
});
|
|
115
128
|
const frame = window.requestAnimationFrame(applyScroll);
|
|
116
129
|
return () => {
|
|
117
130
|
window.cancelAnimationFrame(frame);
|
|
118
131
|
unmount?.();
|
|
119
132
|
};
|
|
120
|
-
}, [catalog, notes, title, eyebrow, embedded]);
|
|
133
|
+
}, [catalog, notes, title, eyebrow, embedded, initialHub]);
|
|
121
134
|
return (
|
|
122
135
|
<div
|
|
123
136
|
className={embedded ? "bookshelf-demo is-site" : "bookshelf-demo"}
|
|
@@ -283,6 +283,7 @@
|
|
|
283
283
|
overscroll-behavior-x: contain;
|
|
284
284
|
scrollbar-width: none;
|
|
285
285
|
}
|
|
286
|
+
.bookshelf-demo .spines.has-jackets { gap: 10px; }
|
|
286
287
|
.bookshelf-demo .spines::-webkit-scrollbar { display: none; }
|
|
287
288
|
|
|
288
289
|
.bookshelf-demo .spine {
|
|
@@ -353,14 +354,29 @@
|
|
|
353
354
|
transition: opacity 220ms var(--ease-motion);
|
|
354
355
|
}
|
|
355
356
|
|
|
356
|
-
/* Mode C: temporary expand on spine wall
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
357
|
+
/* Mode C: temporary expand on spine wall.
|
|
358
|
+
Demo spines (no .has-jacket) stay cloth until expand.
|
|
359
|
+
A real books[].cover is .has-jacket: the <img> is the face at rest,
|
|
360
|
+
on hover, and while extracting. bk-* must not paint a plate over it.
|
|
361
|
+
The old `.spine[class*="img-"] { background-image: none !important }`
|
|
362
|
+
was that plate. */
|
|
360
363
|
.bookshelf-demo .spine.is-expanded,
|
|
361
|
-
.bookshelf-demo .spine.is-extracting
|
|
364
|
+
.bookshelf-demo .spine.is-extracting,
|
|
365
|
+
.bookshelf-demo .spine.has-jacket {
|
|
362
366
|
background-color: transparent !important;
|
|
363
367
|
}
|
|
368
|
+
.bookshelf-demo .spine.has-jacket {
|
|
369
|
+
padding: 0;
|
|
370
|
+
writing-mode: horizontal-tb;
|
|
371
|
+
text-orientation: mixed;
|
|
372
|
+
letter-spacing: 0;
|
|
373
|
+
background-image: none;
|
|
374
|
+
}
|
|
375
|
+
.bookshelf-demo .spine.has-jacket > span {
|
|
376
|
+
opacity: 0 !important;
|
|
377
|
+
visibility: hidden !important;
|
|
378
|
+
}
|
|
379
|
+
.bookshelf-demo .spine > .cover-art,
|
|
364
380
|
.bookshelf-demo .spine.is-expanded > .cover-art,
|
|
365
381
|
.bookshelf-demo .spine.is-extracting > .cover-art {
|
|
366
382
|
position: absolute;
|
|
@@ -374,33 +390,34 @@
|
|
|
374
390
|
z-index: 1;
|
|
375
391
|
border-radius: inherit;
|
|
376
392
|
}
|
|
377
|
-
|
|
393
|
+
/* Demo catalog only: cloth at rest, jacket when the spine expands. */
|
|
394
|
+
.bookshelf-demo .spine[class*="img-"]:not(.has-jacket) {
|
|
378
395
|
background-image: none !important;
|
|
379
396
|
}
|
|
380
|
-
.bookshelf-demo .spine.is-expanded[class*="img-"] {
|
|
397
|
+
.bookshelf-demo .spine.is-expanded[class*="img-"]:not(.has-jacket) {
|
|
381
398
|
background-image: unset;
|
|
382
399
|
}
|
|
383
|
-
.bookshelf-demo .spine.is-expanded.img-clean-code { background-image: url("/covers/clean-code.jpg") !important; }
|
|
384
|
-
.bookshelf-demo .spine.is-expanded.img-refactoring { background-image: url("/covers/refactoring.jpg") !important; }
|
|
385
|
-
.bookshelf-demo .spine.is-expanded.img-design-patterns { background-image: url("/covers/design-patterns.jpg") !important; }
|
|
386
|
-
.bookshelf-demo .spine.is-expanded.img-pragmatic-programmer { background-image: url("/covers/pragmatic-programmer.jpg") !important; }
|
|
387
|
-
.bookshelf-demo .spine.is-expanded.img-continuous-delivery { background-image: url("/covers/continuous-delivery.jpg") !important; }
|
|
388
|
-
.bookshelf-demo .spine.is-expanded.img-phoenix-project { background-image: url("/covers/phoenix-project.jpg") !important; }
|
|
389
|
-
.bookshelf-demo .spine.is-expanded.img-mythical-man-month { background-image: url("/covers/mythical-man-month.jpg") !important; }
|
|
390
|
-
.bookshelf-demo .spine.is-expanded.img-tdd { background-image: url("/covers/tdd.jpg") !important; }
|
|
391
|
-
.bookshelf-demo .spine.is-expanded.img-lean-startup { background-image: url("/covers/lean-startup.jpg") !important; }
|
|
392
|
-
.bookshelf-demo .spine.is-expanded.img-release-it { background-image: url("/covers/release-it.jpg") !important; }
|
|
393
|
-
.bookshelf-demo .spine.is-expanded.img-sre { background-image: url("/covers/sre.jpg") !important; }
|
|
394
|
-
.bookshelf-demo .spine.is-expanded.img-team-topologies { background-image: url("/covers/team-topologies.jpg") !important; }
|
|
395
|
-
.bookshelf-demo .spine.is-expanded.img-accelerate { background-image: url("/covers/accelerate.jpg") !important; }
|
|
396
|
-
.bookshelf-demo .spine.is-expanded.img-ddd { background-image: url("/covers/ddd.jpg") !important; }
|
|
397
|
-
.bookshelf-demo .spine.is-expanded.img-microservices { background-image: url("/covers/microservices.jpg") !important; }
|
|
398
|
-
.bookshelf-demo .spine.is-expanded.img-eaa { background-image: url("/covers/eaa.jpg") !important; }
|
|
399
|
-
.bookshelf-demo .spine.is-expanded.img-ddia { background-image: url("/covers/ddia.jpg") !important; }
|
|
400
|
-
.bookshelf-demo .spine.is-expanded.img-fundamentals-sa { background-image: url("/covers/fundamentals-sa.jpg") !important; }
|
|
401
|
-
.bookshelf-demo .spine.is-expanded.img-implementing-ddd { background-image: url("/covers/implementing-ddd.jpg") !important; }
|
|
402
|
-
.bookshelf-demo .spine.is-expanded.img-systems-performance { background-image: url("/covers/systems-performance.jpg") !important; }
|
|
403
|
-
.bookshelf-demo .spine.is-expanded.img-clean-architecture { background-image: url("/covers/clean-architecture.jpg") !important; }
|
|
400
|
+
.bookshelf-demo .spine.is-expanded.img-clean-code:not(.has-jacket) { background-image: url("/covers/clean-code.jpg") !important; }
|
|
401
|
+
.bookshelf-demo .spine.is-expanded.img-refactoring:not(.has-jacket) { background-image: url("/covers/refactoring.jpg") !important; }
|
|
402
|
+
.bookshelf-demo .spine.is-expanded.img-design-patterns:not(.has-jacket) { background-image: url("/covers/design-patterns.jpg") !important; }
|
|
403
|
+
.bookshelf-demo .spine.is-expanded.img-pragmatic-programmer:not(.has-jacket) { background-image: url("/covers/pragmatic-programmer.jpg") !important; }
|
|
404
|
+
.bookshelf-demo .spine.is-expanded.img-continuous-delivery:not(.has-jacket) { background-image: url("/covers/continuous-delivery.jpg") !important; }
|
|
405
|
+
.bookshelf-demo .spine.is-expanded.img-phoenix-project:not(.has-jacket) { background-image: url("/covers/phoenix-project.jpg") !important; }
|
|
406
|
+
.bookshelf-demo .spine.is-expanded.img-mythical-man-month:not(.has-jacket) { background-image: url("/covers/mythical-man-month.jpg") !important; }
|
|
407
|
+
.bookshelf-demo .spine.is-expanded.img-tdd:not(.has-jacket) { background-image: url("/covers/tdd.jpg") !important; }
|
|
408
|
+
.bookshelf-demo .spine.is-expanded.img-lean-startup:not(.has-jacket) { background-image: url("/covers/lean-startup.jpg") !important; }
|
|
409
|
+
.bookshelf-demo .spine.is-expanded.img-release-it:not(.has-jacket) { background-image: url("/covers/release-it.jpg") !important; }
|
|
410
|
+
.bookshelf-demo .spine.is-expanded.img-sre:not(.has-jacket) { background-image: url("/covers/sre.jpg") !important; }
|
|
411
|
+
.bookshelf-demo .spine.is-expanded.img-team-topologies:not(.has-jacket) { background-image: url("/covers/team-topologies.jpg") !important; }
|
|
412
|
+
.bookshelf-demo .spine.is-expanded.img-accelerate:not(.has-jacket) { background-image: url("/covers/accelerate.jpg") !important; }
|
|
413
|
+
.bookshelf-demo .spine.is-expanded.img-ddd:not(.has-jacket) { background-image: url("/covers/ddd.jpg") !important; }
|
|
414
|
+
.bookshelf-demo .spine.is-expanded.img-microservices:not(.has-jacket) { background-image: url("/covers/microservices.jpg") !important; }
|
|
415
|
+
.bookshelf-demo .spine.is-expanded.img-eaa:not(.has-jacket) { background-image: url("/covers/eaa.jpg") !important; }
|
|
416
|
+
.bookshelf-demo .spine.is-expanded.img-ddia:not(.has-jacket) { background-image: url("/covers/ddia.jpg") !important; }
|
|
417
|
+
.bookshelf-demo .spine.is-expanded.img-fundamentals-sa:not(.has-jacket) { background-image: url("/covers/fundamentals-sa.jpg") !important; }
|
|
418
|
+
.bookshelf-demo .spine.is-expanded.img-implementing-ddd:not(.has-jacket) { background-image: url("/covers/implementing-ddd.jpg") !important; }
|
|
419
|
+
.bookshelf-demo .spine.is-expanded.img-systems-performance:not(.has-jacket) { background-image: url("/covers/systems-performance.jpg") !important; }
|
|
420
|
+
.bookshelf-demo .spine.is-expanded.img-clean-architecture:not(.has-jacket) { background-image: url("/covers/clean-architecture.jpg") !important; }
|
|
404
421
|
|
|
405
422
|
.bookshelf-demo .spine.is-expanded {
|
|
406
423
|
width: 100px;
|
|
@@ -435,7 +452,8 @@
|
|
|
435
452
|
box-shadow var(--t-collapse) var(--ease-motion),
|
|
436
453
|
background-size var(--t-collapse) var(--ease-motion);
|
|
437
454
|
}
|
|
438
|
-
/* C collapse: shrink while still "expanded"
|
|
455
|
+
/* C collapse: shrink while still "expanded". Real jackets keep .cover-art.
|
|
456
|
+
Demo cloth may drop the expanded photo; it must not clear a .has-jacket. */
|
|
439
457
|
.bookshelf-demo .spine.is-expanded.is-collapsing {
|
|
440
458
|
width: 28px;
|
|
441
459
|
height: 150px;
|
|
@@ -449,10 +467,18 @@
|
|
|
449
467
|
box-shadow:
|
|
450
468
|
inset -1px 0 0 rgba(255,255,255,0.12),
|
|
451
469
|
inset 1px 0 0 rgba(0,0,0,0.18);
|
|
452
|
-
background-image: none !important;
|
|
453
470
|
background-size: auto;
|
|
454
471
|
z-index: 2;
|
|
455
472
|
}
|
|
473
|
+
.bookshelf-demo .spine.is-expanded.is-collapsing:not(.has-jacket) {
|
|
474
|
+
background-image: none !important;
|
|
475
|
+
}
|
|
476
|
+
.bookshelf-demo .spine.has-jacket.is-expanded.is-collapsing {
|
|
477
|
+
writing-mode: horizontal-tb;
|
|
478
|
+
padding: 0;
|
|
479
|
+
background-color: transparent !important;
|
|
480
|
+
background-image: none;
|
|
481
|
+
}
|
|
456
482
|
.bookshelf-demo .spine.is-expanded.is-collapsing.s-tall { width: 30px; height: 168px; }
|
|
457
483
|
.bookshelf-demo .spine.is-expanded.is-collapsing.s-mid { width: 28px; height: 152px; }
|
|
458
484
|
.bookshelf-demo .spine.is-expanded.is-collapsing.s-short { width: 24px; height: 136px; }
|
|
@@ -823,7 +849,24 @@
|
|
|
823
849
|
opacity: 0 !important;
|
|
824
850
|
}
|
|
825
851
|
|
|
826
|
-
/* ux16: exact homeRect standing spine — full height, spine width, slides out/in
|
|
852
|
+
/* ux16: exact homeRect standing spine — full height, spine width, slides out/in.
|
|
853
|
+
A real jacket fills the slot. bk-* cloth is only the filler / demo label. */
|
|
854
|
+
.bookshelf-demo .flyer.flyer-spine-slot.has-jacket {
|
|
855
|
+
background: none !important;
|
|
856
|
+
background-color: transparent !important;
|
|
857
|
+
box-shadow: none;
|
|
858
|
+
padding: 0;
|
|
859
|
+
}
|
|
860
|
+
.bookshelf-demo .flyer-spine-slot-img {
|
|
861
|
+
position: absolute;
|
|
862
|
+
inset: 0;
|
|
863
|
+
width: 100%;
|
|
864
|
+
height: 100%;
|
|
865
|
+
object-fit: contain;
|
|
866
|
+
object-position: center bottom;
|
|
867
|
+
display: block;
|
|
868
|
+
border-radius: inherit;
|
|
869
|
+
}
|
|
827
870
|
.bookshelf-demo .flyer.flyer-spine-slot {
|
|
828
871
|
position: fixed;
|
|
829
872
|
margin: 0;
|
|
@@ -1161,35 +1204,6 @@
|
|
|
1161
1204
|
.bookshelf-demo .s-tall { height: 148px; width: 26px; }
|
|
1162
1205
|
.bookshelf-demo .s-mid { height: 134px; width: 24px; }
|
|
1163
1206
|
.bookshelf-demo .s-short { height: 120px; width: 20px; }
|
|
1164
|
-
/* resting spines: solid hue only; cover image only when expanded */
|
|
1165
|
-
.bookshelf-demo .spine[class*="img-"] {
|
|
1166
|
-
background-image: none !important;
|
|
1167
|
-
}
|
|
1168
|
-
.bookshelf-demo .spine.is-expanded[class*="img-"] {
|
|
1169
|
-
background-image: unset;
|
|
1170
|
-
}
|
|
1171
|
-
.bookshelf-demo .spine.is-expanded.img-clean-code { background-image: url("/covers/clean-code.jpg") !important; }
|
|
1172
|
-
.bookshelf-demo .spine.is-expanded.img-refactoring { background-image: url("/covers/refactoring.jpg") !important; }
|
|
1173
|
-
.bookshelf-demo .spine.is-expanded.img-design-patterns { background-image: url("/covers/design-patterns.jpg") !important; }
|
|
1174
|
-
.bookshelf-demo .spine.is-expanded.img-pragmatic-programmer { background-image: url("/covers/pragmatic-programmer.jpg") !important; }
|
|
1175
|
-
.bookshelf-demo .spine.is-expanded.img-continuous-delivery { background-image: url("/covers/continuous-delivery.jpg") !important; }
|
|
1176
|
-
.bookshelf-demo .spine.is-expanded.img-phoenix-project { background-image: url("/covers/phoenix-project.jpg") !important; }
|
|
1177
|
-
.bookshelf-demo .spine.is-expanded.img-mythical-man-month { background-image: url("/covers/mythical-man-month.jpg") !important; }
|
|
1178
|
-
.bookshelf-demo .spine.is-expanded.img-tdd { background-image: url("/covers/tdd.jpg") !important; }
|
|
1179
|
-
.bookshelf-demo .spine.is-expanded.img-lean-startup { background-image: url("/covers/lean-startup.jpg") !important; }
|
|
1180
|
-
.bookshelf-demo .spine.is-expanded.img-release-it { background-image: url("/covers/release-it.jpg") !important; }
|
|
1181
|
-
.bookshelf-demo .spine.is-expanded.img-sre { background-image: url("/covers/sre.jpg") !important; }
|
|
1182
|
-
.bookshelf-demo .spine.is-expanded.img-team-topologies { background-image: url("/covers/team-topologies.jpg") !important; }
|
|
1183
|
-
.bookshelf-demo .spine.is-expanded.img-accelerate { background-image: url("/covers/accelerate.jpg") !important; }
|
|
1184
|
-
.bookshelf-demo .spine.is-expanded.img-ddd { background-image: url("/covers/ddd.jpg") !important; }
|
|
1185
|
-
.bookshelf-demo .spine.is-expanded.img-microservices { background-image: url("/covers/microservices.jpg") !important; }
|
|
1186
|
-
.bookshelf-demo .spine.is-expanded.img-eaa { background-image: url("/covers/eaa.jpg") !important; }
|
|
1187
|
-
.bookshelf-demo .spine.is-expanded.img-ddia { background-image: url("/covers/ddia.jpg") !important; }
|
|
1188
|
-
.bookshelf-demo .spine.is-expanded.img-fundamentals-sa { background-image: url("/covers/fundamentals-sa.jpg") !important; }
|
|
1189
|
-
.bookshelf-demo .spine.is-expanded.img-implementing-ddd { background-image: url("/covers/implementing-ddd.jpg") !important; }
|
|
1190
|
-
.bookshelf-demo .spine.is-expanded.img-systems-performance { background-image: url("/covers/systems-performance.jpg") !important; }
|
|
1191
|
-
.bookshelf-demo .spine.is-expanded.img-clean-architecture { background-image: url("/covers/clean-architecture.jpg") !important; }
|
|
1192
|
-
|
|
1193
1207
|
.bookshelf-demo .spine.is-expanded {
|
|
1194
1208
|
width: 90px;
|
|
1195
1209
|
height: 136px;
|
|
@@ -1328,6 +1342,10 @@ body:has(.bookshelf-demo.is-site) .sh-header .sh-word {
|
|
|
1328
1342
|
text-underline-offset: 3px;
|
|
1329
1343
|
}
|
|
1330
1344
|
.bookshelf-demo .note-read[hidden] { display: none; }
|
|
1345
|
+
.bookshelf-demo .hub-panel[hidden] { display: none !important; }
|
|
1346
|
+
.bookshelf-demo .note-view.is-hub .note-hero,
|
|
1347
|
+
.bookshelf-demo .note-view.is-hub .note-body { display: none !important; }
|
|
1348
|
+
.bookshelf-demo .hub-panel .book-hub { width: 100%; padding: 0; }
|
|
1331
1349
|
.bookshelf-demo.is-site .note-read { color: var(--blue); }
|
|
1332
1350
|
@media (max-width: 820px) {
|
|
1333
1351
|
.bookshelf-demo.is-site .note { padding: 16px 20px 64px; }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
2
|
// @ts-nocheck
|
|
3
3
|
// Tip ux26 held-book-s10-ux26-20261028. Motion copied from that app.js. Do not retune it.
|
|
4
|
+
import { ui } from "../../lib/i18n";
|
|
4
5
|
import { DEMO_CATALOG, DEMO_NOTES } from "./demo-data";
|
|
5
6
|
|
|
6
7
|
function cssIdent(id) {
|
|
@@ -12,6 +13,19 @@ function coverSrc(book) {
|
|
|
12
13
|
return `/covers/${book.id}.jpg`;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** Site jackets are URL strings. Demo `cover: true` stays a cloth spine at rest. */
|
|
17
|
+
function realCover(book) {
|
|
18
|
+
return !!(book && typeof book.cover === "string" && book.cover && !book.filler);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function escapeHtml(value) {
|
|
22
|
+
return String(value ?? "")
|
|
23
|
+
.replaceAll("&", "&")
|
|
24
|
+
.replaceAll("<", "<")
|
|
25
|
+
.replaceAll(">", ">")
|
|
26
|
+
.replaceAll('"', """);
|
|
27
|
+
}
|
|
28
|
+
|
|
15
29
|
function injectExtraCovers(catalog) {
|
|
16
30
|
const rules = [];
|
|
17
31
|
for (const cat of catalog) {
|
|
@@ -43,6 +57,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
43
57
|
const CATALOG = options.catalog ?? DEMO_CATALOG;
|
|
44
58
|
const NOTES = options.notes ?? (options.catalog ? {} : DEMO_NOTES);
|
|
45
59
|
const usingDemo = !options.catalog;
|
|
60
|
+
const initialHub = typeof options.initialHub === "string" ? options.initialHub : "";
|
|
61
|
+
const shelfHref = typeof options.shelfHref === "string" && options.shelfHref
|
|
62
|
+
? options.shelfHref
|
|
63
|
+
: "/bookshelf/";
|
|
46
64
|
const removeExtraCovers = injectExtraCovers(CATALOG);
|
|
47
65
|
|
|
48
66
|
// ux26: per-book cover dominant (= existing bk-* spine colors) for open-shell + page wash
|
|
@@ -297,6 +315,24 @@ export function mountBookshelf(root, options = {}) {
|
|
|
297
315
|
return window.innerWidth <= 640 ? 102 : 118;
|
|
298
316
|
}
|
|
299
317
|
|
|
318
|
+
// Real jackets stand face-out. Width fits the row when it can (≥72);
|
|
319
|
+
// otherwise 72 and the row scrolls, so the art stays readable.
|
|
320
|
+
function jacketFaceWidth(count) {
|
|
321
|
+
const col = Math.min((window.innerWidth || 1280) - 32, 740);
|
|
322
|
+
const gap = 10;
|
|
323
|
+
const full = coverWidthPx();
|
|
324
|
+
const n = Math.max(1, count | 0);
|
|
325
|
+
const fit = Math.floor((col - 8 - gap * Math.max(0, n - 1)) / n);
|
|
326
|
+
if (fit >= 72) return Math.min(full, fit);
|
|
327
|
+
return Math.min(full, 72);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function faceHeightFor(book, w) {
|
|
331
|
+
const nat = bookNat(book);
|
|
332
|
+
if (!nat) return Math.max(96, Math.round(w / 0.68));
|
|
333
|
+
return Math.max(96, Math.round(w * (nat.h / nat.w)));
|
|
334
|
+
}
|
|
335
|
+
|
|
300
336
|
function bookNat(book) {
|
|
301
337
|
if (!book || !book.cover) return null;
|
|
302
338
|
const pair = COVER_NAT[book.id];
|
|
@@ -306,10 +342,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
306
342
|
|
|
307
343
|
// Fixed width; height from real cover aspect. Fallback ~2:3 if unknown.
|
|
308
344
|
function bookFaceHeight(book) {
|
|
309
|
-
|
|
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)));
|
|
345
|
+
return faceHeightFor(book, coverWidthPx());
|
|
313
346
|
}
|
|
314
347
|
|
|
315
348
|
// Real jacket on the expanded spine. CSS background-image is stripped by
|
|
@@ -338,18 +371,21 @@ export function mountBookshelf(root, options = {}) {
|
|
|
338
371
|
|
|
339
372
|
function applyFaceSize(el, book, { expanded = false } = {}) {
|
|
340
373
|
if (!el || !book) return;
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
374
|
+
const jacket = realCover(book) && el.classList.contains("spine");
|
|
375
|
+
const stored = jacket ? Number(el.dataset.jacketW) : 0;
|
|
376
|
+
const w = expanded ? coverWidthPx() : (stored > 0 ? stored : (jacket ? jacketFaceWidth(1) : coverWidthPx()));
|
|
377
|
+
const faceH = faceHeightFor(book, jacket || el.classList.contains("cover") || expanded ? w : coverWidthPx());
|
|
378
|
+
if (el.classList.contains("cover") || expanded || jacket) {
|
|
344
379
|
el.style.width = w + "px";
|
|
345
380
|
el.style.height = faceH + "px";
|
|
346
381
|
el.style.backgroundSize = "contain";
|
|
347
382
|
el.style.backgroundPosition = "center top";
|
|
348
383
|
el.style.backgroundRepeat = "no-repeat";
|
|
384
|
+
el.dataset.faceH = String(faceH);
|
|
349
385
|
} else if (el.classList.contains("spine")) {
|
|
350
|
-
//
|
|
386
|
+
// Demo / filler spine: height tracks the cover face; width stays the cloth class.
|
|
351
387
|
el.style.height = faceH + "px";
|
|
352
|
-
el.style.removeProperty("width");
|
|
388
|
+
el.style.removeProperty("width");
|
|
353
389
|
el.dataset.faceH = String(faceH);
|
|
354
390
|
}
|
|
355
391
|
}
|
|
@@ -557,16 +593,24 @@ export function mountBookshelf(root, options = {}) {
|
|
|
557
593
|
} else {
|
|
558
594
|
const row = document.createElement("div");
|
|
559
595
|
row.className = "spines";
|
|
596
|
+
const jacketCount = cat.books.filter(realCover).length;
|
|
597
|
+
if (jacketCount) row.classList.add("has-jackets");
|
|
598
|
+
const jacketW = jacketFaceWidth(Math.max(1, jacketCount));
|
|
560
599
|
for (const book of cat.books) {
|
|
561
600
|
const btn = document.createElement("button");
|
|
562
601
|
btn.type = "button";
|
|
563
602
|
const sizeCls = book.size === "thin" ? "thin dark-text" : `${book.size} light-text`;
|
|
564
603
|
btn.className = `spine ${bkClass(book)} ${sizeCls}`;
|
|
565
604
|
if (book.cover) btn.classList.add(coverClass(book));
|
|
605
|
+
if (realCover(book)) {
|
|
606
|
+
btn.classList.add("has-jacket");
|
|
607
|
+
btn.dataset.jacketW = String(jacketW);
|
|
608
|
+
}
|
|
566
609
|
btn.dataset.bookId = book.id;
|
|
567
610
|
btn.setAttribute("aria-label", book.title);
|
|
568
|
-
btn.innerHTML = `<span>${book.spine}</span>`;
|
|
611
|
+
btn.innerHTML = `<span>${escapeHtml(book.spine)}</span>`;
|
|
569
612
|
if (book.cover && !book.filler) applyFaceSize(btn, book);
|
|
613
|
+
if (realCover(book)) mountSeatCover(btn, book);
|
|
570
614
|
else if (book.filler) { /* thin filler keeps CSS height */ }
|
|
571
615
|
|
|
572
616
|
if (book.cover && !book.filler) {
|
|
@@ -604,13 +648,19 @@ export function mountBookshelf(root, options = {}) {
|
|
|
604
648
|
function restoreRestingSpine(el, bookId) {
|
|
605
649
|
if (!el) return;
|
|
606
650
|
el.classList.remove("is-expanded", "is-collapsing");
|
|
607
|
-
clearSeatCover(el);
|
|
608
651
|
const found = bookById(bookId);
|
|
609
|
-
if (found && found.book
|
|
652
|
+
if (found && realCover(found.book)) {
|
|
610
653
|
applyFaceSize(el, found.book, { expanded: false });
|
|
654
|
+
mountSeatCover(el, found.book);
|
|
655
|
+
el.classList.add("has-jacket");
|
|
611
656
|
} else {
|
|
612
|
-
el
|
|
613
|
-
|
|
657
|
+
clearSeatCover(el);
|
|
658
|
+
if (found && found.book.cover && !found.book.filler) {
|
|
659
|
+
applyFaceSize(el, found.book, { expanded: false });
|
|
660
|
+
} else {
|
|
661
|
+
el.style.removeProperty("width");
|
|
662
|
+
el.style.removeProperty("height");
|
|
663
|
+
}
|
|
614
664
|
}
|
|
615
665
|
}
|
|
616
666
|
|
|
@@ -618,13 +668,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
618
668
|
$$(".spine[data-book-id]").forEach((el) => {
|
|
619
669
|
const id = el.dataset.bookId;
|
|
620
670
|
if (!id || id === exceptId) return;
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
) {
|
|
626
|
-
restoreRestingSpine(el, id);
|
|
627
|
-
}
|
|
671
|
+
const expandedLike = el.classList.contains("is-expanded") || el.classList.contains("is-collapsing");
|
|
672
|
+
// Real jackets keep an inline face width at rest. That is not an orphan expand.
|
|
673
|
+
const strayWidth = !!el.style.width && !el.classList.contains("has-jacket");
|
|
674
|
+
if (expandedLike || strayWidth) restoreRestingSpine(el, id);
|
|
628
675
|
});
|
|
629
676
|
}
|
|
630
677
|
|
|
@@ -660,7 +707,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
660
707
|
if (!el) return;
|
|
661
708
|
// Keep is-expanded + expandedId during shrink so a click mid-collapse still takes C path.
|
|
662
709
|
// Drop inline width FIRST — ux17 face size would otherwise beat .is-collapsing CSS width.
|
|
663
|
-
|
|
710
|
+
// Real jackets keep their resting face; clearing width snaps them to a 28px cloth bar.
|
|
711
|
+
const found = bookById(id);
|
|
712
|
+
if (found && realCover(found.book)) applyFaceSize(el, found.book, { expanded: false });
|
|
713
|
+
else el.style.removeProperty("width");
|
|
664
714
|
el.classList.add("is-collapsing");
|
|
665
715
|
const token = ++collapseToken;
|
|
666
716
|
await wait(cssMs("--t-collapse", 230));
|
|
@@ -677,30 +727,55 @@ export function mountBookshelf(root, options = {}) {
|
|
|
677
727
|
collapseToken += 1;
|
|
678
728
|
}
|
|
679
729
|
|
|
680
|
-
//
|
|
681
|
-
|
|
730
|
+
// Two or more notes: the shelf extract lands on the book hub, not the first note.
|
|
731
|
+
// List → article is an ordinary link and does not use this.
|
|
732
|
+
function isHubBook(book) {
|
|
682
733
|
const posts = book && Array.isArray(book.posts) ? book.posts : [];
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
return
|
|
734
|
+
return posts.length >= 2 && typeof book.hub === "string" && !!book.hub;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function samePath(a, b) {
|
|
738
|
+
const norm = (value) => String(value || "").replace(/\/+$/u, "") || "/";
|
|
739
|
+
return norm(a) === norm(b);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function rememberHubUrl() {
|
|
743
|
+
if (!openBook || !openBook.hubMode || !openBook.book || !openBook.book.hub) return;
|
|
744
|
+
if (openBook.hubEntry === "direct" || openBook.hubEntry === "pushed") return;
|
|
745
|
+
const url = openBook.book.hub;
|
|
746
|
+
if (samePath(location.pathname, url)) {
|
|
747
|
+
openBook.hubEntry = "direct";
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
history.pushState(history.state, "", url);
|
|
752
|
+
openBook.hubEntry = "pushed";
|
|
753
|
+
} catch (_) {
|
|
754
|
+
openBook.hubEntry = "direct";
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function releaseHubUrl() {
|
|
759
|
+
if (!openBook || !openBook.hubMode || openBook.hubEntry === "history") return;
|
|
760
|
+
if (samePath(location.pathname, shelfHref)) return;
|
|
761
|
+
try { history.replaceState(history.state, "", shelfHref); } catch (_) {}
|
|
689
762
|
}
|
|
690
763
|
|
|
691
764
|
async function onCoverClick(id, el) {
|
|
692
765
|
if (busy) return;
|
|
693
766
|
const found = bookById(id);
|
|
694
767
|
if (!found || found.book.filler) return;
|
|
695
|
-
|
|
696
|
-
|
|
768
|
+
await openBookFlow(found.book, found.cat, el, {
|
|
769
|
+
fromExpanded: false,
|
|
770
|
+
asSpine: false,
|
|
771
|
+
hub: isHubBook(found.book),
|
|
772
|
+
});
|
|
697
773
|
}
|
|
698
774
|
|
|
699
775
|
async function onSpineClick(id, el, ev) {
|
|
700
776
|
if (busy) return;
|
|
701
777
|
const found = bookById(id);
|
|
702
778
|
if (!found || found.book.filler) return;
|
|
703
|
-
if (leaveForHub(found.book)) return;
|
|
704
779
|
if (!found.book.cover) return;
|
|
705
780
|
|
|
706
781
|
const isTouch = ev && (ev.pointerType === "touch" || ("ontouchstart" in window && window.matchMedia("(hover: none)").matches));
|
|
@@ -709,7 +784,11 @@ export function mountBookshelf(root, options = {}) {
|
|
|
709
784
|
if (isTouch || window.matchMedia("(hover: none)").matches) {
|
|
710
785
|
touchExpandedId = null;
|
|
711
786
|
if (expandedId) await collapseSpine(expandedId);
|
|
712
|
-
await openBookFlow(found.book, found.cat, el, {
|
|
787
|
+
await openBookFlow(found.book, found.cat, el, {
|
|
788
|
+
fromExpanded: false,
|
|
789
|
+
asSpine: true,
|
|
790
|
+
hub: isHubBook(found.book),
|
|
791
|
+
});
|
|
713
792
|
return;
|
|
714
793
|
}
|
|
715
794
|
|
|
@@ -717,10 +796,105 @@ export function mountBookshelf(root, options = {}) {
|
|
|
717
796
|
const fromExpanded =
|
|
718
797
|
(expandedId === id && el.classList.contains("is-expanded")) ||
|
|
719
798
|
el.classList.contains("is-collapsing");
|
|
720
|
-
await openBookFlow(found.book, found.cat, el, {
|
|
799
|
+
await openBookFlow(found.book, found.cat, el, {
|
|
800
|
+
fromExpanded,
|
|
801
|
+
asSpine: true,
|
|
802
|
+
hub: isHubBook(found.book),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function showNoteChrome() {
|
|
807
|
+
noteView.classList.remove("is-hub");
|
|
808
|
+
const hub = $("#hubPanel");
|
|
809
|
+
if (hub) {
|
|
810
|
+
hub.hidden = true;
|
|
811
|
+
hub.replaceChildren();
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function showHubChrome() {
|
|
816
|
+
noteView.classList.add("is-hub");
|
|
817
|
+
const hub = $("#hubPanel");
|
|
818
|
+
if (hub) hub.hidden = false;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function hubKindLabel(kind) {
|
|
822
|
+
const t = ui();
|
|
823
|
+
switch (kind) {
|
|
824
|
+
case "overview":
|
|
825
|
+
return t.bookHubOverview;
|
|
826
|
+
case "excerpt":
|
|
827
|
+
return t.bookHubExcerpt;
|
|
828
|
+
case undefined:
|
|
829
|
+
return "";
|
|
830
|
+
default: {
|
|
831
|
+
const unexpected = kind;
|
|
832
|
+
return unexpected;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function hubDay(value) {
|
|
838
|
+
const date = new Date(value);
|
|
839
|
+
if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
|
|
840
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
841
|
+
timeZone: "Asia/Shanghai",
|
|
842
|
+
year: "numeric",
|
|
843
|
+
month: "2-digit",
|
|
844
|
+
day: "2-digit",
|
|
845
|
+
}).format(date);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function hubSourceList(book) {
|
|
849
|
+
const list = Array.isArray(book.sources) ? book.sources : [];
|
|
850
|
+
const douban = list.filter((source) => source.label === "豆瓣" || /^douban$/iu.test(source.label));
|
|
851
|
+
const pdf = list.filter((source) => /pdf/iu.test(source.label));
|
|
852
|
+
return [...douban, ...pdf];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function fillHub(book) {
|
|
856
|
+
showHubChrome();
|
|
857
|
+
const t = ui();
|
|
858
|
+
const panel = $("#hubPanel");
|
|
859
|
+
if (!panel) return;
|
|
860
|
+
const jacket = realCover(book) ? coverSrc(book) : "";
|
|
861
|
+
const sources = hubSourceList(book).map((source) => {
|
|
862
|
+
const external = /^(?:https?:)?\/\//iu.test(source.href);
|
|
863
|
+
const pdf = /pdf/iu.test(source.label);
|
|
864
|
+
const attrs = external ? ` target="_blank" rel="noopener noreferrer"` : "";
|
|
865
|
+
return `<span class="book-hub-source"><a class="book-hub-chip" href="${escapeHtml(source.href)}"${attrs}>${escapeHtml(source.label)}<span class="ext" aria-hidden="true">↗</span></a>${pdf ? `<span class="book-hub-source-note">${escapeHtml(t.bookHubPdfNote)}</span>` : ""}</span>`;
|
|
866
|
+
}).join("");
|
|
867
|
+
const notes = (Array.isArray(book.posts) ? book.posts : []).map((entry) => {
|
|
868
|
+
const label = hubKindLabel(entry.kind);
|
|
869
|
+
const href = entry.href || `/blog/${entry.path}`;
|
|
870
|
+
const when = entry.date ? hubDay(entry.date) : "";
|
|
871
|
+
const count = entry.words ? t.wordCount(entry.words) : "";
|
|
872
|
+
const meta = when || count
|
|
873
|
+
? `<p class="book-hub-note-meta">${escapeHtml(when)}${when && count ? " · " : ""}${escapeHtml(count)}</p>`
|
|
874
|
+
: "";
|
|
875
|
+
return `<li><a class="book-hub-note" href="${escapeHtml(href)}">${label ? `<p class="book-hub-note-label">${escapeHtml(label)}</p>` : ""}<h3>${escapeHtml(entry.title || book.title)}</h3>${entry.summary ? `<p class="book-hub-note-sum">${escapeHtml(entry.summary)}</p>` : ""}${meta}</a></li>`;
|
|
876
|
+
}).join("");
|
|
877
|
+
panel.innerHTML = `<div class="book-hub">
|
|
878
|
+
<section class="book-hub-card" aria-labelledby="book-hub-title">
|
|
879
|
+
${jacket ? `<div class="book-hub-cover"><img src="${escapeHtml(jacket)}" alt="" width="148"></div>` : ""}
|
|
880
|
+
<div class="book-hub-meta">
|
|
881
|
+
<h1 id="book-hub-title">${escapeHtml(book.title)}</h1>
|
|
882
|
+
${book.author ? `<p class="book-hub-author">${escapeHtml(book.author)}</p>` : ""}
|
|
883
|
+
${sources ? `<div class="book-hub-sources">${sources}</div>` : ""}
|
|
884
|
+
</div>
|
|
885
|
+
</section>
|
|
886
|
+
<h2 class="book-hub-notes-head">${escapeHtml(t.bookHubNotes)}</h2>
|
|
887
|
+
<ul class="book-hub-notes">${notes}</ul>
|
|
888
|
+
</div>`;
|
|
889
|
+
const read = $("#noteRead");
|
|
890
|
+
if (read) {
|
|
891
|
+
read.hidden = true;
|
|
892
|
+
read.removeAttribute("href");
|
|
893
|
+
}
|
|
721
894
|
}
|
|
722
895
|
|
|
723
896
|
function fillNote(book, cat) {
|
|
897
|
+
showNoteChrome();
|
|
724
898
|
$("#noteCat").textContent = cat.name;
|
|
725
899
|
$("#noteTitle").textContent = book.title;
|
|
726
900
|
$("#noteAuthor").textContent = book.author || "";
|
|
@@ -892,10 +1066,23 @@ export function mountBookshelf(root, options = {}) {
|
|
|
892
1066
|
el.style.opacity = String(opacity);
|
|
893
1067
|
el.style.zIndex = "80";
|
|
894
1068
|
el.style.pointerEvents = "none";
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1069
|
+
if (book && book.cover && !book.filler) {
|
|
1070
|
+
el.classList.add("has-jacket", coverClass(book));
|
|
1071
|
+
el.style.backgroundColor = "transparent";
|
|
1072
|
+
el.style.backgroundImage = "none";
|
|
1073
|
+
el.style.padding = "0";
|
|
1074
|
+
const img = document.createElement("img");
|
|
1075
|
+
img.className = "flyer-spine-slot-img";
|
|
1076
|
+
img.alt = "";
|
|
1077
|
+
img.draggable = false;
|
|
1078
|
+
img.src = coverSrc(book);
|
|
1079
|
+
el.appendChild(img);
|
|
1080
|
+
} else {
|
|
1081
|
+
const label = document.createElement("span");
|
|
1082
|
+
label.className = "flyer-spine-slot-label";
|
|
1083
|
+
label.textContent = book.spine || book.title || "";
|
|
1084
|
+
el.appendChild(label);
|
|
1085
|
+
}
|
|
899
1086
|
stage.appendChild(el);
|
|
900
1087
|
return el;
|
|
901
1088
|
}
|
|
@@ -1047,16 +1234,31 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1047
1234
|
];
|
|
1048
1235
|
}
|
|
1049
1236
|
|
|
1050
|
-
//
|
|
1237
|
+
// Close start must not composite the open spread on the note (double exposure).
|
|
1238
|
+
// The article is gone within ~80ms. The flyer stays hidden until then, already
|
|
1239
|
+
// collapsed to a single jacket, then flies home.
|
|
1051
1240
|
function noteOutKeys() {
|
|
1052
1241
|
return [
|
|
1053
1242
|
{ offset: 0, opacity: 1 },
|
|
1054
|
-
{ offset: 0.
|
|
1055
|
-
{ offset: 0.12, opacity: 0 },
|
|
1243
|
+
{ offset: 0.02, opacity: 0 },
|
|
1056
1244
|
{ offset: 1, opacity: 0 },
|
|
1057
1245
|
];
|
|
1058
1246
|
}
|
|
1059
1247
|
|
|
1248
|
+
function closedReturnLeftKeys() {
|
|
1249
|
+
return [
|
|
1250
|
+
{ offset: 0, transform: "rotateY(180deg)" },
|
|
1251
|
+
{ offset: 1, transform: "rotateY(180deg)" },
|
|
1252
|
+
];
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function closedReturnRightKeys() {
|
|
1256
|
+
return [
|
|
1257
|
+
{ offset: 0, opacity: 0, transform: "rotateY(0deg)" },
|
|
1258
|
+
{ offset: 1, opacity: 0, transform: "rotateY(0deg)" },
|
|
1259
|
+
];
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1060
1262
|
// ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
|
|
1061
1263
|
// X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
|
|
1062
1264
|
// Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
|
|
@@ -1367,7 +1569,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1367
1569
|
"is-expanded",
|
|
1368
1570
|
"is-collapsing"
|
|
1369
1571
|
);
|
|
1370
|
-
clearSeatCover(el);
|
|
1572
|
+
if (!(book && realCover(book))) clearSeatCover(el);
|
|
1371
1573
|
// Explicit removeProperty — kills commitStyles leftovers like opacity:0 / transform
|
|
1372
1574
|
[
|
|
1373
1575
|
"width",
|
|
@@ -1391,6 +1593,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1391
1593
|
applyFaceSize(el, book);
|
|
1392
1594
|
} else if (book && el.classList.contains("spine") && book.cover && !book.filler) {
|
|
1393
1595
|
applyFaceSize(el, book);
|
|
1596
|
+
if (realCover(book)) {
|
|
1597
|
+
el.classList.add("has-jacket");
|
|
1598
|
+
mountSeatCover(el, book);
|
|
1599
|
+
}
|
|
1394
1600
|
}
|
|
1395
1601
|
}
|
|
1396
1602
|
|
|
@@ -1560,6 +1766,24 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1560
1766
|
}
|
|
1561
1767
|
}
|
|
1562
1768
|
|
|
1769
|
+
async function prepareHub(book) {
|
|
1770
|
+
fillHub(book);
|
|
1771
|
+
noteView.hidden = false;
|
|
1772
|
+
noteView.classList.add("is-preparing", "is-hub");
|
|
1773
|
+
noteView.classList.remove("is-open");
|
|
1774
|
+
noteView.style.opacity = "0";
|
|
1775
|
+
const img = $("#hubPanel") && $("#hubPanel").querySelector("img");
|
|
1776
|
+
if (img && !coverImgReady(img)) {
|
|
1777
|
+
try {
|
|
1778
|
+
const decodeOrLoad = img.decode
|
|
1779
|
+
? img.decode()
|
|
1780
|
+
: new Promise((res) => { img.onload = res; img.onerror = res; });
|
|
1781
|
+
await Promise.race([decodeOrLoad.catch(() => {}), wait(400)]);
|
|
1782
|
+
} catch (_) {}
|
|
1783
|
+
}
|
|
1784
|
+
void noteView.offsetWidth;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1563
1787
|
async function prepareNote(book, cat) {
|
|
1564
1788
|
fillNote(book, cat);
|
|
1565
1789
|
noteView.hidden = false;
|
|
@@ -1610,13 +1834,22 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1610
1834
|
restoreSlot(el);
|
|
1611
1835
|
}
|
|
1612
1836
|
|
|
1613
|
-
async function openBookFlow(book, cat, sourceEl, { fromExpanded, asSpine }) {
|
|
1837
|
+
async function openBookFlow(book, cat, sourceEl, { fromExpanded, asSpine, hub }) {
|
|
1614
1838
|
if (busy) return;
|
|
1615
1839
|
busy = true;
|
|
1616
1840
|
const myFlow = ++flowSeq;
|
|
1617
1841
|
document.body.classList.add("is-busy");
|
|
1618
1842
|
root.classList.remove("is-return");
|
|
1619
|
-
|
|
1843
|
+
const hubMode = !!hub && isHubBook(book);
|
|
1844
|
+
openBook = {
|
|
1845
|
+
book,
|
|
1846
|
+
cat,
|
|
1847
|
+
asSpine,
|
|
1848
|
+
fromExpanded: !!fromExpanded,
|
|
1849
|
+
hubMode,
|
|
1850
|
+
hubEntry: hubMode ? "pending" : "",
|
|
1851
|
+
openSeatRect: null,
|
|
1852
|
+
};
|
|
1620
1853
|
openFromExpanded = !!fromExpanded;
|
|
1621
1854
|
|
|
1622
1855
|
// ux6b: freeze window scroll to idle/expand home (face-out stuck ~197px up).
|
|
@@ -1629,7 +1862,8 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1629
1862
|
} catch (_) {}
|
|
1630
1863
|
|
|
1631
1864
|
try {
|
|
1632
|
-
await
|
|
1865
|
+
if (hubMode) await prepareHub(book);
|
|
1866
|
+
else await prepareNote(book, cat);
|
|
1633
1867
|
} catch (_) {
|
|
1634
1868
|
// Decode/prepare failed — still open; never leave is-busy hung.
|
|
1635
1869
|
}
|
|
@@ -1644,6 +1878,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1644
1878
|
noteView.classList.add("is-open");
|
|
1645
1879
|
noteView.style.opacity = "1";
|
|
1646
1880
|
parkShelf();
|
|
1881
|
+
rememberHubUrl();
|
|
1647
1882
|
releaseScrollLock();
|
|
1648
1883
|
busy = false;
|
|
1649
1884
|
document.body.classList.remove("is-busy");
|
|
@@ -1989,6 +2224,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1989
2224
|
unlockNoteClicks();
|
|
1990
2225
|
holdFlyerUnderNote();
|
|
1991
2226
|
parkShelf();
|
|
2227
|
+
rememberHubUrl();
|
|
1992
2228
|
restoreSlot(sourceEl, book);
|
|
1993
2229
|
sourceEl.classList.remove("is-extracting");
|
|
1994
2230
|
// Keep sourceEl as invisible reserved seat while note open (no gray plate).
|
|
@@ -2026,13 +2262,14 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2026
2262
|
const targetMode = pageMode;
|
|
2027
2263
|
|
|
2028
2264
|
if (reduceMotion()) {
|
|
2029
|
-
noteView.classList.remove("is-open", "is-preparing");
|
|
2265
|
+
noteView.classList.remove("is-open", "is-preparing", "is-hub");
|
|
2030
2266
|
noteView.hidden = true;
|
|
2031
2267
|
noteView.style.opacity = "";
|
|
2032
2268
|
expandedId = null;
|
|
2033
2269
|
openFromExpanded = false;
|
|
2034
2270
|
unparkShelf();
|
|
2035
2271
|
renderShelf();
|
|
2272
|
+
releaseHubUrl();
|
|
2036
2273
|
clearGhost(
|
|
2037
2274
|
targetMode === "covers"
|
|
2038
2275
|
? $(`.cover[data-book-id="${book.id}"]`)
|
|
@@ -2069,47 +2306,36 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2069
2306
|
stashEarly.Tp2 &&
|
|
2070
2307
|
stashEarly.Tp3
|
|
2071
2308
|
) {
|
|
2309
|
+
// Hide the spread on the first frame. Shrinking starts now, but the
|
|
2310
|
+
// open pages must not sit on top of the note.
|
|
2072
2311
|
heldEarly.style.visibility = "visible";
|
|
2073
2312
|
heldEarly.style.zIndex = "70";
|
|
2074
|
-
heldEarly.style.opacity = "
|
|
2313
|
+
heldEarly.style.opacity = "0";
|
|
2075
2314
|
if (heldEarly._board) {
|
|
2076
2315
|
heldEarly._board.style.opacity = "0";
|
|
2077
2316
|
heldEarly._board.style.visibility = "hidden";
|
|
2078
2317
|
}
|
|
2318
|
+
if (heldEarly._right) heldEarly._right.style.opacity = "0";
|
|
2319
|
+
if (heldEarly._left) heldEarly._left.style.transform = "rotateY(180deg)";
|
|
2320
|
+
if (heldEarly._leftThick) heldEarly._leftThick.style.opacity = "0";
|
|
2321
|
+
if (heldEarly._rightThick) heldEarly._rightThick.style.opacity = "0";
|
|
2322
|
+
if (heldEarly._gutter) heldEarly._gutter.style.opacity = "0";
|
|
2079
2323
|
promote(heldEarly);
|
|
2080
2324
|
earlyMove = heldEarly.animate(
|
|
2081
2325
|
returnMoveKeys(stashEarly.Tfull, stashEarly.Tp1, stashEarly.T0),
|
|
2082
2326
|
closeMotion
|
|
2083
2327
|
);
|
|
2084
2328
|
earlyNote = noteView.animate(noteOutKeys(), closeOpts);
|
|
2085
|
-
// Backup if the fade is dropped: the article must not still be up
|
|
2086
|
-
wait(Math.round(CLOSE_MS * 0.
|
|
2329
|
+
// Backup if the fade is dropped: the article must not still be up.
|
|
2330
|
+
wait(Math.round(CLOSE_MS * 0.05)).then(() => {
|
|
2087
2331
|
if (myFlow !== flowSeq || !noteView) return;
|
|
2088
2332
|
noteView.style.opacity = "0";
|
|
2089
2333
|
});
|
|
2090
2334
|
if (heldEarly._left) {
|
|
2091
|
-
earlyLeft = heldEarly._left.animate(
|
|
2092
|
-
[
|
|
2093
|
-
{ offset: 0, transform: "rotateY(8deg)" },
|
|
2094
|
-
{ offset: 0.08, transform: "rotateY(20deg)" },
|
|
2095
|
-
{ offset: 0.16, transform: "rotateY(70deg)" },
|
|
2096
|
-
{ offset: 0.24, transform: "rotateY(140deg)" },
|
|
2097
|
-
{ offset: 0.32, transform: "rotateY(180deg)" },
|
|
2098
|
-
{ offset: 1.0, transform: "rotateY(180deg)" },
|
|
2099
|
-
],
|
|
2100
|
-
closeMotion
|
|
2101
|
-
);
|
|
2335
|
+
earlyLeft = heldEarly._left.animate(closedReturnLeftKeys(), closeMotion);
|
|
2102
2336
|
}
|
|
2103
2337
|
if (heldEarly._right) {
|
|
2104
|
-
earlyRight = heldEarly._right.animate(
|
|
2105
|
-
[
|
|
2106
|
-
{ offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
|
|
2107
|
-
{ offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
|
|
2108
|
-
{ offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
|
|
2109
|
-
{ offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
|
|
2110
|
-
],
|
|
2111
|
-
closeMotion
|
|
2112
|
-
);
|
|
2338
|
+
earlyRight = heldEarly._right.animate(closedReturnRightKeys(), closeMotion);
|
|
2113
2339
|
}
|
|
2114
2340
|
}
|
|
2115
2341
|
|
|
@@ -2141,9 +2367,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2141
2367
|
try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
|
|
2142
2368
|
try { earlyRight && earlyRight.cancel(); } catch (_) {}
|
|
2143
2369
|
root.classList.remove("is-return");
|
|
2144
|
-
noteView.classList.remove("is-open", "is-preparing");
|
|
2370
|
+
noteView.classList.remove("is-open", "is-preparing", "is-hub");
|
|
2145
2371
|
noteView.hidden = true;
|
|
2146
2372
|
noteView.style.opacity = "";
|
|
2373
|
+
releaseHubUrl();
|
|
2147
2374
|
openBook = null;
|
|
2148
2375
|
busy = false;
|
|
2149
2376
|
document.body.classList.remove("is-busy");
|
|
@@ -2297,19 +2524,20 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2297
2524
|
}
|
|
2298
2525
|
}
|
|
2299
2526
|
}
|
|
2300
|
-
//
|
|
2301
|
-
|
|
2527
|
+
// Collapse to one jacket before the flyer is allowed to paint. An open
|
|
2528
|
+
// spread here is the full-screen double exposure over the note.
|
|
2529
|
+
if (flyer._left) flyer._left.style.transform = "rotateY(180deg)";
|
|
2302
2530
|
if (flyer._right) {
|
|
2303
|
-
flyer._right.style.transform = "rotateY(
|
|
2304
|
-
flyer._right.style.opacity = "
|
|
2531
|
+
flyer._right.style.transform = "rotateY(0deg)";
|
|
2532
|
+
flyer._right.style.opacity = "0";
|
|
2305
2533
|
}
|
|
2306
2534
|
if (flyer._board) {
|
|
2307
2535
|
flyer._board.style.opacity = "0";
|
|
2308
2536
|
flyer._board.style.visibility = "hidden";
|
|
2309
2537
|
}
|
|
2310
|
-
if (flyer._leftThick) flyer._leftThick.style.opacity = "
|
|
2311
|
-
if (flyer._rightThick) flyer._rightThick.style.opacity = "
|
|
2312
|
-
if (flyer._gutter) flyer._gutter.style.opacity = "
|
|
2538
|
+
if (flyer._leftThick) flyer._leftThick.style.opacity = "0";
|
|
2539
|
+
if (flyer._rightThick) flyer._rightThick.style.opacity = "0";
|
|
2540
|
+
if (flyer._gutter) flyer._gutter.style.opacity = "0";
|
|
2313
2541
|
const closeSpineLabel = flyer._cover && flyer._cover.querySelector(".flyer-spine-label");
|
|
2314
2542
|
if (closeSpineLabel) closeSpineLabel.style.opacity = "0";
|
|
2315
2543
|
// ux23: spines may swap to img-only; covers keep applyCoverImage bg under img
|
|
@@ -2357,7 +2585,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2357
2585
|
// Start the reverse on this frame. A pre-clock rAF left the spread parked at
|
|
2358
2586
|
// 672×504 while the note was still up (the ~240ms close-start hitch).
|
|
2359
2587
|
void readyPulse;
|
|
2360
|
-
if (!earlyMove) flyer.style.opacity = "
|
|
2588
|
+
if (!earlyMove) flyer.style.opacity = "0";
|
|
2361
2589
|
|
|
2362
2590
|
// ux14: TIME-REVERSE of open pathTs — ban separately computed return / end-segment seek.
|
|
2363
2591
|
const PUT_MS = 380;
|
|
@@ -2380,33 +2608,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2380
2608
|
);
|
|
2381
2609
|
// Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
|
|
2382
2610
|
const aLeftClose = earlyLeft || (flyer._left
|
|
2383
|
-
? flyer._left.animate(
|
|
2384
|
-
[
|
|
2385
|
-
{ offset: 0, transform: "rotateY(8deg)" },
|
|
2386
|
-
{ offset: 0.08, transform: "rotateY(20deg)" },
|
|
2387
|
-
{ offset: 0.16, transform: "rotateY(70deg)" },
|
|
2388
|
-
{ offset: 0.24, transform: "rotateY(140deg)" },
|
|
2389
|
-
{ offset: 0.32, transform: "rotateY(180deg)" },
|
|
2390
|
-
{ offset: 1.0, transform: "rotateY(180deg)" },
|
|
2391
|
-
],
|
|
2392
|
-
closeMotion
|
|
2393
|
-
)
|
|
2611
|
+
? flyer._left.animate(closedReturnLeftKeys(), closeMotion)
|
|
2394
2612
|
: null);
|
|
2395
2613
|
const aRightClose = earlyRight || (flyer._right
|
|
2396
|
-
? flyer._right.animate(
|
|
2397
|
-
[
|
|
2398
|
-
{ offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
|
|
2399
|
-
{ offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
|
|
2400
|
-
{ offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
|
|
2401
|
-
{ offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
|
|
2402
|
-
],
|
|
2403
|
-
closeMotion
|
|
2404
|
-
)
|
|
2614
|
+
? flyer._right.animate(closedReturnRightKeys(), closeMotion)
|
|
2405
2615
|
: null);
|
|
2406
2616
|
const closedFaceOut = [
|
|
2407
|
-
{ offset: 0, opacity:
|
|
2408
|
-
{ offset: 0.18, opacity: 0.7 },
|
|
2409
|
-
{ offset: 0.28, opacity: 0 },
|
|
2617
|
+
{ offset: 0, opacity: 0 },
|
|
2410
2618
|
{ offset: 1.0, opacity: 0 },
|
|
2411
2619
|
];
|
|
2412
2620
|
const aBoardClose = null;
|
|
@@ -2415,8 +2623,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2415
2623
|
const aGutterClose = flyer._gutter
|
|
2416
2624
|
? flyer._gutter.animate(
|
|
2417
2625
|
[
|
|
2418
|
-
{ offset: 0, opacity:
|
|
2419
|
-
{ offset: 0.24, opacity: 0 },
|
|
2626
|
+
{ offset: 0, opacity: 0 },
|
|
2420
2627
|
{ offset: 1.0, opacity: 0 },
|
|
2421
2628
|
],
|
|
2422
2629
|
closeOpts
|
|
@@ -2450,15 +2657,19 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2450
2657
|
const aFlyerFade = flyer.animate(
|
|
2451
2658
|
targetMode === "spines"
|
|
2452
2659
|
? [
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
2455
|
-
{ offset: 0, opacity:
|
|
2456
|
-
{ offset: 0.
|
|
2457
|
-
{ offset: 0.
|
|
2660
|
+
// Hidden while the note is still up, then the collapsed jacket
|
|
2661
|
+
// flies home. Do not hold opacity 1 at the full spread.
|
|
2662
|
+
{ offset: 0, opacity: 0 },
|
|
2663
|
+
{ offset: 0.06, opacity: 0 },
|
|
2664
|
+
{ offset: 0.12, opacity: 1 },
|
|
2665
|
+
{ offset: 0.72, opacity: 1 },
|
|
2666
|
+
{ offset: 0.84, opacity: 0 },
|
|
2458
2667
|
{ offset: 1.0, opacity: 0 },
|
|
2459
2668
|
]
|
|
2460
2669
|
: [
|
|
2461
|
-
{ offset: 0, opacity:
|
|
2670
|
+
{ offset: 0, opacity: 0 },
|
|
2671
|
+
{ offset: 0.06, opacity: 0 },
|
|
2672
|
+
{ offset: 0.12, opacity: 1 },
|
|
2462
2673
|
{ offset: flyerFadeAt, opacity: 1 },
|
|
2463
2674
|
{ offset: 1.0, opacity: 0 },
|
|
2464
2675
|
],
|
|
@@ -2503,13 +2714,17 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2503
2714
|
? coverImg.animate(
|
|
2504
2715
|
targetMode === "spines"
|
|
2505
2716
|
? [
|
|
2506
|
-
{ offset: 0, opacity:
|
|
2507
|
-
{ offset: 0.
|
|
2508
|
-
{ offset: 0.
|
|
2717
|
+
{ offset: 0, opacity: 0 },
|
|
2718
|
+
{ offset: 0.06, opacity: 0 },
|
|
2719
|
+
{ offset: 0.12, opacity: 1 },
|
|
2720
|
+
{ offset: 0.72, opacity: 1 },
|
|
2721
|
+
{ offset: 0.84, opacity: 0 },
|
|
2509
2722
|
{ offset: 1, opacity: 0 },
|
|
2510
2723
|
]
|
|
2511
2724
|
: [
|
|
2512
|
-
{ offset: 0, opacity:
|
|
2725
|
+
{ offset: 0, opacity: 0 },
|
|
2726
|
+
{ offset: 0.06, opacity: 0 },
|
|
2727
|
+
{ offset: 0.12, opacity: 1 },
|
|
2513
2728
|
{ offset: flyerFadeAt, opacity: 1 },
|
|
2514
2729
|
{ offset: 1, opacity: 0 },
|
|
2515
2730
|
],
|
|
@@ -2593,7 +2808,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2593
2808
|
slot.style.opacity = "1";
|
|
2594
2809
|
slot.style.transform = "none";
|
|
2595
2810
|
root.classList.remove("is-return");
|
|
2596
|
-
noteView.classList.remove("is-open", "is-preparing");
|
|
2811
|
+
noteView.classList.remove("is-open", "is-preparing", "is-hub");
|
|
2597
2812
|
noteView.hidden = true;
|
|
2598
2813
|
noteView.style.opacity = "";
|
|
2599
2814
|
noteView.style.zIndex = "";
|
|
@@ -2664,6 +2879,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2664
2879
|
});
|
|
2665
2880
|
try { shelfView.style.removeProperty("min-height"); } catch (_) {}
|
|
2666
2881
|
shelfView.style.opacity = "";
|
|
2882
|
+
releaseHubUrl();
|
|
2667
2883
|
openBook = null;
|
|
2668
2884
|
openFromExpanded = false;
|
|
2669
2885
|
releaseScrollLock();
|
|
@@ -2702,6 +2918,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2702
2918
|
|
|
2703
2919
|
listen($("#backBtn"), "click", () => closeBookFlow());
|
|
2704
2920
|
|
|
2921
|
+
listen(window, "popstate", () => {
|
|
2922
|
+
if (!openBook || !openBook.hubMode || busy || !openBook.book) return;
|
|
2923
|
+
if (samePath(location.pathname, openBook.book.hub)) return;
|
|
2924
|
+
openBook.hubEntry = "history";
|
|
2925
|
+
closeBookFlow();
|
|
2926
|
+
});
|
|
2927
|
+
|
|
2705
2928
|
// Browser Back from the full note restores this page from bfcache, still open.
|
|
2706
2929
|
// Land on the shelf at the scroll we saved, without replaying the close clock.
|
|
2707
2930
|
listen(window, "pageshow", (event) => {
|
|
@@ -2717,7 +2940,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2717
2940
|
busy = false;
|
|
2718
2941
|
flowSeq += 1;
|
|
2719
2942
|
document.body.classList.remove("is-busy");
|
|
2720
|
-
noteView.classList.remove("is-open", "is-preparing");
|
|
2943
|
+
noteView.classList.remove("is-open", "is-preparing", "is-hub");
|
|
2721
2944
|
noteView.hidden = true;
|
|
2722
2945
|
noteView.style.opacity = "";
|
|
2723
2946
|
noteView.style.zIndex = "";
|
|
@@ -2737,7 +2960,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2737
2960
|
listen(root, "click", (event) => {
|
|
2738
2961
|
const target = event.target;
|
|
2739
2962
|
if (!target || !target.closest) return;
|
|
2740
|
-
const link = target.closest("a.flyer-page-more, a.note-read");
|
|
2963
|
+
const link = target.closest("a.flyer-page-more, a.note-read, a.book-hub-note");
|
|
2741
2964
|
if (!link || !root.contains(link)) return;
|
|
2742
2965
|
const y = scrollLock ? scrollLock.y : readScroll().y;
|
|
2743
2966
|
try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
|
|
@@ -2755,12 +2978,73 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2755
2978
|
/* intentionally empty — enter-time density + face sizes stay put */
|
|
2756
2979
|
});
|
|
2757
2980
|
|
|
2981
|
+
// Direct /bookshelf/<id>: hub is already the page. Shelf sits underneath so
|
|
2982
|
+
// 返回书架 can play the return flyer. No extract — there was no click.
|
|
2983
|
+
function presentHubImmediate(book) {
|
|
2984
|
+
const found = bookById(book.id);
|
|
2985
|
+
const cat = found ? found.cat : { name: "" };
|
|
2986
|
+
const slot = $(`.spine[data-book-id="${book.id}"]`) || $(`.cover[data-book-id="${book.id}"]`);
|
|
2987
|
+
if (!slot) return;
|
|
2988
|
+
fillHub(book);
|
|
2989
|
+
noteView.hidden = false;
|
|
2990
|
+
noteView.classList.add("is-open", "is-hub");
|
|
2991
|
+
noteView.classList.remove("is-preparing");
|
|
2992
|
+
noteView.style.opacity = "1";
|
|
2993
|
+
const homeRect = copyRect(rectOf(slot));
|
|
2994
|
+
const mid = midRect(book);
|
|
2995
|
+
const wp = buildSlotWaypoints(homeRect, mid, book);
|
|
2996
|
+
const startAsSpine = slot.classList.contains("spine");
|
|
2997
|
+
const flyer = makeFlyer(book, homeRect, { spineShape: false, spreadOpen: true });
|
|
2998
|
+
flyer.style.opacity = "0";
|
|
2999
|
+
if (flyer._left) flyer._left.style.transform = "rotateY(180deg)";
|
|
3000
|
+
if (flyer._right) flyer._right.style.opacity = "0";
|
|
3001
|
+
if (flyer._board) {
|
|
3002
|
+
flyer._board.style.opacity = "0";
|
|
3003
|
+
flyer._board.style.visibility = "hidden";
|
|
3004
|
+
}
|
|
3005
|
+
slot.classList.add("is-placeholder");
|
|
3006
|
+
slot.style.opacity = "0";
|
|
3007
|
+
openBook = {
|
|
3008
|
+
book,
|
|
3009
|
+
cat,
|
|
3010
|
+
asSpine: startAsSpine,
|
|
3011
|
+
fromExpanded: false,
|
|
3012
|
+
hubMode: true,
|
|
3013
|
+
hubEntry: "direct",
|
|
3014
|
+
openSeatRect: { ...homeRect },
|
|
3015
|
+
homeRect: { ...homeRect },
|
|
3016
|
+
homeMode: startAsSpine ? "spines" : "covers",
|
|
3017
|
+
pathMid: copyRect(mid),
|
|
3018
|
+
pathWp: wp,
|
|
3019
|
+
pathTs: {
|
|
3020
|
+
T0: wp.T0,
|
|
3021
|
+
Tp1: wp.Tp1,
|
|
3022
|
+
Tp2: wp.Tp2,
|
|
3023
|
+
Tp3: wp.Tp3,
|
|
3024
|
+
TdualClosed: wp.Tp2,
|
|
3025
|
+
Tgrow: wp.Tp3,
|
|
3026
|
+
Tnear: lerpTS(wp.Tp3, wp.Tfull, 0.5),
|
|
3027
|
+
Tfull: wp.Tfull,
|
|
3028
|
+
},
|
|
3029
|
+
spineHome: copyRect(homeRect),
|
|
3030
|
+
heldFlyer: flyer,
|
|
3031
|
+
};
|
|
3032
|
+
if (startAsSpine) openBook.spineOut = spineSlotOutDelta(homeRect, mid);
|
|
3033
|
+
parkShelf();
|
|
3034
|
+
rememberScrollHome();
|
|
3035
|
+
armScrollLock();
|
|
3036
|
+
}
|
|
3037
|
+
|
|
2758
3038
|
// init
|
|
2759
3039
|
const q = parseQuery();
|
|
2760
3040
|
if (q) modeOverride = q;
|
|
2761
3041
|
enableDebugChrome();
|
|
2762
3042
|
applyModePreference();
|
|
2763
3043
|
preloadCovers();
|
|
3044
|
+
if (initialHub) {
|
|
3045
|
+
const found = bookById(initialHub);
|
|
3046
|
+
if (found && isHubBook(found.book)) presentHubImmediate(found.book);
|
|
3047
|
+
}
|
|
2764
3048
|
|
|
2765
3049
|
return () => {
|
|
2766
3050
|
for (const fn of unlisten) fn();
|
|
@@ -59,16 +59,16 @@ export type BookshelfFileSource = {
|
|
|
59
59
|
* the same path as `posts[0]`. Otherwise the shelf is rejected.
|
|
60
60
|
* - one note: the shelf opens that note as it does today (extract flyer, note,
|
|
61
61
|
* return flyer). There is no book hub.
|
|
62
|
-
* - two or more notes: a spine click
|
|
63
|
-
* (本书). It does not open `posts[0]
|
|
62
|
+
* - two or more notes: a spine click plays the same extract flyer and lands
|
|
63
|
+
* on `/bookshelf/<id>` (本书). It does not open `posts[0]`.
|
|
64
64
|
* The hub card is the locked book-multi layout: the jacket slot is 148px
|
|
65
65
|
* wide, the image fills that width, and the height follows the file's ratio.
|
|
66
66
|
* There is no plate behind the jacket. A Douban scan's white border is
|
|
67
67
|
* cropped in the jacket file before it is committed; the build copies that
|
|
68
68
|
* file into `/media/covers/` unchanged.
|
|
69
69
|
* 「我的笔记」 lists the notes. A note card is an ordinary link to that
|
|
70
|
-
* article; article ↔ list does not play the flyer.
|
|
71
|
-
*
|
|
70
|
+
* article; article ↔ list does not play the flyer. Returning from the hub
|
|
71
|
+
* to the shelf plays the close flyer. A single note uses that same flyer.
|
|
72
72
|
* - `sources` are optional `{ label, href }` chips. The hub shows 豆瓣 and PDF
|
|
73
73
|
* only. The PDF chip carries the caption 「(仅分享,侵权删)」. No chapter list.
|
|
74
74
|
* - `note` is Markdown used only when the book has no posts.
|
|
@@ -86,4 +86,9 @@ export type BookshelfProps = {
|
|
|
86
86
|
* unset and keeps its own full-viewport frame.
|
|
87
87
|
*/
|
|
88
88
|
embedded?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Open `/bookshelf/<id>` already on the hub, with the shelf parked under it
|
|
91
|
+
* so the return flyer can run. A shelf click does not use this.
|
|
92
|
+
*/
|
|
93
|
+
initialHub?: string;
|
|
89
94
|
};
|
package/docs/handbook/config.md
CHANGED
|
@@ -130,7 +130,7 @@ columns:
|
|
|
130
130
|
|
|
131
131
|
`/bookshelf` 在站点写了 `content/bookshelf.yaml` 时渲染这里的书,并带上站点顶栏,和其他页面一样可以被索引。没有这个文件时,`/bookshelf` 只在引擎包内的中文验收里显示内置 demo(无顶栏、不收录);正式站要真书目,就放下这份文件。
|
|
132
132
|
|
|
133
|
-
架上每一条 `books` 是一本书。`post: note/ddia` 是只有一篇笔记时的简写。一本书有多篇笔记时写 `posts
|
|
133
|
+
架上每一条 `books` 是一本书。`post: note/ddia` 是只有一篇笔记时的简写。一本书有多篇笔记时写 `posts`,点书脊播放抽书动画,落到 `/bookshelf/<id>`(本书),不打开第一篇。从本书页回到书架播放合上动画。本书页左边是书衣(宽 148,高度跟图片走),右边是书名、作者,以及豆瓣和 PDF 两枚出处胶囊;PDF 后面有一行「(仅分享,侵权删)」。下面是「我的笔记」,点进去才是文章,这一步没有飞页。单篇笔记仍走原来的抽书和合上。
|
|
134
134
|
|
|
135
135
|
`cover` 是书衣,只给书架、抽书飞页和本书页用。文件放在 `content/covers/`,构建时会复制到 `/media/covers/`,即使没有任何博文的 front matter 引用这个文件名。豆瓣封面四周的白边在放进 `content/covers/` 之前裁好,构建不会再改像素。笔记页的横幅仍是各篇 front matter 的 `cover`,不要把那张图填进书衣。yaml 不写 `cover` 时,书衣才沿用第一篇的封面。`color`(`#rgb` 或 `#rrggbb`)画书脊和单篇打开后的纸页。
|
|
136
136
|
|
|
@@ -130,7 +130,7 @@ columns:
|
|
|
130
130
|
|
|
131
131
|
When a site has `content/bookshelf.yaml`, `/bookshelf` renders those books inside the site header and is indexed with the other pages. Without that file, `/bookshelf` is only the package's built-in demo (Chinese click-test, no header, not indexed). A production site supplies the file.
|
|
132
132
|
|
|
133
|
-
Each `books` entry is one volume. `post: note/ddia` is the one-note shorthand. Several notes use `posts`. Clicking that spine
|
|
133
|
+
Each `books` entry is one volume. `post: note/ddia` is the one-note shorthand. Several notes use `posts`. Clicking that spine plays the extract animation and lands on `/bookshelf/<id>`. It does not open the first note. Returning from that page to the shelf plays the close animation. The book page shows a 148px-wide jacket (height follows the image), the title, the author, and only the Douban and PDF chips. The PDF chip is followed by a short sharing note. My notes lists the notes; each card is a normal link to the article and does not play the flyer. A single note still uses the extract and return animation.
|
|
134
134
|
|
|
135
135
|
`cover` is the jacket for the shelf, the flyer, and the book page. The file lives in `content/covers/` and the build copies it to `/media/covers/`, even when no post front matter names that file. Crop a Douban scan's white margin before committing the file; the build does not change pixels. A note's hero stays that post's front matter `cover`. Omit the jacket and it falls back to the first note's cover. `color` (`#rgb` or `#rrggbb`) is the spine and the single-note open page.
|
|
136
136
|
|