hd-garden 0.9.2 → 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 +72 -0
- package/app/bookshelf/load.mjs +24 -6
- package/app/components/bookshelf/BookHub.tsx +142 -0
- package/app/components/bookshelf/Bookshelf.tsx +30 -6
- package/app/components/bookshelf/bookshelf.css +78 -60
- package/app/components/bookshelf/catalog.mjs +74 -14
- package/app/components/bookshelf/catalog.ts +4 -0
- package/app/components/bookshelf/engine.ts +406 -109
- package/app/components/bookshelf/index.ts +3 -0
- package/app/components/bookshelf/schema.mjs +70 -1
- package/app/components/bookshelf/schema.ts +52 -10
- package/app/components/bookshelf/types.ts +42 -3
- package/app/lib/i18n.ts +10 -0
- package/app/sitemap.ts +8 -0
- package/app/styles/shell.css +37 -0
- package/docs/handbook/config.md +23 -5
- package/docs/handbook/en/config.md +23 -5
- package/package.json +1 -1
- package/scripts/bookshelf-covers.mjs +62 -0
- package/scripts/build-content.mjs +15 -1
|
@@ -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,18 +727,56 @@ export function mountBookshelf(root, options = {}) {
|
|
|
677
727
|
collapseToken += 1;
|
|
678
728
|
}
|
|
679
729
|
|
|
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) {
|
|
733
|
+
const posts = book && Array.isArray(book.posts) ? book.posts : [];
|
|
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 (_) {}
|
|
762
|
+
}
|
|
763
|
+
|
|
680
764
|
async function onCoverClick(id, el) {
|
|
681
765
|
if (busy) return;
|
|
682
766
|
const found = bookById(id);
|
|
683
767
|
if (!found || found.book.filler) return;
|
|
684
|
-
await openBookFlow(found.book, found.cat, el, {
|
|
768
|
+
await openBookFlow(found.book, found.cat, el, {
|
|
769
|
+
fromExpanded: false,
|
|
770
|
+
asSpine: false,
|
|
771
|
+
hub: isHubBook(found.book),
|
|
772
|
+
});
|
|
685
773
|
}
|
|
686
774
|
|
|
687
775
|
async function onSpineClick(id, el, ev) {
|
|
688
776
|
if (busy) return;
|
|
689
777
|
const found = bookById(id);
|
|
690
|
-
if (!found) return;
|
|
691
|
-
if (
|
|
778
|
+
if (!found || found.book.filler) return;
|
|
779
|
+
if (!found.book.cover) return;
|
|
692
780
|
|
|
693
781
|
const isTouch = ev && (ev.pointerType === "touch" || ("ontouchstart" in window && window.matchMedia("(hover: none)").matches));
|
|
694
782
|
|
|
@@ -696,7 +784,11 @@ export function mountBookshelf(root, options = {}) {
|
|
|
696
784
|
if (isTouch || window.matchMedia("(hover: none)").matches) {
|
|
697
785
|
touchExpandedId = null;
|
|
698
786
|
if (expandedId) await collapseSpine(expandedId);
|
|
699
|
-
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
|
+
});
|
|
700
792
|
return;
|
|
701
793
|
}
|
|
702
794
|
|
|
@@ -704,10 +796,105 @@ export function mountBookshelf(root, options = {}) {
|
|
|
704
796
|
const fromExpanded =
|
|
705
797
|
(expandedId === id && el.classList.contains("is-expanded")) ||
|
|
706
798
|
el.classList.contains("is-collapsing");
|
|
707
|
-
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
|
+
}
|
|
708
894
|
}
|
|
709
895
|
|
|
710
896
|
function fillNote(book, cat) {
|
|
897
|
+
showNoteChrome();
|
|
711
898
|
$("#noteCat").textContent = cat.name;
|
|
712
899
|
$("#noteTitle").textContent = book.title;
|
|
713
900
|
$("#noteAuthor").textContent = book.author || "";
|
|
@@ -879,10 +1066,23 @@ export function mountBookshelf(root, options = {}) {
|
|
|
879
1066
|
el.style.opacity = String(opacity);
|
|
880
1067
|
el.style.zIndex = "80";
|
|
881
1068
|
el.style.pointerEvents = "none";
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
+
}
|
|
886
1086
|
stage.appendChild(el);
|
|
887
1087
|
return el;
|
|
888
1088
|
}
|
|
@@ -1034,16 +1234,31 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1034
1234
|
];
|
|
1035
1235
|
}
|
|
1036
1236
|
|
|
1037
|
-
//
|
|
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.
|
|
1038
1240
|
function noteOutKeys() {
|
|
1039
1241
|
return [
|
|
1040
1242
|
{ offset: 0, opacity: 1 },
|
|
1041
|
-
{ offset: 0.
|
|
1042
|
-
{ offset: 0.12, opacity: 0 },
|
|
1243
|
+
{ offset: 0.02, opacity: 0 },
|
|
1043
1244
|
{ offset: 1, opacity: 0 },
|
|
1044
1245
|
];
|
|
1045
1246
|
}
|
|
1046
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
|
+
|
|
1047
1262
|
// ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
|
|
1048
1263
|
// X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
|
|
1049
1264
|
// Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
|
|
@@ -1354,7 +1569,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1354
1569
|
"is-expanded",
|
|
1355
1570
|
"is-collapsing"
|
|
1356
1571
|
);
|
|
1357
|
-
clearSeatCover(el);
|
|
1572
|
+
if (!(book && realCover(book))) clearSeatCover(el);
|
|
1358
1573
|
// Explicit removeProperty — kills commitStyles leftovers like opacity:0 / transform
|
|
1359
1574
|
[
|
|
1360
1575
|
"width",
|
|
@@ -1378,6 +1593,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1378
1593
|
applyFaceSize(el, book);
|
|
1379
1594
|
} else if (book && el.classList.contains("spine") && book.cover && !book.filler) {
|
|
1380
1595
|
applyFaceSize(el, book);
|
|
1596
|
+
if (realCover(book)) {
|
|
1597
|
+
el.classList.add("has-jacket");
|
|
1598
|
+
mountSeatCover(el, book);
|
|
1599
|
+
}
|
|
1381
1600
|
}
|
|
1382
1601
|
}
|
|
1383
1602
|
|
|
@@ -1547,6 +1766,24 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1547
1766
|
}
|
|
1548
1767
|
}
|
|
1549
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
|
+
|
|
1550
1787
|
async function prepareNote(book, cat) {
|
|
1551
1788
|
fillNote(book, cat);
|
|
1552
1789
|
noteView.hidden = false;
|
|
@@ -1597,13 +1834,22 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1597
1834
|
restoreSlot(el);
|
|
1598
1835
|
}
|
|
1599
1836
|
|
|
1600
|
-
async function openBookFlow(book, cat, sourceEl, { fromExpanded, asSpine }) {
|
|
1837
|
+
async function openBookFlow(book, cat, sourceEl, { fromExpanded, asSpine, hub }) {
|
|
1601
1838
|
if (busy) return;
|
|
1602
1839
|
busy = true;
|
|
1603
1840
|
const myFlow = ++flowSeq;
|
|
1604
1841
|
document.body.classList.add("is-busy");
|
|
1605
1842
|
root.classList.remove("is-return");
|
|
1606
|
-
|
|
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
|
+
};
|
|
1607
1853
|
openFromExpanded = !!fromExpanded;
|
|
1608
1854
|
|
|
1609
1855
|
// ux6b: freeze window scroll to idle/expand home (face-out stuck ~197px up).
|
|
@@ -1616,7 +1862,8 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1616
1862
|
} catch (_) {}
|
|
1617
1863
|
|
|
1618
1864
|
try {
|
|
1619
|
-
await
|
|
1865
|
+
if (hubMode) await prepareHub(book);
|
|
1866
|
+
else await prepareNote(book, cat);
|
|
1620
1867
|
} catch (_) {
|
|
1621
1868
|
// Decode/prepare failed — still open; never leave is-busy hung.
|
|
1622
1869
|
}
|
|
@@ -1631,6 +1878,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1631
1878
|
noteView.classList.add("is-open");
|
|
1632
1879
|
noteView.style.opacity = "1";
|
|
1633
1880
|
parkShelf();
|
|
1881
|
+
rememberHubUrl();
|
|
1634
1882
|
releaseScrollLock();
|
|
1635
1883
|
busy = false;
|
|
1636
1884
|
document.body.classList.remove("is-busy");
|
|
@@ -1976,6 +2224,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1976
2224
|
unlockNoteClicks();
|
|
1977
2225
|
holdFlyerUnderNote();
|
|
1978
2226
|
parkShelf();
|
|
2227
|
+
rememberHubUrl();
|
|
1979
2228
|
restoreSlot(sourceEl, book);
|
|
1980
2229
|
sourceEl.classList.remove("is-extracting");
|
|
1981
2230
|
// Keep sourceEl as invisible reserved seat while note open (no gray plate).
|
|
@@ -2013,13 +2262,14 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2013
2262
|
const targetMode = pageMode;
|
|
2014
2263
|
|
|
2015
2264
|
if (reduceMotion()) {
|
|
2016
|
-
noteView.classList.remove("is-open", "is-preparing");
|
|
2265
|
+
noteView.classList.remove("is-open", "is-preparing", "is-hub");
|
|
2017
2266
|
noteView.hidden = true;
|
|
2018
2267
|
noteView.style.opacity = "";
|
|
2019
2268
|
expandedId = null;
|
|
2020
2269
|
openFromExpanded = false;
|
|
2021
2270
|
unparkShelf();
|
|
2022
2271
|
renderShelf();
|
|
2272
|
+
releaseHubUrl();
|
|
2023
2273
|
clearGhost(
|
|
2024
2274
|
targetMode === "covers"
|
|
2025
2275
|
? $(`.cover[data-book-id="${book.id}"]`)
|
|
@@ -2056,47 +2306,36 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2056
2306
|
stashEarly.Tp2 &&
|
|
2057
2307
|
stashEarly.Tp3
|
|
2058
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.
|
|
2059
2311
|
heldEarly.style.visibility = "visible";
|
|
2060
2312
|
heldEarly.style.zIndex = "70";
|
|
2061
|
-
heldEarly.style.opacity = "
|
|
2313
|
+
heldEarly.style.opacity = "0";
|
|
2062
2314
|
if (heldEarly._board) {
|
|
2063
2315
|
heldEarly._board.style.opacity = "0";
|
|
2064
2316
|
heldEarly._board.style.visibility = "hidden";
|
|
2065
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";
|
|
2066
2323
|
promote(heldEarly);
|
|
2067
2324
|
earlyMove = heldEarly.animate(
|
|
2068
2325
|
returnMoveKeys(stashEarly.Tfull, stashEarly.Tp1, stashEarly.T0),
|
|
2069
2326
|
closeMotion
|
|
2070
2327
|
);
|
|
2071
2328
|
earlyNote = noteView.animate(noteOutKeys(), closeOpts);
|
|
2072
|
-
// Backup if the fade is dropped: the article must not still be up
|
|
2073
|
-
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(() => {
|
|
2074
2331
|
if (myFlow !== flowSeq || !noteView) return;
|
|
2075
2332
|
noteView.style.opacity = "0";
|
|
2076
2333
|
});
|
|
2077
2334
|
if (heldEarly._left) {
|
|
2078
|
-
earlyLeft = heldEarly._left.animate(
|
|
2079
|
-
[
|
|
2080
|
-
{ offset: 0, transform: "rotateY(8deg)" },
|
|
2081
|
-
{ offset: 0.08, transform: "rotateY(20deg)" },
|
|
2082
|
-
{ offset: 0.16, transform: "rotateY(70deg)" },
|
|
2083
|
-
{ offset: 0.24, transform: "rotateY(140deg)" },
|
|
2084
|
-
{ offset: 0.32, transform: "rotateY(180deg)" },
|
|
2085
|
-
{ offset: 1.0, transform: "rotateY(180deg)" },
|
|
2086
|
-
],
|
|
2087
|
-
closeMotion
|
|
2088
|
-
);
|
|
2335
|
+
earlyLeft = heldEarly._left.animate(closedReturnLeftKeys(), closeMotion);
|
|
2089
2336
|
}
|
|
2090
2337
|
if (heldEarly._right) {
|
|
2091
|
-
earlyRight = heldEarly._right.animate(
|
|
2092
|
-
[
|
|
2093
|
-
{ offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
|
|
2094
|
-
{ offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
|
|
2095
|
-
{ offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
|
|
2096
|
-
{ offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
|
|
2097
|
-
],
|
|
2098
|
-
closeMotion
|
|
2099
|
-
);
|
|
2338
|
+
earlyRight = heldEarly._right.animate(closedReturnRightKeys(), closeMotion);
|
|
2100
2339
|
}
|
|
2101
2340
|
}
|
|
2102
2341
|
|
|
@@ -2128,9 +2367,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2128
2367
|
try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
|
|
2129
2368
|
try { earlyRight && earlyRight.cancel(); } catch (_) {}
|
|
2130
2369
|
root.classList.remove("is-return");
|
|
2131
|
-
noteView.classList.remove("is-open", "is-preparing");
|
|
2370
|
+
noteView.classList.remove("is-open", "is-preparing", "is-hub");
|
|
2132
2371
|
noteView.hidden = true;
|
|
2133
2372
|
noteView.style.opacity = "";
|
|
2373
|
+
releaseHubUrl();
|
|
2134
2374
|
openBook = null;
|
|
2135
2375
|
busy = false;
|
|
2136
2376
|
document.body.classList.remove("is-busy");
|
|
@@ -2284,19 +2524,20 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2284
2524
|
}
|
|
2285
2525
|
}
|
|
2286
2526
|
}
|
|
2287
|
-
//
|
|
2288
|
-
|
|
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)";
|
|
2289
2530
|
if (flyer._right) {
|
|
2290
|
-
flyer._right.style.transform = "rotateY(
|
|
2291
|
-
flyer._right.style.opacity = "
|
|
2531
|
+
flyer._right.style.transform = "rotateY(0deg)";
|
|
2532
|
+
flyer._right.style.opacity = "0";
|
|
2292
2533
|
}
|
|
2293
2534
|
if (flyer._board) {
|
|
2294
2535
|
flyer._board.style.opacity = "0";
|
|
2295
2536
|
flyer._board.style.visibility = "hidden";
|
|
2296
2537
|
}
|
|
2297
|
-
if (flyer._leftThick) flyer._leftThick.style.opacity = "
|
|
2298
|
-
if (flyer._rightThick) flyer._rightThick.style.opacity = "
|
|
2299
|
-
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";
|
|
2300
2541
|
const closeSpineLabel = flyer._cover && flyer._cover.querySelector(".flyer-spine-label");
|
|
2301
2542
|
if (closeSpineLabel) closeSpineLabel.style.opacity = "0";
|
|
2302
2543
|
// ux23: spines may swap to img-only; covers keep applyCoverImage bg under img
|
|
@@ -2344,7 +2585,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2344
2585
|
// Start the reverse on this frame. A pre-clock rAF left the spread parked at
|
|
2345
2586
|
// 672×504 while the note was still up (the ~240ms close-start hitch).
|
|
2346
2587
|
void readyPulse;
|
|
2347
|
-
if (!earlyMove) flyer.style.opacity = "
|
|
2588
|
+
if (!earlyMove) flyer.style.opacity = "0";
|
|
2348
2589
|
|
|
2349
2590
|
// ux14: TIME-REVERSE of open pathTs — ban separately computed return / end-segment seek.
|
|
2350
2591
|
const PUT_MS = 380;
|
|
@@ -2367,33 +2608,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2367
2608
|
);
|
|
2368
2609
|
// Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
|
|
2369
2610
|
const aLeftClose = earlyLeft || (flyer._left
|
|
2370
|
-
? flyer._left.animate(
|
|
2371
|
-
[
|
|
2372
|
-
{ offset: 0, transform: "rotateY(8deg)" },
|
|
2373
|
-
{ offset: 0.08, transform: "rotateY(20deg)" },
|
|
2374
|
-
{ offset: 0.16, transform: "rotateY(70deg)" },
|
|
2375
|
-
{ offset: 0.24, transform: "rotateY(140deg)" },
|
|
2376
|
-
{ offset: 0.32, transform: "rotateY(180deg)" },
|
|
2377
|
-
{ offset: 1.0, transform: "rotateY(180deg)" },
|
|
2378
|
-
],
|
|
2379
|
-
closeMotion
|
|
2380
|
-
)
|
|
2611
|
+
? flyer._left.animate(closedReturnLeftKeys(), closeMotion)
|
|
2381
2612
|
: null);
|
|
2382
2613
|
const aRightClose = earlyRight || (flyer._right
|
|
2383
|
-
? flyer._right.animate(
|
|
2384
|
-
[
|
|
2385
|
-
{ offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
|
|
2386
|
-
{ offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
|
|
2387
|
-
{ offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
|
|
2388
|
-
{ offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
|
|
2389
|
-
],
|
|
2390
|
-
closeMotion
|
|
2391
|
-
)
|
|
2614
|
+
? flyer._right.animate(closedReturnRightKeys(), closeMotion)
|
|
2392
2615
|
: null);
|
|
2393
2616
|
const closedFaceOut = [
|
|
2394
|
-
{ offset: 0, opacity:
|
|
2395
|
-
{ offset: 0.18, opacity: 0.7 },
|
|
2396
|
-
{ offset: 0.28, opacity: 0 },
|
|
2617
|
+
{ offset: 0, opacity: 0 },
|
|
2397
2618
|
{ offset: 1.0, opacity: 0 },
|
|
2398
2619
|
];
|
|
2399
2620
|
const aBoardClose = null;
|
|
@@ -2402,8 +2623,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2402
2623
|
const aGutterClose = flyer._gutter
|
|
2403
2624
|
? flyer._gutter.animate(
|
|
2404
2625
|
[
|
|
2405
|
-
{ offset: 0, opacity:
|
|
2406
|
-
{ offset: 0.24, opacity: 0 },
|
|
2626
|
+
{ offset: 0, opacity: 0 },
|
|
2407
2627
|
{ offset: 1.0, opacity: 0 },
|
|
2408
2628
|
],
|
|
2409
2629
|
closeOpts
|
|
@@ -2437,15 +2657,19 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2437
2657
|
const aFlyerFade = flyer.animate(
|
|
2438
2658
|
targetMode === "spines"
|
|
2439
2659
|
? [
|
|
2440
|
-
//
|
|
2441
|
-
//
|
|
2442
|
-
{ offset: 0, opacity:
|
|
2443
|
-
{ offset: 0.
|
|
2444
|
-
{ 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 },
|
|
2445
2667
|
{ offset: 1.0, opacity: 0 },
|
|
2446
2668
|
]
|
|
2447
2669
|
: [
|
|
2448
|
-
{ offset: 0, opacity:
|
|
2670
|
+
{ offset: 0, opacity: 0 },
|
|
2671
|
+
{ offset: 0.06, opacity: 0 },
|
|
2672
|
+
{ offset: 0.12, opacity: 1 },
|
|
2449
2673
|
{ offset: flyerFadeAt, opacity: 1 },
|
|
2450
2674
|
{ offset: 1.0, opacity: 0 },
|
|
2451
2675
|
],
|
|
@@ -2490,13 +2714,17 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2490
2714
|
? coverImg.animate(
|
|
2491
2715
|
targetMode === "spines"
|
|
2492
2716
|
? [
|
|
2493
|
-
{ offset: 0, opacity:
|
|
2494
|
-
{ offset: 0.
|
|
2495
|
-
{ 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 },
|
|
2496
2722
|
{ offset: 1, opacity: 0 },
|
|
2497
2723
|
]
|
|
2498
2724
|
: [
|
|
2499
|
-
{ offset: 0, opacity:
|
|
2725
|
+
{ offset: 0, opacity: 0 },
|
|
2726
|
+
{ offset: 0.06, opacity: 0 },
|
|
2727
|
+
{ offset: 0.12, opacity: 1 },
|
|
2500
2728
|
{ offset: flyerFadeAt, opacity: 1 },
|
|
2501
2729
|
{ offset: 1, opacity: 0 },
|
|
2502
2730
|
],
|
|
@@ -2580,7 +2808,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2580
2808
|
slot.style.opacity = "1";
|
|
2581
2809
|
slot.style.transform = "none";
|
|
2582
2810
|
root.classList.remove("is-return");
|
|
2583
|
-
noteView.classList.remove("is-open", "is-preparing");
|
|
2811
|
+
noteView.classList.remove("is-open", "is-preparing", "is-hub");
|
|
2584
2812
|
noteView.hidden = true;
|
|
2585
2813
|
noteView.style.opacity = "";
|
|
2586
2814
|
noteView.style.zIndex = "";
|
|
@@ -2651,6 +2879,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2651
2879
|
});
|
|
2652
2880
|
try { shelfView.style.removeProperty("min-height"); } catch (_) {}
|
|
2653
2881
|
shelfView.style.opacity = "";
|
|
2882
|
+
releaseHubUrl();
|
|
2654
2883
|
openBook = null;
|
|
2655
2884
|
openFromExpanded = false;
|
|
2656
2885
|
releaseScrollLock();
|
|
@@ -2689,6 +2918,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2689
2918
|
|
|
2690
2919
|
listen($("#backBtn"), "click", () => closeBookFlow());
|
|
2691
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
|
+
|
|
2692
2928
|
// Browser Back from the full note restores this page from bfcache, still open.
|
|
2693
2929
|
// Land on the shelf at the scroll we saved, without replaying the close clock.
|
|
2694
2930
|
listen(window, "pageshow", (event) => {
|
|
@@ -2704,7 +2940,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2704
2940
|
busy = false;
|
|
2705
2941
|
flowSeq += 1;
|
|
2706
2942
|
document.body.classList.remove("is-busy");
|
|
2707
|
-
noteView.classList.remove("is-open", "is-preparing");
|
|
2943
|
+
noteView.classList.remove("is-open", "is-preparing", "is-hub");
|
|
2708
2944
|
noteView.hidden = true;
|
|
2709
2945
|
noteView.style.opacity = "";
|
|
2710
2946
|
noteView.style.zIndex = "";
|
|
@@ -2724,7 +2960,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2724
2960
|
listen(root, "click", (event) => {
|
|
2725
2961
|
const target = event.target;
|
|
2726
2962
|
if (!target || !target.closest) return;
|
|
2727
|
-
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");
|
|
2728
2964
|
if (!link || !root.contains(link)) return;
|
|
2729
2965
|
const y = scrollLock ? scrollLock.y : readScroll().y;
|
|
2730
2966
|
try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
|
|
@@ -2742,12 +2978,73 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2742
2978
|
/* intentionally empty — enter-time density + face sizes stay put */
|
|
2743
2979
|
});
|
|
2744
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
|
+
|
|
2745
3038
|
// init
|
|
2746
3039
|
const q = parseQuery();
|
|
2747
3040
|
if (q) modeOverride = q;
|
|
2748
3041
|
enableDebugChrome();
|
|
2749
3042
|
applyModePreference();
|
|
2750
3043
|
preloadCovers();
|
|
3044
|
+
if (initialHub) {
|
|
3045
|
+
const found = bookById(initialHub);
|
|
3046
|
+
if (found && isHubBook(found.book)) presentHubImmediate(found.book);
|
|
3047
|
+
}
|
|
2751
3048
|
|
|
2752
3049
|
return () => {
|
|
2753
3050
|
for (const fn of unlisten) fn();
|