@commentray/render 0.3.3 → 0.3.5

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.
@@ -13,7 +13,8 @@ import { parseDualPaneScrollSyncStrategy } from "./code-browser-scroll-sync-stra
13
13
  import { DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX, READING_LEAD_ALIGN_TOLERANCE_CSS_PX, READING_VIEWPORT_BOTTOM_EDGE_CSS_PX, readingViewportTopInsetCssPx, } from "./reading-viewport-comfort.js";
14
14
  import { wireWideModeIntroTour } from "./code-browser-wide-intro-controller.js";
15
15
  import { readWebStorageItem, writeWebStorageItem } from "./code-browser-web-storage.js";
16
- import { dispatchCommentrayMermaidDone, wireBlockStretchBufferSync, } from "./block-stretch-buffer-sync.js";
16
+ import { applyBlockStretchRowBuffers, dispatchCommentrayMermaidDone, wireBlockStretchBufferSync, } from "./block-stretch-buffer-sync.js";
17
+ import { COMMENTRAY_MERMAID_MODULE_READY_EVENT } from "./commentray-mermaid-events.js";
17
18
  /**
18
19
  * Hub pages emit `./browse/…` relative to the site root. From `/…/browse/current.html` the browser
19
20
  * would otherwise resolve `./browse/…` against that folder and nest a second `browse/` segment.
@@ -30,30 +31,82 @@ function rewriteHubRelativeBrowseAnchorsIn(root) {
30
31
  el.href = resolveStaticBrowseHref(raw, path, origin);
31
32
  }
32
33
  }
34
+ /**
35
+ * The Mermaid bundle is loaded via a trailing `type="module"` script (async `import()`). The main
36
+ * client runs as a classic inline script **before** that module executes, so `cy.visit` + an
37
+ * immediate pane flip can call {@link runMermaidOnFreshDocNodes} while `commentrayMermaid` is still
38
+ * undefined. We enqueue roots and flush on {@link COMMENTRAY_MERMAID_MODULE_READY_EVENT}.
39
+ */
40
+ const pendingMermaidDocRoots = new Set();
41
+ const queuedMermaidDocRoots = new Set();
42
+ let mermaidQueueInFlight = null;
43
+ function collectFreshMermaidPreNodes(roots) {
44
+ const uniq = new Set();
45
+ for (const root of roots) {
46
+ for (const pre of Array.from(root.querySelectorAll("pre.mermaid"))) {
47
+ const wrap = pre.closest(".commentray-mermaid");
48
+ if (wrap !== null && wrap.querySelector("svg") !== null)
49
+ continue;
50
+ uniq.add(pre);
51
+ }
52
+ }
53
+ return Array.from(uniq);
54
+ }
55
+ function pumpQueuedMermaidRuns() {
56
+ if (mermaidQueueInFlight)
57
+ return mermaidQueueInFlight;
58
+ mermaidQueueInFlight = (async () => {
59
+ while (queuedMermaidDocRoots.size > 0) {
60
+ const m = globalThis
61
+ .commentrayMermaid;
62
+ if (!m) {
63
+ for (const root of queuedMermaidDocRoots)
64
+ pendingMermaidDocRoots.add(root);
65
+ queuedMermaidDocRoots.clear();
66
+ return;
67
+ }
68
+ const roots = Array.from(queuedMermaidDocRoots);
69
+ queuedMermaidDocRoots.clear();
70
+ const nodes = collectFreshMermaidPreNodes(roots);
71
+ if (nodes.length === 0)
72
+ continue;
73
+ try {
74
+ await m.run({ nodes });
75
+ dispatchCommentrayMermaidDone();
76
+ }
77
+ catch (err) {
78
+ console.error("Commentray: mermaid.run failed", err);
79
+ }
80
+ }
81
+ })().finally(() => {
82
+ mermaidQueueInFlight = null;
83
+ if (queuedMermaidDocRoots.size > 0)
84
+ void pumpQueuedMermaidRuns();
85
+ });
86
+ return mermaidQueueInFlight;
87
+ }
88
+ function queueMermaidRunForDocRoot(docRoot) {
89
+ queuedMermaidDocRoots.add(docRoot);
90
+ return pumpQueuedMermaidRuns();
91
+ }
92
+ function flushPendingMermaidDocRoots() {
93
+ const roots = Array.from(pendingMermaidDocRoots);
94
+ pendingMermaidDocRoots.clear();
95
+ for (const root of roots) {
96
+ void queueMermaidRunForDocRoot(root);
97
+ }
98
+ }
99
+ globalThis.addEventListener(COMMENTRAY_MERMAID_MODULE_READY_EVENT, () => {
100
+ flushPendingMermaidDocRoots();
101
+ });
33
102
  function runMermaidOnFreshDocNodes(docBody) {
34
103
  if (typeof globalThis.location !== "undefined" && globalThis.location.protocol === "file:")
35
104
  return Promise.resolve();
36
- /** Only fenced diagram sources; Mermaid leaves other `.mermaid` nodes in the tree after render. */
37
- const allPres = Array.from(docBody.querySelectorAll("pre.mermaid"));
38
- /** Do not re-run on wrappers that already have SVG (avoids corrupting output after dual-mobile pane flip). */
39
- const nodes = allPres.filter((pre) => {
40
- const wrap = pre.closest(".commentray-mermaid");
41
- return wrap === null || wrap.querySelector("svg") === null;
42
- });
43
- if (nodes.length === 0)
44
- return Promise.resolve();
45
- const m = globalThis
46
- .commentrayMermaid;
47
- if (!m)
105
+ if (!globalThis.commentrayMermaid) {
106
+ pendingMermaidDocRoots.add(docBody);
48
107
  return Promise.resolve();
49
- return m
50
- .run({ nodes })
51
- .then(() => {
52
- dispatchCommentrayMermaidDone();
53
- })
54
- .catch((err) => {
55
- console.error("Commentray: mermaid.run failed", err);
56
- });
108
+ }
109
+ return queueMermaidRunForDocRoot(docBody);
57
110
  }
58
111
  function clamp(n, lo, hi) {
59
112
  return Math.max(lo, Math.min(hi, n));
@@ -1038,6 +1091,14 @@ function emptyBrowsePreviewInnerHtml(scope, filePathLabel, mutable) {
1038
1091
  const hint = emptyBrowsePreviewHint("commentray-and-paths", fb.length, fb.length, true);
1039
1092
  return emptySearchBrowsePreviewInnerHtml(hint, fb, hitCtx);
1040
1093
  }
1094
+ /**
1095
+ * List rows use `focus({ preventScroll: true })` so the page does not jump; follow with
1096
+ * `scrollIntoView` so overflow panels (`#search-results`, `#documented-files-tree`) still scroll.
1097
+ */
1098
+ function focusListRowAndReveal(el) {
1099
+ el.focus({ preventScroll: true });
1100
+ el.scrollIntoView({ block: "nearest", inline: "nearest" });
1101
+ }
1041
1102
  function wireSearchResultsHitListKeyboard(searchResults, searchInput) {
1042
1103
  searchResults.addEventListener("keydown", (e) => {
1043
1104
  if (e.isComposing || searchResults.hidden)
@@ -1052,17 +1113,17 @@ function wireSearchResultsHitListKeyboard(searchResults, searchInput) {
1052
1113
  if (idx < 0)
1053
1114
  return;
1054
1115
  if (e.key === "ArrowDown" && idx < hits.length - 1) {
1055
- hits[idx + 1].focus({ preventScroll: true });
1116
+ focusListRowAndReveal(hits[idx + 1]);
1056
1117
  e.preventDefault();
1057
1118
  return;
1058
1119
  }
1059
1120
  if (e.key === "ArrowUp") {
1060
1121
  if (idx > 0) {
1061
- hits[idx - 1].focus({ preventScroll: true });
1122
+ focusListRowAndReveal(hits[idx - 1]);
1062
1123
  e.preventDefault();
1063
1124
  return;
1064
1125
  }
1065
- searchInput.focus({ preventScroll: true });
1126
+ focusListRowAndReveal(searchInput);
1066
1127
  e.preventDefault();
1067
1128
  }
1068
1129
  });
@@ -1076,7 +1137,7 @@ function wireSearchInputKeyboard(searchInput, searchResults, actions) {
1076
1137
  if (!searchResults.hidden) {
1077
1138
  const hits = listSearchHitButtons(searchResults);
1078
1139
  if (hits.length > 0 && document.activeElement === searchInput) {
1079
- hits[0].focus({ preventScroll: true });
1140
+ focusListRowAndReveal(hits[0]);
1080
1141
  e.preventDefault();
1081
1142
  return;
1082
1143
  }
@@ -2262,7 +2323,34 @@ function focusDocumentedFilesFilterInput() {
2262
2323
  const el = document.getElementById("documented-files-filter");
2263
2324
  if (!(el instanceof HTMLInputElement))
2264
2325
  return;
2265
- el.focus({ preventScroll: true });
2326
+ focusListRowAndReveal(el);
2327
+ }
2328
+ function focusDocumentedFilesHubSummaryIfPresent(hub) {
2329
+ const sum = hub.querySelector("summary");
2330
+ if (sum instanceof HTMLElement)
2331
+ sum.focus({ preventScroll: true });
2332
+ }
2333
+ /** Escape to close; pointer outside `#documented-files-hub` to close (capture). */
2334
+ function wireDocumentedFilesHubDismissalHandlers(hub) {
2335
+ function onEscape(ev) {
2336
+ if (!hub.open || ev.key !== "Escape")
2337
+ return;
2338
+ ev.preventDefault();
2339
+ hub.open = false;
2340
+ focusDocumentedFilesHubSummaryIfPresent(hub);
2341
+ }
2342
+ document.addEventListener("keydown", onEscape, true);
2343
+ document.addEventListener("pointerdown", (ev) => {
2344
+ if (!hub.open)
2345
+ return;
2346
+ const t = ev.target;
2347
+ if (!(t instanceof Node))
2348
+ return;
2349
+ if (hub.contains(t))
2350
+ return;
2351
+ hub.open = false;
2352
+ focusDocumentedFilesHubSummaryIfPresent(hub);
2353
+ }, true);
2266
2354
  }
2267
2355
  function wireDocumentedFilesTree() {
2268
2356
  const hub = document.getElementById("documented-files-hub");
@@ -2313,16 +2401,7 @@ function wireDocumentedFilesTree() {
2313
2401
  return;
2314
2402
  void hydrateTree();
2315
2403
  });
2316
- function onDocumentedFilesHubEscape(ev) {
2317
- if (!detailsHub.open || ev.key !== "Escape")
2318
- return;
2319
- ev.preventDefault();
2320
- detailsHub.open = false;
2321
- const sum = detailsHub.querySelector("summary");
2322
- if (sum instanceof HTMLElement)
2323
- sum.focus({ preventScroll: true });
2324
- }
2325
- document.addEventListener("keydown", onDocumentedFilesHubEscape, true);
2404
+ wireDocumentedFilesHubDismissalHandlers(detailsHub);
2326
2405
  treeMount.addEventListener("keydown", (e) => {
2327
2406
  if (!detailsHub.open || e.isComposing)
2328
2407
  return;
@@ -2337,19 +2416,19 @@ function wireDocumentedFilesTree() {
2337
2416
  return;
2338
2417
  if (e.key === "ArrowDown") {
2339
2418
  if (idx < links.length - 1) {
2340
- links[idx + 1].focus({ preventScroll: true });
2419
+ focusListRowAndReveal(links[idx + 1]);
2341
2420
  e.preventDefault();
2342
2421
  }
2343
2422
  return;
2344
2423
  }
2345
2424
  if (e.key === "ArrowUp") {
2346
2425
  if (idx > 0) {
2347
- links[idx - 1].focus({ preventScroll: true });
2426
+ focusListRowAndReveal(links[idx - 1]);
2348
2427
  e.preventDefault();
2349
2428
  return;
2350
2429
  }
2351
2430
  if (filterInput instanceof HTMLInputElement) {
2352
- filterInput.focus({ preventScroll: true });
2431
+ focusListRowAndReveal(filterInput);
2353
2432
  e.preventDefault();
2354
2433
  }
2355
2434
  }
@@ -2366,7 +2445,7 @@ function wireDocumentedFilesTree() {
2366
2445
  const links = listDocumentedTreeFileLinks(treeMount);
2367
2446
  if (links.length === 0)
2368
2447
  return;
2369
- links[0].focus({ preventScroll: true });
2448
+ focusListRowAndReveal(links[0]);
2370
2449
  e.preventDefault();
2371
2450
  });
2372
2451
  }
@@ -2411,6 +2490,38 @@ const DUAL_MOBILE_SINGLE_PANE_MQ = "(max-width: 767px)";
2411
2490
  function normalizedDualMobilePane(v) {
2412
2491
  return v === "code" ? "code" : "doc";
2413
2492
  }
2493
+ function splitLocationHashTokens(rawHash) {
2494
+ const hash = rawHash.replace(/^#/, "").trim();
2495
+ if (hash.length === 0)
2496
+ return [];
2497
+ return hash
2498
+ .split(/--|&/)
2499
+ .map((t) => t.trim())
2500
+ .filter((t) => t.length > 0);
2501
+ }
2502
+ function mobilePaneHashToken(pane) {
2503
+ return `mobile-pane-${pane}`;
2504
+ }
2505
+ function mobilePaneFromLocationHash(rawHash) {
2506
+ const tokens = splitLocationHashTokens(rawHash);
2507
+ for (const token of tokens) {
2508
+ if (token === "mobile-pane-code")
2509
+ return "code";
2510
+ if (token === "mobile-pane-doc")
2511
+ return "doc";
2512
+ }
2513
+ return null;
2514
+ }
2515
+ function updateLocationHashToken(token, shouldReplace) {
2516
+ const tokens = splitLocationHashTokens(globalThis.location.hash).filter((t) => !shouldReplace(t));
2517
+ tokens.push(token);
2518
+ const u = new URL(globalThis.location.href);
2519
+ u.hash = tokens.length > 0 ? tokens.join("&") : "";
2520
+ globalThis.history.replaceState(globalThis.history.state, "", u.toString());
2521
+ }
2522
+ function updateLocationHashForMobilePane(pane) {
2523
+ updateLocationHashToken(mobilePaneHashToken(pane), (t) => /^mobile-pane-(?:code|doc)$/.test(t));
2524
+ }
2414
2525
  function isNarrowViewport() {
2415
2526
  return globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ).matches;
2416
2527
  }
@@ -2634,6 +2745,7 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, sig
2634
2745
  if (curPane === "doc") {
2635
2746
  shell.setAttribute("data-dual-mobile-pane", "code");
2636
2747
  writeWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE, "code");
2748
+ updateLocationHashForMobilePane("code");
2637
2749
  }
2638
2750
  }
2639
2751
  syncSourceMarkdownFlipA11y();
@@ -2662,6 +2774,9 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, sig
2662
2774
  function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
2663
2775
  const mq = globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ);
2664
2776
  function readStoredPane() {
2777
+ const fromHash = mobilePaneFromLocationHash(globalThis.location.hash);
2778
+ if (fromHash !== null)
2779
+ return fromHash;
2665
2780
  return normalizedDualMobilePane(readWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE));
2666
2781
  }
2667
2782
  function applyForViewport() {
@@ -2688,6 +2803,7 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
2688
2803
  }
2689
2804
  shell.setAttribute("data-dual-mobile-pane", next);
2690
2805
  writeWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE, next);
2806
+ updateLocationHashForMobilePane(next);
2691
2807
  globalThis.requestAnimationFrame(() => {
2692
2808
  globalThis.requestAnimationFrame(() => {
2693
2809
  if (next === "code") {
@@ -2714,49 +2830,64 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
2714
2830
  wireDualMobilePaneFlipScrollAffordance(flipBtn, flipScrollBtn, mq);
2715
2831
  }
2716
2832
  mq.addEventListener("change", applyForViewport);
2833
+ globalThis.addEventListener("hashchange", applyForViewport);
2717
2834
  applyForViewport();
2718
2835
  }
2719
2836
  function wireStretchMobilePaneFlip(shell, codePane, flipBtn, flipScrollBtn, onAfterFlip) {
2720
- void codePane;
2721
- void onAfterFlip;
2722
- const mq = globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ);
2723
- function readStoredPane() {
2724
- return normalizedDualMobilePane(readWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE));
2725
- }
2726
- function applyForViewport() {
2727
- if (mq.matches) {
2728
- syncSinglePaneShellState(shell, true);
2729
- shell.setAttribute("data-dual-mobile-pane", readStoredPane());
2837
+ const stretchTable = codePane instanceof HTMLTableElement ? codePane : null;
2838
+ let anchorRow = null;
2839
+ let anchorTopBefore = 0;
2840
+ let rootTopBefore = 0;
2841
+ const captureAnchor = () => {
2842
+ const root = rootScrollingElement();
2843
+ rootTopBefore = root.scrollTop;
2844
+ if (stretchTable === null) {
2845
+ anchorRow = null;
2846
+ anchorTopBefore = 0;
2847
+ return;
2730
2848
  }
2731
- else {
2732
- syncSinglePaneShellState(shell, false);
2733
- shell.removeAttribute("data-dual-mobile-pane");
2849
+ const rows = stretchBlockRows(stretchTable);
2850
+ anchorRow = rows.find((row) => row.getBoundingClientRect().bottom > 0) ?? null;
2851
+ anchorTopBefore = anchorRow?.getBoundingClientRect().top ?? 0;
2852
+ };
2853
+ const restoreAnchor = () => {
2854
+ const root = rootScrollingElement();
2855
+ if (anchorRow !== null) {
2856
+ const delta = anchorRow.getBoundingClientRect().top - anchorTopBefore;
2857
+ if (Math.abs(delta) > 0.5) {
2858
+ const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
2859
+ root.scrollTop = clamp(root.scrollTop + delta, 0, maxY);
2860
+ return;
2861
+ }
2734
2862
  }
2735
- }
2736
- const runFlip = () => {
2737
- if (!mq.matches)
2738
- return;
2739
- const cur = normalizedDualMobilePane(shell.getAttribute("data-dual-mobile-pane"));
2740
- const next = cur === "code" ? "doc" : "code";
2741
- shell.setAttribute("data-dual-mobile-pane", next);
2742
- writeWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE, next);
2743
- // When revealing the doc pane, (re)run Mermaid — diagrams inside display:none cells are
2744
- // skipped on initial load, so they need a triggered pass once the cells become visible.
2745
- if (next === "doc") {
2746
- queueMicrotask(() => {
2863
+ const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
2864
+ root.scrollTop = clamp(rootTopBefore, 0, maxY);
2865
+ };
2866
+ const applyBuffersAndRestore = () => {
2867
+ if (stretchTable !== null)
2868
+ applyBlockStretchRowBuffers(stretchTable);
2869
+ restoreAnchor();
2870
+ };
2871
+ const runners = {
2872
+ syncFromCodeToDoc: () => { },
2873
+ syncFromDocToCode: () => { },
2874
+ prepareMobileFlipToCode: captureAnchor,
2875
+ prepareMobileFlipToDoc: captureAnchor,
2876
+ finishMobileFlipToCode: () => {
2877
+ applyBuffersAndRestore();
2878
+ onAfterFlip?.();
2879
+ },
2880
+ finishMobileFlipToDoc: () => {
2881
+ applyBuffersAndRestore();
2882
+ onAfterFlip?.();
2883
+ void runMermaidOnFreshDocNodes(shell).then(() => {
2747
2884
  requestAnimationFrame(() => {
2748
- void runMermaidOnFreshDocNodes(shell);
2885
+ applyBuffersAndRestore();
2749
2886
  });
2750
2887
  });
2751
- }
2888
+ },
2752
2889
  };
2753
- flipBtn.addEventListener("click", runFlip);
2754
- if (flipScrollBtn) {
2755
- flipScrollBtn.addEventListener("click", runFlip);
2756
- wireDualMobilePaneFlipScrollAffordance(flipBtn, flipScrollBtn, mq);
2757
- }
2758
- mq.addEventListener("change", applyForViewport);
2759
- applyForViewport();
2890
+ wireDualMobilePaneFlip(shell, flipBtn, runners, flipScrollBtn);
2760
2891
  }
2761
2892
  /** Multi-angle stretch swaps `#shell` innerHTML; disconnect the previous table observer so listeners do not accumulate. */
2762
2893
  let stretchRowBufferSyncHandle = null;
@@ -3687,6 +3818,11 @@ function permalinkHashSuffixFromUi() {
3687
3818
  pushUnique(`angle-${encodeURIComponent(id)}`);
3688
3819
  }
3689
3820
  }
3821
+ const shell = document.getElementById("shell");
3822
+ if (shell instanceof HTMLElement && shell.getAttribute("data-mobile-single-pane") === "true") {
3823
+ const pane = normalizedDualMobilePane(shell.getAttribute("data-dual-mobile-pane"));
3824
+ pushUnique(mobilePaneHashToken(pane));
3825
+ }
3690
3826
  const activeAnchor = activeCommentrayHashTokenFromViewport();
3691
3827
  if (activeAnchor)
3692
3828
  pushUnique(activeAnchor);