hd-garden 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/bookshelf/load.mjs +96 -0
- package/app/bookshelf/load.ts +12 -19
- package/app/bookshelf/page.tsx +43 -10
- package/app/components/SiteChrome.tsx +14 -6
- package/app/components/SiteHeader.tsx +3 -3
- package/app/components/bookshelf/Bookshelf.tsx +64 -9
- package/app/components/bookshelf/bookshelf.css +123 -2
- package/app/components/bookshelf/catalog.mjs +116 -0
- package/app/components/bookshelf/catalog.ts +23 -0
- package/app/components/bookshelf/engine.ts +278 -63
- package/app/components/bookshelf/schema.mjs +86 -0
- package/app/components/bookshelf/schema.ts +25 -87
- package/app/components/bookshelf/types.ts +20 -3
- package/app/garden-modules.d.ts +6 -0
- package/app/layout.tsx +2 -1
- package/app/lib/i18n.ts +2 -2
- package/app/sitemap.ts +3 -0
- package/docs/handbook/config.md +41 -2
- package/docs/handbook/en/config.md +41 -2
- package/package.json +1 -1
- package/scripts/build-content.mjs +17 -1
- package/scripts/handbook-facts.mjs +2 -1
- package/ui/garden/site-nav.mjs +12 -0
- package/vite.config.ts +12 -3
|
@@ -38,8 +38,11 @@ function injectExtraCovers(catalog) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function mountBookshelf(root, options = {}) {
|
|
41
|
+
// A passed catalog is a site shelf. Demo notes and demo copy stay on the
|
|
42
|
+
// package preview, which mounts this with no catalog.
|
|
41
43
|
const CATALOG = options.catalog ?? DEMO_CATALOG;
|
|
42
|
-
const NOTES = options.notes ?? DEMO_NOTES;
|
|
44
|
+
const NOTES = options.notes ?? (options.catalog ? {} : DEMO_NOTES);
|
|
45
|
+
const usingDemo = !options.catalog;
|
|
43
46
|
const removeExtraCovers = injectExtraCovers(CATALOG);
|
|
44
47
|
|
|
45
48
|
// ux26: per-book cover dominant (= existing bk-* spine colors) for open-shell + page wash
|
|
@@ -100,8 +103,11 @@ export function mountBookshelf(root, options = {}) {
|
|
|
100
103
|
|
|
101
104
|
function bookShellHex(book) {
|
|
102
105
|
if (!book) return "#5a6a88";
|
|
103
|
-
if (book.filler === "sand") return COVER_SHELL.sand;
|
|
106
|
+
if (book.filler === "sand" || book.filler === true) return COVER_SHELL.sand;
|
|
104
107
|
if (book.filler === "cream") return COVER_SHELL.cream;
|
|
108
|
+
// Site books carry the sampled cover color. Demo jackets keep COVER_SHELL.
|
|
109
|
+
const color = typeof book.color === "string" ? book.color.trim() : "";
|
|
110
|
+
if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) return color;
|
|
105
111
|
return COVER_SHELL[book.id] || "#5a6a88";
|
|
106
112
|
}
|
|
107
113
|
|
|
@@ -171,6 +177,9 @@ export function mountBookshelf(root, options = {}) {
|
|
|
171
177
|
function noteSummaryText(book) {
|
|
172
178
|
const key = book && book.note;
|
|
173
179
|
const note = key ? NOTES[key] : null;
|
|
180
|
+
if (note && typeof note.summary === "string" && note.summary.trim()) {
|
|
181
|
+
return note.summary.trim().replace(/\s+/g, " ");
|
|
182
|
+
}
|
|
174
183
|
if (note && note.body) {
|
|
175
184
|
let text = plainFromHtml(note.body);
|
|
176
185
|
// Prefer first real sentence-ish chunk after stripping headings noise
|
|
@@ -179,6 +188,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
179
188
|
if (text.length >= 24) return text;
|
|
180
189
|
}
|
|
181
190
|
const who = book && book.author ? ` · ${book.author}` : "";
|
|
191
|
+
if (!usingDemo) return (book && (book.author || book.title)) || "";
|
|
182
192
|
return `《${book.title}》的读书笔记摘要${who}。正式站会换成完整笔记;Demo 先占这一页。`;
|
|
183
193
|
}
|
|
184
194
|
|
|
@@ -190,7 +200,11 @@ export function mountBookshelf(root, options = {}) {
|
|
|
190
200
|
|
|
191
201
|
function rightPageHtml(book) {
|
|
192
202
|
const summary = escapeHtml(noteSummaryText(book));
|
|
193
|
-
|
|
203
|
+
const href = book && typeof book.href === "string" ? book.href : "";
|
|
204
|
+
const more = href
|
|
205
|
+
? `<a class="flyer-page-more" href="${escapeHtml(href)}">阅读全文</a>`
|
|
206
|
+
: "";
|
|
207
|
+
return `<div class="flyer-page-kicker">读书笔记</div><p class="flyer-page-summary">${summary}</p>${more}`;
|
|
194
208
|
}
|
|
195
209
|
|
|
196
210
|
|
|
@@ -323,12 +337,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
323
337
|
return null;
|
|
324
338
|
}
|
|
325
339
|
|
|
326
|
-
// ux21: mobile
|
|
327
|
-
//
|
|
340
|
+
// ux21 lock: mobile resting = all covers (全封面).
|
|
341
|
+
// Match CSS mobile shelf: (hover:none) OR max-width 640.
|
|
342
|
+
// DevTools「手机 393」often keeps hover:hover — width gate is required or autoDecide→spines.
|
|
328
343
|
function isMobilePrimary() {
|
|
329
344
|
return (
|
|
330
|
-
window.matchMedia("(hover: none)").matches
|
|
331
|
-
window.matchMedia("(
|
|
345
|
+
window.matchMedia("(hover: none)").matches ||
|
|
346
|
+
window.matchMedia("(max-width: 640px)").matches
|
|
332
347
|
);
|
|
333
348
|
}
|
|
334
349
|
|
|
@@ -673,10 +688,22 @@ export function mountBookshelf(root, options = {}) {
|
|
|
673
688
|
const coverEl = $("#noteCover");
|
|
674
689
|
coverEl.className = `note-cover ${bkClass(book)} ${coverClass(book)}`;
|
|
675
690
|
const note = NOTES[book.note];
|
|
691
|
+
const read = $("#noteRead");
|
|
692
|
+
if (read) {
|
|
693
|
+
if (book.href) {
|
|
694
|
+
read.href = book.href;
|
|
695
|
+
read.hidden = false;
|
|
696
|
+
} else {
|
|
697
|
+
read.hidden = true;
|
|
698
|
+
read.removeAttribute("href");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
676
701
|
if (note) {
|
|
677
702
|
$("#noteBody").innerHTML = note.body;
|
|
678
|
-
} else {
|
|
703
|
+
} else if (usingDemo) {
|
|
679
704
|
$("#noteBody").innerHTML = `<p class="placeholder">这本还没有完整笔记正文。Demo 占位:返回书架继续抽翻验收。</p><p>${book.title}${book.author ? " · " + book.author : ""}</p>`;
|
|
705
|
+
} else {
|
|
706
|
+
$("#noteBody").innerHTML = `<p class="placeholder">这本还没有笔记正文。</p><p>${escapeHtml(book.title)}${book.author ? " · " + escapeHtml(book.author) : ""}</p>`;
|
|
680
707
|
}
|
|
681
708
|
}
|
|
682
709
|
|
|
@@ -727,15 +754,23 @@ export function mountBookshelf(root, options = {}) {
|
|
|
727
754
|
return `translate(${tx}px, ${ty}px) scale(${sx}, ${sy})${extra ? " " + extra : ""}`;
|
|
728
755
|
}
|
|
729
756
|
|
|
730
|
-
// Closed book: cover on RIGHT half.
|
|
757
|
+
// Closed book: cover on RIGHT half.
|
|
758
|
+
// Covers stay uniform (sx ≈ sy). A narrow spine must NOT share that one scale:
|
|
759
|
+
// min(spineW / halfSpread, spineH / spreadH) ≈ 0.09 crushes height to ~45px.
|
|
760
|
+
// Spine slot: scale X and Y apart so the flyer box height stays the resting spine.
|
|
731
761
|
function closedCoverTransform(slotRect, mid) {
|
|
732
762
|
const halfW = mid.width / 2;
|
|
733
763
|
const sx = slotRect.width / halfW;
|
|
734
764
|
const sy = slotRect.height / mid.height;
|
|
765
|
+
const narrow = slotRect.width / Math.max(1, slotRect.height) < 0.42;
|
|
766
|
+
if (narrow) {
|
|
767
|
+
const tx = slotRect.left - mid.left - halfW * sx;
|
|
768
|
+
const ty = slotRect.top - mid.top;
|
|
769
|
+
return `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px) scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
|
|
770
|
+
}
|
|
735
771
|
const s = Math.min(sx, sy);
|
|
736
772
|
const scaledHalfW = halfW * s;
|
|
737
773
|
const scaledH = mid.height * s;
|
|
738
|
-
// Center scaled right-half over slot
|
|
739
774
|
const tx = slotRect.left - mid.left - halfW * s + (slotRect.width - scaledHalfW) / 2;
|
|
740
775
|
const ty = slotRect.top - mid.top + (slotRect.height - scaledH) / 2;
|
|
741
776
|
return `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px) scale(${s.toFixed(4)})`;
|
|
@@ -879,19 +914,27 @@ export function mountBookshelf(root, options = {}) {
|
|
|
879
914
|
// Parse translate/scale from our path strings; lerp components so grow ≠ hard jump (PATH-SHAKE ~2.2s).
|
|
880
915
|
function parseTS(transform) {
|
|
881
916
|
const s = String(transform || "");
|
|
882
|
-
let tx = 0, ty = 0,
|
|
917
|
+
let tx = 0, ty = 0, sx = 1, sy = 1;
|
|
883
918
|
let m = s.match(/translate3d\(\s*([\d.-]+)px\s*,\s*([\d.-]+)px/);
|
|
884
919
|
if (m) { tx = +m[1]; ty = +m[2]; }
|
|
885
920
|
else {
|
|
886
921
|
m = s.match(/translate\(\s*([\d.-]+)px\s*,\s*([\d.-]+)px/);
|
|
887
922
|
if (m) { tx = +m[1]; ty = +m[2]; }
|
|
888
923
|
}
|
|
889
|
-
m = s.match(/scale\(\s*([\d.-]+)
|
|
890
|
-
if (m)
|
|
891
|
-
|
|
924
|
+
m = s.match(/scale\(\s*([\d.-]+)(?:\s*,\s*([\d.-]+))?\s*\)/);
|
|
925
|
+
if (m) {
|
|
926
|
+
sx = +m[1];
|
|
927
|
+
sy = m[2] != null ? +m[2] : sx;
|
|
928
|
+
}
|
|
929
|
+
return { tx, ty, sx, sy };
|
|
892
930
|
}
|
|
893
931
|
function formatTS(p) {
|
|
894
|
-
|
|
932
|
+
const sx = p.sx != null ? p.sx : p.s;
|
|
933
|
+
const sy = p.sy != null ? p.sy : sx;
|
|
934
|
+
const scale = Math.abs(sx - sy) < 1e-4
|
|
935
|
+
? `scale(${sx.toFixed(4)})`
|
|
936
|
+
: `scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
|
|
937
|
+
return `translate(${p.tx.toFixed(2)}px, ${p.ty.toFixed(2)}px) ${scale}`;
|
|
895
938
|
}
|
|
896
939
|
function lerpTS(a, b, t) {
|
|
897
940
|
const u = Math.max(0, Math.min(1, t));
|
|
@@ -900,10 +943,32 @@ export function mountBookshelf(root, options = {}) {
|
|
|
900
943
|
return formatTS({
|
|
901
944
|
tx: pa.tx + (pb.tx - pa.tx) * u,
|
|
902
945
|
ty: pa.ty + (pb.ty - pa.ty) * u,
|
|
903
|
-
|
|
946
|
+
sx: pa.sx + (pb.sx - pa.sx) * u,
|
|
947
|
+
sy: pa.sy + (pb.sy - pa.sy) * u,
|
|
904
948
|
});
|
|
905
949
|
}
|
|
906
950
|
|
|
951
|
+
// Pose for the flyer move. Same tx/ty/sx/sy as the ux26 string, but on the
|
|
952
|
+
// individual translate and scale properties. A combined transform matrix
|
|
953
|
+
// decomposes badly when sx ≠ sy (slot height pin) and the flight stutters.
|
|
954
|
+
function pathPose(transform) {
|
|
955
|
+
const p = parseTS(transform);
|
|
956
|
+
return {
|
|
957
|
+
translate: `${p.tx.toFixed(2)}px ${p.ty.toFixed(2)}px`,
|
|
958
|
+
scale: `${p.sx.toFixed(4)} ${p.sy.toFixed(4)}`,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
function applyPathPose(el, transform) {
|
|
962
|
+
if (!el) return;
|
|
963
|
+
const pose = pathPose(transform);
|
|
964
|
+
el.style.transform = "none";
|
|
965
|
+
el.style.translate = pose.translate;
|
|
966
|
+
el.style.scale = pose.scale;
|
|
967
|
+
}
|
|
968
|
+
function pathKey(offset, transform) {
|
|
969
|
+
return { offset, ...pathPose(transform) };
|
|
970
|
+
}
|
|
971
|
+
|
|
907
972
|
// ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
|
|
908
973
|
// X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
|
|
909
974
|
// Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
|
|
@@ -1328,7 +1393,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1328
1393
|
right.style.transform = "rotateY(-8deg)";
|
|
1329
1394
|
bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
|
|
1330
1395
|
gutter.style.opacity = "1";
|
|
1331
|
-
flyer
|
|
1396
|
+
applyPathPose(flyer, "translate(0.00px, -4.00px) scale(1)");
|
|
1332
1397
|
flyer.style.opacity = "1";
|
|
1333
1398
|
} else {
|
|
1334
1399
|
// ux10: closed extract must look like ONE cover/spine — hide board + right page
|
|
@@ -1341,7 +1406,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1341
1406
|
rightThick.style.opacity = "0";
|
|
1342
1407
|
bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
|
|
1343
1408
|
gutter.style.opacity = "0";
|
|
1344
|
-
flyer
|
|
1409
|
+
applyPathPose(flyer, closedCoverTransform(atRect, mid));
|
|
1345
1410
|
flyer.style.opacity = "1";
|
|
1346
1411
|
}
|
|
1347
1412
|
stage.appendChild(flyer);
|
|
@@ -1351,7 +1416,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1351
1416
|
function promote(el) {
|
|
1352
1417
|
if (!el) return;
|
|
1353
1418
|
el.classList.add("is-ready");
|
|
1354
|
-
el.style.willChange = "
|
|
1419
|
+
el.style.willChange = "translate, scale, opacity";
|
|
1355
1420
|
}
|
|
1356
1421
|
|
|
1357
1422
|
function demote(el) {
|
|
@@ -1505,18 +1570,21 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1505
1570
|
sourceEl.classList.add("is-extracting"); // CSS: suppress :hover transform while extracting
|
|
1506
1571
|
if (fromExpanded) {
|
|
1507
1572
|
sourceEl.classList.remove("is-expanded", "is-collapsing");
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
"flex-shrink",
|
|
1514
|
-
].forEach((p) => {
|
|
1573
|
+
// Drop the expanded cover's width. Do NOT drop a resting spine's height:
|
|
1574
|
+
// clearing it falls through to .s-mid/.s-short (152/136), shorter than the
|
|
1575
|
+
// face height the shelf is already showing, and that short box becomes
|
|
1576
|
+
// homeRect for extract and put-back.
|
|
1577
|
+
["width", "min-width", "min-height", "flex-shrink"].forEach((p) => {
|
|
1515
1578
|
try { sourceEl.style.removeProperty(p); } catch (_) {}
|
|
1516
1579
|
});
|
|
1580
|
+
if (sourceEl.classList.contains("spine") && book && book.cover && !book.filler) {
|
|
1581
|
+
applyFaceSize(sourceEl, book);
|
|
1582
|
+
} else {
|
|
1583
|
+
try { sourceEl.style.removeProperty("height"); } catch (_) {}
|
|
1584
|
+
}
|
|
1517
1585
|
expandedId = null;
|
|
1518
1586
|
touchExpandedId = null;
|
|
1519
|
-
void sourceEl.offsetWidth; // reflow to
|
|
1587
|
+
void sourceEl.offsetWidth; // reflow to resting spine (transform already none)
|
|
1520
1588
|
} else {
|
|
1521
1589
|
// Even spine-direct: pointer may still be hovering → :hover lift would skew freeze.
|
|
1522
1590
|
void sourceEl.offsetWidth;
|
|
@@ -1636,13 +1704,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1636
1704
|
// Same dual: single diagonal to full — no hold-then-slide second segment.
|
|
1637
1705
|
const aDualMove = dual.animate(
|
|
1638
1706
|
[
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1707
|
+
pathKey(0, T0),
|
|
1708
|
+
pathKey(0.12, Tp1),
|
|
1709
|
+
pathKey(0.28, Tp2),
|
|
1710
|
+
pathKey(0.44, Tp3),
|
|
1711
|
+
pathKey(0.58, Tfull),
|
|
1712
|
+
pathKey(0.78, Tfull),
|
|
1713
|
+
pathKey(1, Tfull),
|
|
1646
1714
|
],
|
|
1647
1715
|
optsMotion
|
|
1648
1716
|
);
|
|
@@ -1802,6 +1870,9 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1802
1870
|
try { a.cancel(); } catch (_) {}
|
|
1803
1871
|
});
|
|
1804
1872
|
} catch (_) {}
|
|
1873
|
+
// commitStyles does not keep the individual scale/translate pose. Park at
|
|
1874
|
+
// the open spread so close shrinks from 672×504 instead of jumping there.
|
|
1875
|
+
applyPathPose(dual, Tfull);
|
|
1805
1876
|
dual.style.opacity = "0";
|
|
1806
1877
|
dual.style.pointerEvents = "none";
|
|
1807
1878
|
dual.style.visibility = "visible";
|
|
@@ -1887,6 +1958,79 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1887
1958
|
return;
|
|
1888
1959
|
}
|
|
1889
1960
|
|
|
1961
|
+
// Clock first. Shelf measure, freeze, and rAF used to run before this and
|
|
1962
|
+
// the 0.12 Tfull keyframe then held 672×504 for ~240ms. Stash from open is
|
|
1963
|
+
// enough to start the return; height pin is already in those samples.
|
|
1964
|
+
const closeMotion = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
|
|
1965
|
+
const closeOpts = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
|
|
1966
|
+
const stashEarly = openBook.pathTs;
|
|
1967
|
+
let earlyMove = null;
|
|
1968
|
+
let earlyNote = null;
|
|
1969
|
+
let earlyLeft = null;
|
|
1970
|
+
let earlyRight = null;
|
|
1971
|
+
const heldEarly = openBook.heldFlyer;
|
|
1972
|
+
if (
|
|
1973
|
+
heldEarly &&
|
|
1974
|
+
heldEarly.isConnected &&
|
|
1975
|
+
stashEarly &&
|
|
1976
|
+
stashEarly.Tfull &&
|
|
1977
|
+
stashEarly.T0 &&
|
|
1978
|
+
stashEarly.Tp1 &&
|
|
1979
|
+
stashEarly.Tp2 &&
|
|
1980
|
+
stashEarly.Tp3
|
|
1981
|
+
) {
|
|
1982
|
+
heldEarly.style.visibility = "visible";
|
|
1983
|
+
heldEarly.style.zIndex = "70";
|
|
1984
|
+
heldEarly.style.opacity = "1";
|
|
1985
|
+
promote(heldEarly);
|
|
1986
|
+
earlyMove = heldEarly.animate(
|
|
1987
|
+
[
|
|
1988
|
+
pathKey(0, stashEarly.Tfull),
|
|
1989
|
+
pathKey(0.28, stashEarly.Tp3),
|
|
1990
|
+
pathKey(0.44, stashEarly.Tp2),
|
|
1991
|
+
pathKey(0.60, stashEarly.Tp1),
|
|
1992
|
+
pathKey(0.78, stashEarly.T0),
|
|
1993
|
+
pathKey(0.90, stashEarly.T0),
|
|
1994
|
+
pathKey(1, stashEarly.T0),
|
|
1995
|
+
],
|
|
1996
|
+
closeMotion
|
|
1997
|
+
);
|
|
1998
|
+
earlyNote = noteView.animate(
|
|
1999
|
+
[
|
|
2000
|
+
{ offset: 0, opacity: 1 },
|
|
2001
|
+
{ offset: 0.06, opacity: 0.85 },
|
|
2002
|
+
{ offset: 0.12, opacity: 0.45 },
|
|
2003
|
+
{ offset: 0.22, opacity: 0 },
|
|
2004
|
+
{ offset: 1.0, opacity: 0 },
|
|
2005
|
+
],
|
|
2006
|
+
closeOpts
|
|
2007
|
+
);
|
|
2008
|
+
if (heldEarly._left) {
|
|
2009
|
+
earlyLeft = heldEarly._left.animate(
|
|
2010
|
+
[
|
|
2011
|
+
{ offset: 0, transform: "rotateY(8deg)" },
|
|
2012
|
+
{ offset: 0.08, transform: "rotateY(20deg)" },
|
|
2013
|
+
{ offset: 0.16, transform: "rotateY(70deg)" },
|
|
2014
|
+
{ offset: 0.24, transform: "rotateY(140deg)" },
|
|
2015
|
+
{ offset: 0.32, transform: "rotateY(180deg)" },
|
|
2016
|
+
{ offset: 1.0, transform: "rotateY(180deg)" },
|
|
2017
|
+
],
|
|
2018
|
+
closeMotion
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
if (heldEarly._right) {
|
|
2022
|
+
earlyRight = heldEarly._right.animate(
|
|
2023
|
+
[
|
|
2024
|
+
{ offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
|
|
2025
|
+
{ offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
|
|
2026
|
+
{ offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
|
|
2027
|
+
{ offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
|
|
2028
|
+
],
|
|
2029
|
+
closeMotion
|
|
2030
|
+
);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1890
2034
|
const mid = midRect(book);
|
|
1891
2035
|
const originX = mid.left + mid.width / 2;
|
|
1892
2036
|
const originY = mid.top + mid.height / 2;
|
|
@@ -1910,8 +2054,13 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1910
2054
|
slot = $(slotSel);
|
|
1911
2055
|
}
|
|
1912
2056
|
if (!slot) {
|
|
2057
|
+
try { earlyMove && earlyMove.cancel(); } catch (_) {}
|
|
2058
|
+
try { earlyNote && earlyNote.cancel(); } catch (_) {}
|
|
2059
|
+
try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
|
|
2060
|
+
try { earlyRight && earlyRight.cancel(); } catch (_) {}
|
|
1913
2061
|
noteView.classList.remove("is-open", "is-preparing");
|
|
1914
2062
|
noteView.hidden = true;
|
|
2063
|
+
noteView.style.opacity = "";
|
|
1915
2064
|
openBook = null;
|
|
1916
2065
|
busy = false;
|
|
1917
2066
|
document.body.classList.remove("is-busy");
|
|
@@ -1934,7 +2083,12 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1934
2083
|
});
|
|
1935
2084
|
slot.classList.remove("is-collapsing", "is-placeholder", "is-expanded");
|
|
1936
2085
|
expandedId = null;
|
|
1937
|
-
|
|
2086
|
+
// Spines: re-pin the resting face BEFORE reading the seat. A cleared inline
|
|
2087
|
+
// height is the shorter size-class box, and put-back would keep it.
|
|
2088
|
+
if (targetMode === "spines" && book && book.cover && !book.filler) {
|
|
2089
|
+
applyFaceSize(slot, book);
|
|
2090
|
+
}
|
|
2091
|
+
void slot.offsetWidth; // reflow to resting spine after class/inline clear
|
|
1938
2092
|
const naturalW = slot.offsetWidth;
|
|
1939
2093
|
const naturalH = slot.offsetHeight;
|
|
1940
2094
|
slot.style.flexShrink = "0";
|
|
@@ -1990,6 +2144,17 @@ export function mountBookshelf(root, options = {}) {
|
|
|
1990
2144
|
frozenHome.width = natW;
|
|
1991
2145
|
frozenHome.height = natH;
|
|
1992
2146
|
frozenHome.top = Math.round(bottom - natH);
|
|
2147
|
+
} else if (targetMode === "spines" && naturalH > 0) {
|
|
2148
|
+
// Seat and flyer use the resting spine just measured, not a short
|
|
2149
|
+
// cover-frame stash from extract.
|
|
2150
|
+
const bottom = frozenHome.top + frozenHome.height;
|
|
2151
|
+
frozenHome.width = naturalW;
|
|
2152
|
+
frozenHome.height = naturalH;
|
|
2153
|
+
frozenHome.top = Math.round(bottom - naturalH);
|
|
2154
|
+
if (openBook) {
|
|
2155
|
+
openBook.homeRect = copyRect(frozenHome);
|
|
2156
|
+
openBook.spineHome = copyRect(frozenHome);
|
|
2157
|
+
}
|
|
1993
2158
|
}
|
|
1994
2159
|
slot.style.width = frozenHome.width + "px";
|
|
1995
2160
|
slot.style.minWidth = frozenHome.width + "px";
|
|
@@ -2027,8 +2192,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2027
2192
|
}
|
|
2028
2193
|
flyer.style.zIndex = "70";
|
|
2029
2194
|
flyer.style.visibility = "visible";
|
|
2030
|
-
|
|
2031
|
-
|
|
2195
|
+
if (!earlyMove) {
|
|
2196
|
+
flyer.style.opacity = "0";
|
|
2197
|
+
applyPathPose(flyer, Tfull); // start at open end pose (transform-aligned)
|
|
2198
|
+
}
|
|
2032
2199
|
promote(flyer);
|
|
2033
2200
|
if (flyer._cover) {
|
|
2034
2201
|
flyer._cover.classList.add("is-cover-ready");
|
|
@@ -2098,13 +2265,10 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2098
2265
|
`translate3d(${spineOutClose.x.toFixed(2)}px, ${spineOutClose.y.toFixed(2)}px, 0)`;
|
|
2099
2266
|
}
|
|
2100
2267
|
|
|
2101
|
-
//
|
|
2102
|
-
|
|
2103
|
-
new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
|
|
2104
|
-
wait(32),
|
|
2105
|
-
]);
|
|
2268
|
+
// Start the reverse on this frame. A pre-clock rAF left the spread parked at
|
|
2269
|
+
// 672×504 while the note was still up (the ~240ms close-start hitch).
|
|
2106
2270
|
void readyPulse;
|
|
2107
|
-
flyer.style.opacity = "1";
|
|
2271
|
+
if (!earlyMove) flyer.style.opacity = "1";
|
|
2108
2272
|
|
|
2109
2273
|
// ux14: TIME-REVERSE of open pathTs — ban separately computed return / end-segment seek.
|
|
2110
2274
|
const PUT_MS = 380;
|
|
@@ -2113,13 +2277,11 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2113
2277
|
const xfadeFrom = 1 - XFADE_MS / CLOSE_MS; // ~0.89
|
|
2114
2278
|
const slotInAt = Math.max(0.86, xfadeFrom - 0.04);
|
|
2115
2279
|
const flyerFadeAt = Math.min(0.97, slotInAt + 0.06);
|
|
2116
|
-
const closeOpts = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
|
|
2117
|
-
const closeMotion = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
|
|
2118
2280
|
|
|
2119
2281
|
resetVeil();
|
|
2120
2282
|
|
|
2121
2283
|
// ux13: note fades ON the book layer while reverse shrink starts — ban instant note unmount.
|
|
2122
|
-
const aNoteOut = noteView.animate(
|
|
2284
|
+
const aNoteOut = earlyNote || noteView.animate(
|
|
2123
2285
|
[
|
|
2124
2286
|
{ offset: 0, opacity: 1 },
|
|
2125
2287
|
{ offset: 0.06, opacity: 0.85 },
|
|
@@ -2130,24 +2292,21 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2130
2292
|
closeOpts
|
|
2131
2293
|
);
|
|
2132
2294
|
|
|
2133
|
-
//
|
|
2134
|
-
|
|
2135
|
-
// TIME-REVERSE of open diagonal — same Ts, no L hang-then-drop.
|
|
2136
|
-
const aFlyerMove = flyer.animate(
|
|
2295
|
+
// Shrink starts at offset 0. The old 0.12 Tfull key was ~228ms parked at 672×504.
|
|
2296
|
+
const aFlyerMove = earlyMove || flyer.animate(
|
|
2137
2297
|
[
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
{ offset: 1.0, transform: T0 },
|
|
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),
|
|
2146
2305
|
],
|
|
2147
2306
|
closeMotion
|
|
2148
2307
|
);
|
|
2149
2308
|
// Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
|
|
2150
|
-
const aLeftClose = flyer._left
|
|
2309
|
+
const aLeftClose = earlyLeft || (flyer._left
|
|
2151
2310
|
? flyer._left.animate(
|
|
2152
2311
|
[
|
|
2153
2312
|
{ offset: 0, transform: "rotateY(8deg)" },
|
|
@@ -2159,8 +2318,8 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2159
2318
|
],
|
|
2160
2319
|
closeMotion
|
|
2161
2320
|
)
|
|
2162
|
-
: null;
|
|
2163
|
-
const aRightClose = flyer._right
|
|
2321
|
+
: null);
|
|
2322
|
+
const aRightClose = earlyRight || (flyer._right
|
|
2164
2323
|
? flyer._right.animate(
|
|
2165
2324
|
[
|
|
2166
2325
|
{ offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
|
|
@@ -2170,7 +2329,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2170
2329
|
],
|
|
2171
2330
|
closeMotion
|
|
2172
2331
|
)
|
|
2173
|
-
: null;
|
|
2332
|
+
: null);
|
|
2174
2333
|
const closedFaceOut = [
|
|
2175
2334
|
{ offset: 0, opacity: 1 },
|
|
2176
2335
|
{ offset: 0.18, opacity: 0.7 },
|
|
@@ -2347,7 +2506,7 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2347
2506
|
try { a.cancel(); } catch (_) {}
|
|
2348
2507
|
});
|
|
2349
2508
|
} catch (_) {}
|
|
2350
|
-
flyer
|
|
2509
|
+
applyPathPose(flyer, T0); // stay locked on frozen home until slot fully owns pixels
|
|
2351
2510
|
flyer.style.opacity = "0";
|
|
2352
2511
|
slot.style.opacity = "1";
|
|
2353
2512
|
slot.style.transform = "none";
|
|
@@ -2376,6 +2535,21 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2376
2535
|
openFromExpanded = false;
|
|
2377
2536
|
restoreSlot(slot, book);
|
|
2378
2537
|
try { slot.classList.remove("is-extracting"); } catch (_) {}
|
|
2538
|
+
// Pointer is often still over the returned spine. :hover is translateY(-8px)
|
|
2539
|
+
// scale(1.06), which measures ~8–11px taller than neighbors. Hold that off
|
|
2540
|
+
// until the pointer leaves; drop immediately if it is not hovering.
|
|
2541
|
+
if (targetMode === "spines" && slot) {
|
|
2542
|
+
slot.classList.add("is-seat");
|
|
2543
|
+
const releaseSeat = () => {
|
|
2544
|
+
slot.classList.remove("is-seat");
|
|
2545
|
+
slot.removeEventListener("pointerleave", releaseSeat);
|
|
2546
|
+
};
|
|
2547
|
+
slot.addEventListener("pointerleave", releaseSeat);
|
|
2548
|
+
requestAnimationFrame(() => {
|
|
2549
|
+
if (!slot.isConnected) return;
|
|
2550
|
+
if (!slot.matches(":hover")) releaseSeat();
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2379
2553
|
// ux23: drop close-path inline cover pin; resting .img-* CSS owns the seat again
|
|
2380
2554
|
if (targetMode === "covers" && slot) {
|
|
2381
2555
|
["background-image", "background-size", "background-position", "background-repeat", "background-color"].forEach((p) => {
|
|
@@ -2445,6 +2619,47 @@ export function mountBookshelf(root, options = {}) {
|
|
|
2445
2619
|
|
|
2446
2620
|
listen($("#backBtn"), "click", () => closeBookFlow());
|
|
2447
2621
|
|
|
2622
|
+
// Browser Back from the full note restores this page from bfcache, still open.
|
|
2623
|
+
// Land on the shelf at the scroll we saved, without replaying the close clock.
|
|
2624
|
+
listen(window, "pageshow", (event) => {
|
|
2625
|
+
if (!event.persisted || !openBook) return;
|
|
2626
|
+
const home = scrollLock
|
|
2627
|
+
? { x: scrollLock.x, y: scrollLock.y }
|
|
2628
|
+
: { x: scrollHome.x, y: scrollHome.y };
|
|
2629
|
+
try { releaseScrollLock(); } catch (_) {}
|
|
2630
|
+
openBook = null;
|
|
2631
|
+
openFromExpanded = false;
|
|
2632
|
+
expandedId = null;
|
|
2633
|
+
touchExpandedId = null;
|
|
2634
|
+
busy = false;
|
|
2635
|
+
flowSeq += 1;
|
|
2636
|
+
document.body.classList.remove("is-busy");
|
|
2637
|
+
noteView.classList.remove("is-open", "is-preparing");
|
|
2638
|
+
noteView.hidden = true;
|
|
2639
|
+
noteView.style.opacity = "";
|
|
2640
|
+
noteView.style.zIndex = "";
|
|
2641
|
+
if (stage) stage.replaceChildren();
|
|
2642
|
+
resetVeil();
|
|
2643
|
+
resetBackdrop();
|
|
2644
|
+
unparkShelf();
|
|
2645
|
+
shelfView.classList.remove("is-frozen", "is-dim", "is-parked");
|
|
2646
|
+
shelfView.style.opacity = "";
|
|
2647
|
+
try { shelfView.style.removeProperty("min-height"); } catch (_) {}
|
|
2648
|
+
renderShelf();
|
|
2649
|
+
writeScroll(home);
|
|
2650
|
+
requestAnimationFrame(() => writeScroll(home));
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
// Leaving for the post page: remember the shelf scroll so Back lands on it.
|
|
2654
|
+
listen(root, "click", (event) => {
|
|
2655
|
+
const target = event.target;
|
|
2656
|
+
if (!target || !target.closest) return;
|
|
2657
|
+
const link = target.closest("a.flyer-page-more, a.note-read");
|
|
2658
|
+
if (!link || !root.contains(link)) return;
|
|
2659
|
+
const y = scrollLock ? scrollLock.y : readScroll().y;
|
|
2660
|
+
try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2448
2663
|
// Escape closes note
|
|
2449
2664
|
listen(window, "keydown", (e) => {
|
|
2450
2665
|
if (e.key === "Escape" && openBook && !busy) closeBookFlow();
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export class BookshelfConfigError extends Error {
|
|
2
|
+
constructor(code, message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "BookshelfConfigError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SIZES = new Set(["thin", "s-short", "s-mid", "s-tall"]);
|
|
10
|
+
|
|
11
|
+
function record(value, code, message) {
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new BookshelfConfigError(code, message);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function optionalString(value) {
|
|
17
|
+
if (typeof value !== "string") return undefined;
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
return trimmed ? trimmed : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseFiller(value, where) {
|
|
23
|
+
if (value == null || value === false) return undefined;
|
|
24
|
+
if (value === true || value === "sand" || value === "cream") return value;
|
|
25
|
+
throw new BookshelfConfigError("filler", `${where} 的 filler 只能是 true、sand 或 cream。`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseSize(value, where) {
|
|
29
|
+
if (value == null || value === "") return undefined;
|
|
30
|
+
if (typeof value === "string" && SIZES.has(value)) return value;
|
|
31
|
+
throw new BookshelfConfigError("size", `${where} 的 size 只能是 thin、s-short、s-mid 或 s-tall。`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseBook(value, where, seen) {
|
|
35
|
+
const book = record(value, "book", `${where} 必须是映射。`);
|
|
36
|
+
const id = optionalString(book.id);
|
|
37
|
+
const title = optionalString(book.title);
|
|
38
|
+
if (!id || !title) throw new BookshelfConfigError("book", `${where} 需要 id 和 title。`);
|
|
39
|
+
if (seen.has(id)) throw new BookshelfConfigError("duplicate-id", `${where} 的 id「${id}」重复。`);
|
|
40
|
+
seen.add(id);
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
title,
|
|
44
|
+
author: optionalString(book.author),
|
|
45
|
+
spine: optionalString(book.spine),
|
|
46
|
+
size: parseSize(book.size, where),
|
|
47
|
+
cover: optionalString(book.cover),
|
|
48
|
+
color: optionalString(book.color),
|
|
49
|
+
filler: parseFiller(book.filler, where),
|
|
50
|
+
post: optionalString(book.post),
|
|
51
|
+
note: typeof book.note === "string" && book.note.trim() ? book.note.trim() : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseShelf(value, index, seen) {
|
|
56
|
+
const where = `shelves[${index}]`;
|
|
57
|
+
const shelf = record(value, "shelf", `${where} 必须是映射。`);
|
|
58
|
+
const id = optionalString(shelf.id);
|
|
59
|
+
const name = optionalString(shelf.name);
|
|
60
|
+
if (!id || !name) throw new BookshelfConfigError("shelf", `${where} 需要 id 和 name。`);
|
|
61
|
+
if (!Array.isArray(shelf.books) || shelf.books.length === 0) {
|
|
62
|
+
throw new BookshelfConfigError("shelf", `${where} 的 books 必须是非空数组。`);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
name,
|
|
67
|
+
books: shelf.books.map((book, bookIndex) => parseBook(book, `${where}.books[${bookIndex}]`, seen)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Validate `content/bookshelf.yaml`. Throws `BookshelfConfigError` with a stable `code`. */
|
|
72
|
+
export function parseBookshelfConfig(input) {
|
|
73
|
+
const data = record(input, "invalid-root", "content/bookshelf.yaml 必须是映射。");
|
|
74
|
+
const title = optionalString(data.title);
|
|
75
|
+
if (!title) throw new BookshelfConfigError("missing-title", "content/bookshelf.yaml 缺少 title。");
|
|
76
|
+
if (!Array.isArray(data.shelves) || data.shelves.length === 0) {
|
|
77
|
+
throw new BookshelfConfigError("shelves", "content/bookshelf.yaml 的 shelves 必须是非空数组。");
|
|
78
|
+
}
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
return {
|
|
81
|
+
title,
|
|
82
|
+
description: optionalString(data.description),
|
|
83
|
+
eyebrow: optionalString(data.eyebrow),
|
|
84
|
+
shelves: data.shelves.map((shelf, index) => parseShelf(shelf, index, seen)),
|
|
85
|
+
};
|
|
86
|
+
}
|