hd-garden 0.9.1 → 0.9.3
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 +55 -0
- package/app/bookshelf/load.mjs +24 -6
- package/app/components/bookshelf/BookHub.tsx +142 -0
- package/app/components/bookshelf/Bookshelf.tsx +14 -3
- package/app/components/bookshelf/bookshelf.css +88 -42
- package/app/components/bookshelf/catalog.mjs +74 -14
- package/app/components/bookshelf/catalog.ts +4 -0
- package/app/components/bookshelf/engine.ts +167 -84
- 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 +37 -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
|
@@ -312,6 +312,30 @@ export function mountBookshelf(root, options = {}) {
|
|
|
312
312
|
return Math.max(96, Math.round(w * (nat.h / nat.w)));
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
// Real jacket on the expanded spine. CSS background-image is stripped by
|
|
316
|
+
// `.spine[class*="img-"] { background-image: none !important }` and by the
|
|
317
|
+
// collapse rule, which left bk-* (#513688) as a solid pad at extract start.
|
|
318
|
+
function mountSeatCover(el, book) {
|
|
319
|
+
if (!el || !book || !book.cover || book.filler) return;
|
|
320
|
+
el.style.backgroundColor = "transparent";
|
|
321
|
+
let art = el.querySelector(":scope > .cover-art");
|
|
322
|
+
if (!art) {
|
|
323
|
+
art = document.createElement("img");
|
|
324
|
+
art.className = "cover-art";
|
|
325
|
+
art.alt = "";
|
|
326
|
+
art.draggable = false;
|
|
327
|
+
el.appendChild(art);
|
|
328
|
+
}
|
|
329
|
+
const src = coverSrc(book);
|
|
330
|
+
if (art.getAttribute("src") !== src) art.src = src;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function clearSeatCover(el) {
|
|
334
|
+
if (!el || !el.classList.contains("spine")) return;
|
|
335
|
+
el.querySelectorAll(":scope > .cover-art").forEach((node) => node.remove());
|
|
336
|
+
try { el.style.removeProperty("background-color"); } catch (_) {}
|
|
337
|
+
}
|
|
338
|
+
|
|
315
339
|
function applyFaceSize(el, book, { expanded = false } = {}) {
|
|
316
340
|
if (!el || !book) return;
|
|
317
341
|
const faceH = bookFaceHeight(book);
|
|
@@ -580,6 +604,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
580
604
|
function restoreRestingSpine(el, bookId) {
|
|
581
605
|
if (!el) return;
|
|
582
606
|
el.classList.remove("is-expanded", "is-collapsing");
|
|
607
|
+
clearSeatCover(el);
|
|
583
608
|
const found = bookById(bookId);
|
|
584
609
|
if (found && found.book.cover && !found.book.filler) {
|
|
585
610
|
applyFaceSize(el, found.book, { expanded: false });
|
|
@@ -621,6 +646,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
621
646
|
el.classList.remove("is-collapsing");
|
|
622
647
|
el.classList.add("is-expanded");
|
|
623
648
|
applyFaceSize(el, found.book, { expanded: true });
|
|
649
|
+
mountSeatCover(el, found.book);
|
|
624
650
|
}
|
|
625
651
|
}
|
|
626
652
|
|
|
@@ -651,18 +677,31 @@ export function mountBookshelf(root, options = {}) {
|
|
|
651
677
|
collapseToken += 1;
|
|
652
678
|
}
|
|
653
679
|
|
|
680
|
+
// Multi-note books leave the shelf for /bookshelf/<id>. No extract flyer.
|
|
681
|
+
function leaveForHub(book) {
|
|
682
|
+
const posts = book && Array.isArray(book.posts) ? book.posts : [];
|
|
683
|
+
const href = book && typeof book.hub === "string" ? book.hub : "";
|
|
684
|
+
if (posts.length < 2 || !href) return false;
|
|
685
|
+
const y = scrollLock ? scrollLock.y : readScroll().y;
|
|
686
|
+
try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
|
|
687
|
+
window.location.assign(href);
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
|
|
654
691
|
async function onCoverClick(id, el) {
|
|
655
692
|
if (busy) return;
|
|
656
693
|
const found = bookById(id);
|
|
657
694
|
if (!found || found.book.filler) return;
|
|
695
|
+
if (leaveForHub(found.book)) return;
|
|
658
696
|
await openBookFlow(found.book, found.cat, el, { fromExpanded: false, asSpine: false });
|
|
659
697
|
}
|
|
660
698
|
|
|
661
699
|
async function onSpineClick(id, el, ev) {
|
|
662
700
|
if (busy) return;
|
|
663
701
|
const found = bookById(id);
|
|
664
|
-
if (!found) return;
|
|
665
|
-
if (found.book
|
|
702
|
+
if (!found || found.book.filler) return;
|
|
703
|
+
if (leaveForHub(found.book)) return;
|
|
704
|
+
if (!found.book.cover) return;
|
|
666
705
|
|
|
667
706
|
const isTouch = ev && (ev.pointerType === "touch" || ("ontouchstart" in window && window.matchMedia("(hover: none)").matches));
|
|
668
707
|
|
|
@@ -686,7 +725,20 @@ export function mountBookshelf(root, options = {}) {
|
|
|
686
725
|
$("#noteTitle").textContent = book.title;
|
|
687
726
|
$("#noteAuthor").textContent = book.author || "";
|
|
688
727
|
const coverEl = $("#noteCover");
|
|
689
|
-
|
|
728
|
+
// Intrinsic <img>, not the .img-* background. That background is
|
|
729
|
+
// background-size:cover inside a fixed 2:3 frame and clips landscape art.
|
|
730
|
+
coverEl.className = "note-cover";
|
|
731
|
+
coverEl.style.backgroundImage = "none";
|
|
732
|
+
coverEl.style.backgroundColor = "transparent";
|
|
733
|
+
coverEl.replaceChildren();
|
|
734
|
+
if (book.cover) {
|
|
735
|
+
const img = document.createElement("img");
|
|
736
|
+
img.className = "note-cover-img";
|
|
737
|
+
img.alt = "";
|
|
738
|
+
img.draggable = false;
|
|
739
|
+
img.src = coverSrc(book);
|
|
740
|
+
coverEl.appendChild(img);
|
|
741
|
+
}
|
|
690
742
|
const note = NOTES[book.note];
|
|
691
743
|
const read = $("#noteRead");
|
|
692
744
|
if (read) {
|
|
@@ -969,6 +1021,42 @@ export function mountBookshelf(root, options = {}) {
|
|
|
969
1021
|
return { offset, ...pathPose(transform) };
|
|
970
1022
|
}
|
|
971
1023
|
|
|
1024
|
+
// Close samples. T0 still carries the separate sx/sy height pin.
|
|
1025
|
+
// The old first stop was Tp3 (only 25% of the way home) at 0.28, so the
|
|
1026
|
+
// book was still nearly full-size for the first ~1s and read as overlap.
|
|
1027
|
+
function returnMoveKeys(Tfull, Tp1, T0) {
|
|
1028
|
+
return [
|
|
1029
|
+
pathKey(0, Tfull),
|
|
1030
|
+
pathKey(0.12, lerpTS(Tfull, T0, 0.35)),
|
|
1031
|
+
pathKey(0.32, lerpTS(Tfull, T0, 0.62)),
|
|
1032
|
+
pathKey(0.52, Tp1),
|
|
1033
|
+
pathKey(0.74, T0),
|
|
1034
|
+
pathKey(0.88, T0),
|
|
1035
|
+
pathKey(1, T0),
|
|
1036
|
+
];
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Note stays out until the flyer leaves, then a short handoff.
|
|
1040
|
+
// Rising from 0.65 while the flyer stayed solid until 0.90 was the double exposure.
|
|
1041
|
+
function noteInKeys() {
|
|
1042
|
+
return [
|
|
1043
|
+
{ offset: 0, opacity: 0 },
|
|
1044
|
+
{ offset: 0.82, opacity: 0 },
|
|
1045
|
+
{ offset: 0.90, opacity: 1 },
|
|
1046
|
+
{ offset: 1, opacity: 1 },
|
|
1047
|
+
];
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Article is gone by ~0.12 (230ms). Shrink has already started on frame 0.
|
|
1051
|
+
function noteOutKeys() {
|
|
1052
|
+
return [
|
|
1053
|
+
{ offset: 0, opacity: 1 },
|
|
1054
|
+
{ offset: 0.05, opacity: 0.35 },
|
|
1055
|
+
{ offset: 0.12, opacity: 0 },
|
|
1056
|
+
{ offset: 1, opacity: 0 },
|
|
1057
|
+
];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
972
1060
|
// ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
|
|
973
1061
|
// X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
|
|
974
1062
|
// Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
|
|
@@ -1279,6 +1367,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1279
1367
|
"is-expanded",
|
|
1280
1368
|
"is-collapsing"
|
|
1281
1369
|
);
|
|
1370
|
+
clearSeatCover(el);
|
|
1282
1371
|
// Explicit removeProperty — kills commitStyles leftovers like opacity:0 / transform
|
|
1283
1372
|
[
|
|
1284
1373
|
"width",
|
|
@@ -1324,6 +1413,9 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1324
1413
|
|
|
1325
1414
|
const board = document.createElement("div");
|
|
1326
1415
|
board.className = "flyer-board";
|
|
1416
|
+
// ux27: never a solid shell behind the jacket. CSS also forces this off.
|
|
1417
|
+
board.style.opacity = "0";
|
|
1418
|
+
board.style.visibility = "hidden";
|
|
1327
1419
|
|
|
1328
1420
|
const left = document.createElement("div");
|
|
1329
1421
|
left.className = "flyer-leaf flyer-leaf-left";
|
|
@@ -1401,7 +1493,6 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1401
1493
|
left.style.transform = "rotateY(180deg)";
|
|
1402
1494
|
right.style.transform = "rotateY(0deg)";
|
|
1403
1495
|
right.style.opacity = "0";
|
|
1404
|
-
board.style.opacity = "0";
|
|
1405
1496
|
leftThick.style.opacity = "0";
|
|
1406
1497
|
rightThick.style.opacity = "0";
|
|
1407
1498
|
bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
|
|
@@ -1475,13 +1566,9 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1475
1566
|
noteView.classList.add("is-preparing");
|
|
1476
1567
|
noteView.classList.remove("is-open");
|
|
1477
1568
|
noteView.style.opacity = "0";
|
|
1478
|
-
const
|
|
1479
|
-
|
|
1480
|
-
const m = bg && bg.match(/url\(["']?([^"')]+)["']?\)/);
|
|
1481
|
-
if (m) {
|
|
1569
|
+
const img = $("#noteCover") && $("#noteCover").querySelector(".note-cover-img");
|
|
1570
|
+
if (img && !coverImgReady(img)) {
|
|
1482
1571
|
try {
|
|
1483
|
-
const img = new Image();
|
|
1484
|
-
img.src = m[1];
|
|
1485
1572
|
const DECODE_MS = 400;
|
|
1486
1573
|
const decodeOrLoad = img.decode
|
|
1487
1574
|
? img.decode()
|
|
@@ -1528,6 +1615,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1528
1615
|
busy = true;
|
|
1529
1616
|
const myFlow = ++flowSeq;
|
|
1530
1617
|
document.body.classList.add("is-busy");
|
|
1618
|
+
root.classList.remove("is-return");
|
|
1531
1619
|
openBook = { book, cat, asSpine, fromExpanded: !!fromExpanded, openSeatRect: null };
|
|
1532
1620
|
openFromExpanded = !!fromExpanded;
|
|
1533
1621
|
|
|
@@ -1568,6 +1656,11 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1568
1656
|
sourceEl.style.transition = "none";
|
|
1569
1657
|
sourceEl.style.transform = "none"; // kill :hover translateY / is-expanded lift
|
|
1570
1658
|
sourceEl.classList.add("is-extracting"); // CSS: suppress :hover transform while extracting
|
|
1659
|
+
// Keep the real jacket on the seat until the flyer exists. Dropping
|
|
1660
|
+
// is-expanded used to reveal the bk-* fill for the frames before the flyer.
|
|
1661
|
+
if (sourceEl.classList.contains("spine") && book && book.cover) {
|
|
1662
|
+
mountSeatCover(sourceEl, book);
|
|
1663
|
+
}
|
|
1571
1664
|
if (fromExpanded) {
|
|
1572
1665
|
sourceEl.classList.remove("is-expanded", "is-collapsing");
|
|
1573
1666
|
// Drop the expanded cover's width. Do NOT drop a resting spine's height:
|
|
@@ -1722,14 +1815,14 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1722
1815
|
{ offset: 0, opacity: 0 },
|
|
1723
1816
|
{ offset: 0.14, opacity: 0 },
|
|
1724
1817
|
{ offset: 0.22, opacity: 1 },
|
|
1725
|
-
{ offset: 0.
|
|
1726
|
-
{ offset: 0.
|
|
1818
|
+
{ offset: 0.82, opacity: 1 },
|
|
1819
|
+
{ offset: 0.90, opacity: 0 },
|
|
1727
1820
|
{ offset: 1.0, opacity: 0 },
|
|
1728
1821
|
]
|
|
1729
1822
|
: [
|
|
1730
1823
|
{ offset: 0, opacity: 1 },
|
|
1731
|
-
{ offset: 0.
|
|
1732
|
-
{ offset: 0.
|
|
1824
|
+
{ offset: 0.82, opacity: 1 },
|
|
1825
|
+
{ offset: 0.90, opacity: 0 },
|
|
1733
1826
|
{ offset: 1.0, opacity: 0 },
|
|
1734
1827
|
],
|
|
1735
1828
|
opts
|
|
@@ -1755,6 +1848,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1755
1848
|
const aExtractCoverIn = null;
|
|
1756
1849
|
|
|
1757
1850
|
// Pose fade on SAME layer while diagonal continues — ban origin-swapping phase.
|
|
1851
|
+
// Board stays out: fading it in painted a wide shell behind the cover by ~1350ms.
|
|
1758
1852
|
const closedFaceKf = [
|
|
1759
1853
|
{ offset: 0, opacity: 0 },
|
|
1760
1854
|
{ offset: 0.28, opacity: 0 },
|
|
@@ -1762,7 +1856,11 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1762
1856
|
{ offset: 0.52, opacity: 1 },
|
|
1763
1857
|
{ offset: 1.0, opacity: 1 },
|
|
1764
1858
|
];
|
|
1765
|
-
|
|
1859
|
+
if (dual._board) {
|
|
1860
|
+
dual._board.style.opacity = "0";
|
|
1861
|
+
dual._board.style.visibility = "hidden";
|
|
1862
|
+
}
|
|
1863
|
+
const aBoard = null;
|
|
1766
1864
|
const aRightFace = dual._right.animate(
|
|
1767
1865
|
[
|
|
1768
1866
|
{ offset: 0, opacity: 0, transform: "rotateY(0deg)" },
|
|
@@ -1803,20 +1901,9 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1803
1901
|
)
|
|
1804
1902
|
: null;
|
|
1805
1903
|
|
|
1806
|
-
//
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
{ offset: 0, opacity: 0 },
|
|
1810
|
-
{ offset: 0.65, opacity: 0 },
|
|
1811
|
-
{ offset: 0.70, opacity: 0.15 },
|
|
1812
|
-
{ offset: 0.78, opacity: 0.55 },
|
|
1813
|
-
{ offset: 0.85, opacity: 0.90 },
|
|
1814
|
-
{ offset: 0.90, opacity: 0.99 },
|
|
1815
|
-
{ offset: 0.92, opacity: 1 },
|
|
1816
|
-
{ offset: 1.0, opacity: 1 },
|
|
1817
|
-
],
|
|
1818
|
-
opts
|
|
1819
|
-
);
|
|
1904
|
+
// Note stays at 0 while the flyer owns the flight, then hands off by unlock (0.90).
|
|
1905
|
+
// FORBIDDEN: note body readable while the flyer is still solid (double exposure).
|
|
1906
|
+
const aNote = noteView.animate(noteInKeys(), opts);
|
|
1820
1907
|
|
|
1821
1908
|
const aBack = openBackdrop
|
|
1822
1909
|
? openBackdrop.animate(
|
|
@@ -1961,6 +2048,9 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1961
2048
|
// Clock first. Shelf measure, freeze, and rAF used to run before this and
|
|
1962
2049
|
// the 0.12 Tfull keyframe then held 672×504 for ~240ms. Stash from open is
|
|
1963
2050
|
// enough to start the return; height pin is already in those samples.
|
|
2051
|
+
// is-return puts the stage above the note so the shrink is not hidden
|
|
2052
|
+
// under an opaque article (that read as ~1s of overlap, then a jump).
|
|
2053
|
+
root.classList.add("is-return");
|
|
1964
2054
|
const closeMotion = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
|
|
1965
2055
|
const closeOpts = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
|
|
1966
2056
|
const stashEarly = openBook.pathTs;
|
|
@@ -1982,29 +2072,21 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1982
2072
|
heldEarly.style.visibility = "visible";
|
|
1983
2073
|
heldEarly.style.zIndex = "70";
|
|
1984
2074
|
heldEarly.style.opacity = "1";
|
|
2075
|
+
if (heldEarly._board) {
|
|
2076
|
+
heldEarly._board.style.opacity = "0";
|
|
2077
|
+
heldEarly._board.style.visibility = "hidden";
|
|
2078
|
+
}
|
|
1985
2079
|
promote(heldEarly);
|
|
1986
2080
|
earlyMove = heldEarly.animate(
|
|
1987
|
-
|
|
1988
|
-
pathKey(0, stashEarly.Tfull),
|
|
1989
|
-
pathKey(0.28, stashEarly.Tp3),
|
|
1990
|
-
pathKey(0.44, stashEarly.Tp2),
|
|
1991
|
-
pathKey(0.60, stashEarly.Tp1),
|
|
1992
|
-
pathKey(0.78, stashEarly.T0),
|
|
1993
|
-
pathKey(0.90, stashEarly.T0),
|
|
1994
|
-
pathKey(1, stashEarly.T0),
|
|
1995
|
-
],
|
|
2081
|
+
returnMoveKeys(stashEarly.Tfull, stashEarly.Tp1, stashEarly.T0),
|
|
1996
2082
|
closeMotion
|
|
1997
2083
|
);
|
|
1998
|
-
earlyNote = noteView.animate(
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
{ offset: 1.0, opacity: 0 },
|
|
2005
|
-
],
|
|
2006
|
-
closeOpts
|
|
2007
|
-
);
|
|
2084
|
+
earlyNote = noteView.animate(noteOutKeys(), closeOpts);
|
|
2085
|
+
// Backup if the fade is dropped: the article must not still be up after ~230ms.
|
|
2086
|
+
wait(Math.round(CLOSE_MS * 0.12)).then(() => {
|
|
2087
|
+
if (myFlow !== flowSeq || !noteView) return;
|
|
2088
|
+
noteView.style.opacity = "0";
|
|
2089
|
+
});
|
|
2008
2090
|
if (heldEarly._left) {
|
|
2009
2091
|
earlyLeft = heldEarly._left.animate(
|
|
2010
2092
|
[
|
|
@@ -2058,6 +2140,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2058
2140
|
try { earlyNote && earlyNote.cancel(); } catch (_) {}
|
|
2059
2141
|
try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
|
|
2060
2142
|
try { earlyRight && earlyRight.cancel(); } catch (_) {}
|
|
2143
|
+
root.classList.remove("is-return");
|
|
2061
2144
|
noteView.classList.remove("is-open", "is-preparing");
|
|
2062
2145
|
noteView.hidden = true;
|
|
2063
2146
|
noteView.style.opacity = "";
|
|
@@ -2081,7 +2164,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2081
2164
|
].forEach((p) => {
|
|
2082
2165
|
try { slot.style.removeProperty(p); } catch (_) {}
|
|
2083
2166
|
});
|
|
2084
|
-
slot.classList.remove("is-collapsing", "is-
|
|
2167
|
+
slot.classList.remove("is-collapsing", "is-expanded");
|
|
2168
|
+
// Keep the seat empty until handover. Dropping is-placeholder here let the
|
|
2169
|
+
// bk-* fill paint a solid rectangle under the returning cover.
|
|
2170
|
+
slot.classList.add("is-placeholder");
|
|
2085
2171
|
expandedId = null;
|
|
2086
2172
|
// Spines: re-pin the resting face BEFORE reading the seat. A cleared inline
|
|
2087
2173
|
// height is the shorter size-class box, and put-back would keep it.
|
|
@@ -2116,13 +2202,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2116
2202
|
});
|
|
2117
2203
|
shelfView.classList.add("is-frozen");
|
|
2118
2204
|
|
|
2119
|
-
//
|
|
2120
|
-
//
|
|
2205
|
+
// Note stays in the tree so its fade can finish, but must not cover the flyer.
|
|
2206
|
+
// is-return (set before the clock) owns stacking. Do not pin z-index 75.
|
|
2121
2207
|
noteView.classList.add("is-open");
|
|
2122
2208
|
noteView.classList.remove("is-preparing");
|
|
2123
2209
|
noteView.hidden = false;
|
|
2124
|
-
noteView.style.opacity = "1";
|
|
2125
|
-
noteView.style.zIndex = "
|
|
2210
|
+
if (!earlyNote) noteView.style.opacity = "1";
|
|
2211
|
+
noteView.style.zIndex = "";
|
|
2126
2212
|
|
|
2127
2213
|
unparkShelf();
|
|
2128
2214
|
shelfView.style.opacity = "1";
|
|
@@ -2217,7 +2303,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2217
2303
|
flyer._right.style.transform = "rotateY(-8deg)";
|
|
2218
2304
|
flyer._right.style.opacity = "1";
|
|
2219
2305
|
}
|
|
2220
|
-
if (flyer._board)
|
|
2306
|
+
if (flyer._board) {
|
|
2307
|
+
flyer._board.style.opacity = "0";
|
|
2308
|
+
flyer._board.style.visibility = "hidden";
|
|
2309
|
+
}
|
|
2221
2310
|
if (flyer._leftThick) flyer._leftThick.style.opacity = "1";
|
|
2222
2311
|
if (flyer._rightThick) flyer._rightThick.style.opacity = "1";
|
|
2223
2312
|
if (flyer._gutter) flyer._gutter.style.opacity = "1";
|
|
@@ -2280,29 +2369,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2280
2369
|
|
|
2281
2370
|
resetVeil();
|
|
2282
2371
|
|
|
2283
|
-
//
|
|
2284
|
-
const aNoteOut = earlyNote || noteView.animate(
|
|
2285
|
-
[
|
|
2286
|
-
{ offset: 0, opacity: 1 },
|
|
2287
|
-
{ offset: 0.06, opacity: 0.85 },
|
|
2288
|
-
{ offset: 0.12, opacity: 0.45 },
|
|
2289
|
-
{ offset: 0.22, opacity: 0 },
|
|
2290
|
-
{ offset: 1.0, opacity: 0 },
|
|
2291
|
-
],
|
|
2292
|
-
closeOpts
|
|
2293
|
-
);
|
|
2372
|
+
// Note fades out in the first ~230ms. Shrink already started on frame 0.
|
|
2373
|
+
const aNoteOut = earlyNote || noteView.animate(noteOutKeys(), closeOpts);
|
|
2294
2374
|
|
|
2295
|
-
// Shrink starts at offset 0
|
|
2375
|
+
// Shrink starts at offset 0 and is already well under way by ~400ms.
|
|
2376
|
+
// A first stop on Tp3 (25% home) at 0.28 left the book looking full for ~1s.
|
|
2296
2377
|
const aFlyerMove = earlyMove || flyer.animate(
|
|
2297
|
-
|
|
2298
|
-
pathKey(0, Tfull),
|
|
2299
|
-
pathKey(0.28, Tp3),
|
|
2300
|
-
pathKey(0.44, Tp2),
|
|
2301
|
-
pathKey(0.60, Tp1),
|
|
2302
|
-
pathKey(0.78, T0),
|
|
2303
|
-
pathKey(0.90, T0),
|
|
2304
|
-
pathKey(1, T0),
|
|
2305
|
-
],
|
|
2378
|
+
returnMoveKeys(Tfull, Tp1, T0),
|
|
2306
2379
|
closeMotion
|
|
2307
2380
|
);
|
|
2308
2381
|
// Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
|
|
@@ -2336,7 +2409,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2336
2409
|
{ offset: 0.28, opacity: 0 },
|
|
2337
2410
|
{ offset: 1.0, opacity: 0 },
|
|
2338
2411
|
];
|
|
2339
|
-
const aBoardClose =
|
|
2412
|
+
const aBoardClose = null;
|
|
2340
2413
|
const aThickLClose = flyer._leftThick ? flyer._leftThick.animate(closedFaceOut, closeOpts) : null;
|
|
2341
2414
|
const aThickRClose = flyer._rightThick ? flyer._rightThick.animate(closedFaceOut, closeOpts) : null;
|
|
2342
2415
|
const aGutterClose = flyer._gutter
|
|
@@ -2377,9 +2450,11 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2377
2450
|
const aFlyerFade = flyer.animate(
|
|
2378
2451
|
targetMode === "spines"
|
|
2379
2452
|
? [
|
|
2453
|
+
// Stay through the shrink. Cutting at 0.58 dropped a still-large
|
|
2454
|
+
// cover and made the first ~1.1s look like overlap, then a pop.
|
|
2380
2455
|
{ offset: 0, opacity: 1 },
|
|
2381
|
-
{ offset: 0.
|
|
2382
|
-
{ offset: 0.
|
|
2456
|
+
{ offset: 0.68, opacity: 1 },
|
|
2457
|
+
{ offset: 0.80, opacity: 0 },
|
|
2383
2458
|
{ offset: 1.0, opacity: 0 },
|
|
2384
2459
|
]
|
|
2385
2460
|
: [
|
|
@@ -2411,10 +2486,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2411
2486
|
? spineSlotClose.animate(
|
|
2412
2487
|
[
|
|
2413
2488
|
{ offset: 0, transform: outClose, opacity: 0 },
|
|
2414
|
-
{ offset: 0.
|
|
2415
|
-
{ offset: 0.
|
|
2416
|
-
{ offset: 0.
|
|
2417
|
-
{ offset: 0.
|
|
2489
|
+
{ offset: 0.66, transform: outClose, opacity: 0 },
|
|
2490
|
+
{ offset: 0.74, transform: outClose, opacity: 1 },
|
|
2491
|
+
{ offset: 0.86, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
|
|
2492
|
+
{ offset: 0.94, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
|
|
2418
2493
|
{ offset: 1.0, transform: "translate3d(0px, 0px, 0)", opacity: 0 },
|
|
2419
2494
|
],
|
|
2420
2495
|
closeMotion
|
|
@@ -2429,8 +2504,8 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2429
2504
|
targetMode === "spines"
|
|
2430
2505
|
? [
|
|
2431
2506
|
{ offset: 0, opacity: 1 },
|
|
2432
|
-
{ offset: 0.
|
|
2433
|
-
{ offset: 0.
|
|
2507
|
+
{ offset: 0.68, opacity: 1 },
|
|
2508
|
+
{ offset: 0.80, opacity: 0 },
|
|
2434
2509
|
{ offset: 1, opacity: 0 },
|
|
2435
2510
|
]
|
|
2436
2511
|
: [
|
|
@@ -2452,6 +2527,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2452
2527
|
slot.style.backgroundColor = "";
|
|
2453
2528
|
}
|
|
2454
2529
|
|
|
2530
|
+
// Seat may show its real face only as the flyer hands off. Until then the
|
|
2531
|
+
// placeholder ban (transparent, invisible) blocks the solid bk plate.
|
|
2532
|
+
wait(Math.round(CLOSE_MS * Math.max(0, slotInAt - 0.02))).then(() => {
|
|
2533
|
+
if (myFlow !== flowSeq || !slot.isConnected) return;
|
|
2534
|
+
slot.classList.remove("is-placeholder");
|
|
2535
|
+
});
|
|
2536
|
+
|
|
2455
2537
|
const aSlotIn = slot.animate(
|
|
2456
2538
|
[
|
|
2457
2539
|
{ offset: 0, opacity: 0 },
|
|
@@ -2510,6 +2592,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2510
2592
|
flyer.style.opacity = "0";
|
|
2511
2593
|
slot.style.opacity = "1";
|
|
2512
2594
|
slot.style.transform = "none";
|
|
2595
|
+
root.classList.remove("is-return");
|
|
2513
2596
|
noteView.classList.remove("is-open", "is-preparing");
|
|
2514
2597
|
noteView.hidden = true;
|
|
2515
2598
|
noteView.style.opacity = "";
|
|
@@ -4,9 +4,12 @@ export { Bookshelf } from "./Bookshelf";
|
|
|
4
4
|
export { mountBookshelf } from "./engine";
|
|
5
5
|
export type {
|
|
6
6
|
BookshelfBook,
|
|
7
|
+
BookshelfBookPost,
|
|
7
8
|
BookshelfFiller,
|
|
8
9
|
BookshelfNote,
|
|
10
|
+
BookshelfNoteKind,
|
|
9
11
|
BookshelfProps,
|
|
10
12
|
BookshelfShelf,
|
|
11
13
|
BookshelfSize,
|
|
14
|
+
BookshelfSource,
|
|
12
15
|
} from "./types";
|
|
@@ -7,6 +7,7 @@ export class BookshelfConfigError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const SIZES = new Set(["thin", "s-short", "s-mid", "s-tall"]);
|
|
10
|
+
const NOTE_KINDS = new Set(["overview", "excerpt"]);
|
|
10
11
|
|
|
11
12
|
function record(value, code, message) {
|
|
12
13
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new BookshelfConfigError(code, message);
|
|
@@ -31,6 +32,71 @@ function parseSize(value, where) {
|
|
|
31
32
|
throw new BookshelfConfigError("size", `${where} 的 size 只能是 thin、s-short、s-mid 或 s-tall。`);
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/** Same path identity as the shelf loader: drop content/posts and .md. */
|
|
36
|
+
function postKey(value) {
|
|
37
|
+
return value.trim().replaceAll("\\", "/").replace(/^(?:content\/)?posts\//u, "").replace(/\.md$/u, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parsePostEntry(item, where, index) {
|
|
41
|
+
if (typeof item === "string") {
|
|
42
|
+
const path = optionalString(item);
|
|
43
|
+
if (!path) throw new BookshelfConfigError("posts", `${where} 的 posts[${index}] 必须是路径。`);
|
|
44
|
+
return { path };
|
|
45
|
+
}
|
|
46
|
+
const entry = record(item, "posts", `${where} 的 posts[${index}] 必须是路径或映射。`);
|
|
47
|
+
const path = optionalString(entry.path);
|
|
48
|
+
if (!path) throw new BookshelfConfigError("posts", `${where} 的 posts[${index}] 需要 path。`);
|
|
49
|
+
const kind = optionalString(entry.kind);
|
|
50
|
+
if (kind && !NOTE_KINDS.has(kind)) {
|
|
51
|
+
throw new BookshelfConfigError("posts", `${where} 的 posts[${index}] 的 kind 只能是 overview 或 excerpt。`);
|
|
52
|
+
}
|
|
53
|
+
return kind ? { path, kind } : { path };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `post` is shorthand for one note. `posts` is the list.
|
|
58
|
+
* When `posts` is set it is the list, and `post` may only repeat the first path.
|
|
59
|
+
*/
|
|
60
|
+
function parsePostList(book, where) {
|
|
61
|
+
const single = optionalString(book.post);
|
|
62
|
+
if (book.posts == null) {
|
|
63
|
+
return single ? { post: single, posts: [{ path: single }] } : { post: undefined, posts: undefined };
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(book.posts)) {
|
|
66
|
+
throw new BookshelfConfigError("posts", `${where} 的 posts 必须是路径数组。`);
|
|
67
|
+
}
|
|
68
|
+
const posts = book.posts.map((item, index) => parsePostEntry(item, where, index));
|
|
69
|
+
if (posts.length === 0) throw new BookshelfConfigError("posts", `${where} 的 posts 不能为空。`);
|
|
70
|
+
const seenPaths = new Set();
|
|
71
|
+
for (const entry of posts) {
|
|
72
|
+
const key = postKey(entry.path);
|
|
73
|
+
if (seenPaths.has(key)) throw new BookshelfConfigError("posts", `${where} 的 posts 重复引用「${entry.path}」。`);
|
|
74
|
+
seenPaths.add(key);
|
|
75
|
+
}
|
|
76
|
+
if (single && postKey(single) !== postKey(posts[0].path)) {
|
|
77
|
+
throw new BookshelfConfigError("posts", `${where} 的 post 与 posts 的第一篇不是同一路径。多篇笔记只写 posts。`);
|
|
78
|
+
}
|
|
79
|
+
return { post: posts[0].path, posts };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseSources(value, where) {
|
|
83
|
+
if (value == null) return undefined;
|
|
84
|
+
if (!Array.isArray(value)) throw new BookshelfConfigError("sources", `${where} 的 sources 必须是数组。`);
|
|
85
|
+
if (value.length === 0) return undefined;
|
|
86
|
+
return value.map((item, index) => {
|
|
87
|
+
const source = record(item, "sources", `${where} 的 sources[${index}] 必须是映射。`);
|
|
88
|
+
const label = optionalString(source.label);
|
|
89
|
+
const href = optionalString(source.href);
|
|
90
|
+
if (!label || !href) {
|
|
91
|
+
throw new BookshelfConfigError("sources", `${where} 的 sources[${index}] 需要 label 和 href。`);
|
|
92
|
+
}
|
|
93
|
+
if (!/^(?:https?:\/\/|\/\/|\/(?!\/))/iu.test(href)) {
|
|
94
|
+
throw new BookshelfConfigError("sources", `${where} 的 sources[${index}] 只接受 http(s) 或以 / 开头的地址。`);
|
|
95
|
+
}
|
|
96
|
+
return { label, href };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
34
100
|
function parseBook(value, where, seen) {
|
|
35
101
|
const book = record(value, "book", `${where} 必须是映射。`);
|
|
36
102
|
const id = optionalString(book.id);
|
|
@@ -38,6 +104,7 @@ function parseBook(value, where, seen) {
|
|
|
38
104
|
if (!id || !title) throw new BookshelfConfigError("book", `${where} 需要 id 和 title。`);
|
|
39
105
|
if (seen.has(id)) throw new BookshelfConfigError("duplicate-id", `${where} 的 id「${id}」重复。`);
|
|
40
106
|
seen.add(id);
|
|
107
|
+
const notes = parsePostList(book, where);
|
|
41
108
|
return {
|
|
42
109
|
id,
|
|
43
110
|
title,
|
|
@@ -47,7 +114,9 @@ function parseBook(value, where, seen) {
|
|
|
47
114
|
cover: optionalString(book.cover),
|
|
48
115
|
color: optionalString(book.color),
|
|
49
116
|
filler: parseFiller(book.filler, where),
|
|
50
|
-
post:
|
|
117
|
+
post: notes.post,
|
|
118
|
+
posts: notes.posts,
|
|
119
|
+
sources: parseSources(book.sources, where),
|
|
51
120
|
note: typeof book.note === "string" && book.note.trim() ? book.note.trim() : undefined,
|
|
52
121
|
};
|
|
53
122
|
}
|
|
@@ -11,7 +11,9 @@ export type BookshelfConfigCode =
|
|
|
11
11
|
| "size"
|
|
12
12
|
| "filler"
|
|
13
13
|
| "cover"
|
|
14
|
-
| "post"
|
|
14
|
+
| "post"
|
|
15
|
+
| "posts"
|
|
16
|
+
| "sources";
|
|
15
17
|
|
|
16
18
|
export interface BookshelfConfigError extends Error {
|
|
17
19
|
readonly code: BookshelfConfigCode;
|
|
@@ -23,18 +25,54 @@ export const BookshelfConfigError = ConfigError as unknown as new (
|
|
|
23
25
|
message: string,
|
|
24
26
|
) => BookshelfConfigError;
|
|
25
27
|
|
|
28
|
+
/** `overview` is the reading note (总览). `excerpt` is the quotation note (摘抄). */
|
|
29
|
+
export type BookshelfNoteKind = "overview" | "excerpt";
|
|
30
|
+
|
|
31
|
+
/** One note bound to a volume. A string in yaml is shorthand for `{ path }`. */
|
|
32
|
+
export type BookshelfFilePost = {
|
|
33
|
+
/** Path under `content/posts/`, without `.md`. */
|
|
34
|
+
path: string;
|
|
35
|
+
kind?: BookshelfNoteKind;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Outbound source on the book card. Rendered as a quiet chip, not a button. */
|
|
39
|
+
export type BookshelfFileSource = {
|
|
40
|
+
label: string;
|
|
41
|
+
href: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
26
44
|
/**
|
|
27
|
-
* One volume in `content/bookshelf.yaml`.
|
|
45
|
+
* One volume in `content/bookshelf.yaml`. One yaml entry is one spine.
|
|
46
|
+
*
|
|
47
|
+
* Covers stay on two paths:
|
|
48
|
+
* - yaml `cover` is the book jacket (Douban). It is the only image for the
|
|
49
|
+
* shelf face, the open/close flyer, and the book-hub card.
|
|
50
|
+
* - a post's front matter `cover` is that note's hero on the article page.
|
|
51
|
+
* Never copy a note hero into the jacket slot.
|
|
52
|
+
* - omit yaml `cover` and the jacket falls back to the first note's cover.
|
|
28
53
|
*
|
|
29
|
-
*
|
|
30
|
-
* - `post` is
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* the same
|
|
35
|
-
*
|
|
54
|
+
* Notes:
|
|
55
|
+
* - `post: path` is shorthand for `posts: [path]`.
|
|
56
|
+
* - `posts` is the note list, in order. `kind: overview | excerpt` labels the
|
|
57
|
+
* hub card 总览 or 摘抄. A bare string is a path with no kind.
|
|
58
|
+
* - when `posts` is set, `post` is optional. If both are set, `post` must be
|
|
59
|
+
* the same path as `posts[0]`. Otherwise the shelf is rejected.
|
|
60
|
+
* - one note: the shelf opens that note as it does today (extract flyer, note,
|
|
61
|
+
* return flyer). There is no book hub.
|
|
62
|
+
* - two or more notes: a spine click leaves the shelf for `/bookshelf/<id>`
|
|
63
|
+
* (本书). It does not open `posts[0]` and it does not play the flyer.
|
|
64
|
+
* The hub card is the locked book-multi layout: the jacket slot is 148px
|
|
65
|
+
* wide, the image fills that width, and the height follows the file's ratio.
|
|
66
|
+
* There is no plate behind the jacket. A Douban scan's white border is
|
|
67
|
+
* cropped in the jacket file before it is committed; the build copies that
|
|
68
|
+
* file into `/media/covers/` unchanged.
|
|
69
|
+
* 「我的笔记」 lists the notes. A note card is an ordinary link to that
|
|
70
|
+
* article; article ↔ list does not play the flyer. The flyer runs only when
|
|
71
|
+
* a single-note book is extracted from the shelf and put back.
|
|
72
|
+
* - `sources` are optional `{ label, href }` chips. The hub shows 豆瓣 and PDF
|
|
73
|
+
* only. The PDF chip carries the caption 「(仅分享,侵权删)」. No chapter list.
|
|
74
|
+
* - `note` is Markdown used only when the book has no posts.
|
|
36
75
|
* - `color` (`#rgb` or `#rrggbb`) is the spine cloth and the open-page palette.
|
|
37
|
-
* - `note` is Markdown used only when `post` is absent.
|
|
38
76
|
*/
|
|
39
77
|
export type BookshelfFileBook = {
|
|
40
78
|
id: string;
|
|
@@ -42,10 +80,14 @@ export type BookshelfFileBook = {
|
|
|
42
80
|
author?: string;
|
|
43
81
|
spine?: string;
|
|
44
82
|
size?: BookshelfSize;
|
|
83
|
+
/** Book jacket. File name under `content/covers/`, or a full URL. */
|
|
45
84
|
cover?: string;
|
|
46
85
|
color?: string;
|
|
47
86
|
filler?: BookshelfFiller;
|
|
87
|
+
/** Shorthand for a single note. Equal to `posts[0].path` after parsing. */
|
|
48
88
|
post?: string;
|
|
89
|
+
posts?: BookshelfFilePost[];
|
|
90
|
+
sources?: BookshelfFileSource[];
|
|
49
91
|
note?: string;
|
|
50
92
|
};
|
|
51
93
|
|