@commentray/render 0.1.2 → 0.3.0
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/dist/block-stretch-buffer-sync.d.ts +16 -0
- package/dist/block-stretch-buffer-sync.d.ts.map +1 -0
- package/dist/block-stretch-buffer-sync.js +271 -0
- package/dist/block-stretch-buffer-sync.js.map +1 -0
- package/dist/block-stretch-layout.d.ts +18 -5
- package/dist/block-stretch-layout.d.ts.map +1 -1
- package/dist/block-stretch-layout.js +121 -43
- package/dist/block-stretch-layout.js.map +1 -1
- package/dist/browse-page-slug.d.ts +3 -4
- package/dist/browse-page-slug.d.ts.map +1 -1
- package/dist/browse-page-slug.js +3 -4
- package/dist/browse-page-slug.js.map +1 -1
- package/dist/build-commentray-nav-search.d.ts +2 -2
- package/dist/build-commentray-nav-search.d.ts.map +1 -1
- package/dist/build-commentray-nav-search.js +8 -3
- package/dist/build-commentray-nav-search.js.map +1 -1
- package/dist/code-browser-block-rays.d.ts +11 -0
- package/dist/code-browser-block-rays.d.ts.map +1 -1
- package/dist/code-browser-block-rays.js +25 -5
- package/dist/code-browser-block-rays.js.map +1 -1
- package/dist/code-browser-client.bundle.js +12 -11
- package/dist/code-browser-client.js +1484 -265
- package/dist/code-browser-client.js.map +1 -1
- package/dist/code-browser-pair-nav.d.ts +9 -2
- package/dist/code-browser-pair-nav.d.ts.map +1 -1
- package/dist/code-browser-pair-nav.js +53 -14
- package/dist/code-browser-pair-nav.js.map +1 -1
- package/dist/code-browser-scroll-buffer-equalize.d.ts +25 -0
- package/dist/code-browser-scroll-buffer-equalize.d.ts.map +1 -0
- package/dist/code-browser-scroll-buffer-equalize.js +316 -0
- package/dist/code-browser-scroll-buffer-equalize.js.map +1 -0
- package/dist/code-browser-scroll-sync-monotonic.d.ts +17 -0
- package/dist/code-browser-scroll-sync-monotonic.d.ts.map +1 -0
- package/dist/code-browser-scroll-sync-monotonic.js +22 -0
- package/dist/code-browser-scroll-sync-monotonic.js.map +1 -0
- package/dist/code-browser-scroll-sync-strategy.d.ts +12 -0
- package/dist/code-browser-scroll-sync-strategy.d.ts.map +1 -0
- package/dist/code-browser-scroll-sync-strategy.js +28 -0
- package/dist/code-browser-scroll-sync-strategy.js.map +1 -0
- package/dist/code-browser-scroll-sync.d.ts +2 -2
- package/dist/code-browser-scroll-sync.d.ts.map +1 -1
- package/dist/code-browser-scroll-sync.js +1 -1
- package/dist/code-browser-scroll-sync.js.map +1 -1
- package/dist/code-browser-smooth-reveal-dedup.d.ts +25 -0
- package/dist/code-browser-smooth-reveal-dedup.d.ts.map +1 -0
- package/dist/code-browser-smooth-reveal-dedup.js +25 -0
- package/dist/code-browser-smooth-reveal-dedup.js.map +1 -0
- package/dist/code-browser.d.ts +25 -8
- package/dist/code-browser.d.ts.map +1 -1
- package/dist/code-browser.js +359 -86
- package/dist/code-browser.js.map +1 -1
- package/dist/commentray-anchor-viewport-probe.d.ts +5 -1
- package/dist/commentray-anchor-viewport-probe.d.ts.map +1 -1
- package/dist/commentray-anchor-viewport-probe.js +8 -2
- package/dist/commentray-anchor-viewport-probe.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/inject-md-line-anchors.d.ts +1 -1
- package/dist/inject-md-line-anchors.d.ts.map +1 -1
- package/dist/inject-md-line-anchors.js +9 -5
- package/dist/inject-md-line-anchors.js.map +1 -1
- package/dist/markdown-pipeline.js +1 -1
- package/dist/markdown-pipeline.js.map +1 -1
- package/dist/mermaid-runtime-html.d.ts.map +1 -1
- package/dist/mermaid-runtime-html.js +4 -1
- package/dist/mermaid-runtime-html.js.map +1 -1
- package/dist/reading-viewport-comfort.d.ts +12 -0
- package/dist/reading-viewport-comfort.d.ts.map +1 -0
- package/dist/reading-viewport-comfort.js +14 -0
- package/dist/reading-viewport-comfort.js.map +1 -0
- package/package.json +2 -2
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { FuzzySearcher, PrefixSearcher, Query, SearcherFactory, SubstringSearcher, } from "@m31coding/fuzzy-search";
|
|
2
|
-
import { activeBlockIdForCommentrayLine0, activeBlockIdForViewport, clampViewportYToGutterLocal, codeLineDomIndex0, dedupeBlockScrollLinksById, gutterRayBezierPaths, maxRenderableCommentaryContentBottomViewport, nextBlockLinkInCommentrayOrder, sortBlockLinksBySource, } from "./code-browser-block-rays.js";
|
|
3
|
-
import { mirroredScrollTop,
|
|
2
|
+
import { activeBlockIdForCommentrayLine0, activeBlockIdForViewport, clampViewportYToGutterLocal, codeLineDomIndex0, dedupeBlockScrollLinksById, gutterRayBezierPaths, commentaryGutterDocBandBottomViewport, maxRenderableCommentaryContentBottomViewport, nextBlockLinkInCommentrayOrder, sortBlockLinksBySource, } from "./code-browser-block-rays.js";
|
|
3
|
+
import { blockStrictlyContainingSourceViewportLine, mirroredScrollTop, pickBlockScrollLinkForCommentrayScroll, sourceTopLineStrictlyBeforeFirstIndexLine, } from "./code-browser-scroll-sync.js";
|
|
4
4
|
import { maxCommentrayAnchorLine0AtOrAboveViewportY } from "./commentray-anchor-viewport-probe.js";
|
|
5
5
|
import { decodeBase64Utf8 } from "./code-browser-encoding.js";
|
|
6
6
|
import { readEmbeddedRawB64Strings } from "./code-browser-embedded-payload.js";
|
|
7
7
|
import { escapeHtmlHighlightingSearchTokens, filterPairsByDocumentedTreeQuery, findOrderedTokenSpans, lineAtIndex, offsetToLineIndex, pathRowsFromDocumentedPairs, tokenizeQuery, uniqueSourceFilePreviewRows, } from "./code-browser-search.js";
|
|
8
|
-
import { findDocumentedPair, isHubRelativeStaticBrowseHref, isSameDocumentedPair, normPosixPath, resolveStaticBrowseHref,
|
|
8
|
+
import { findDocumentedPair, isHubRelativeStaticBrowseHref, isSameDocumentedPair, normPosixPath, resolveStaticBrowseHref, staticBrowseHrefForShellDataAttribute, } from "./code-browser-pair-nav.js";
|
|
9
9
|
import { COMMENTRAY_COLOR_THEME_STORAGE_KEY, applyCommentrayColorTheme, nextCommentrayColorThemeMode, parseCommentrayColorThemeMode, } from "./code-browser-color-theme.js";
|
|
10
|
+
import { shouldRevertPartnerScrollForMonotonicity } from "./code-browser-scroll-sync-monotonic.js";
|
|
11
|
+
import { SMOOTH_REVEAL_INFLIGHT_DEDUP_MS } from "./code-browser-smooth-reveal-dedup.js";
|
|
12
|
+
import { parseDualPaneScrollSyncStrategy } from "./code-browser-scroll-sync-strategy.js";
|
|
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";
|
|
10
14
|
import { wireWideModeIntroTour } from "./code-browser-wide-intro-controller.js";
|
|
11
15
|
import { readWebStorageItem, writeWebStorageItem } from "./code-browser-web-storage.js";
|
|
16
|
+
import { dispatchCommentrayMermaidDone, wireBlockStretchBufferSync, } from "./block-stretch-buffer-sync.js";
|
|
12
17
|
/**
|
|
13
18
|
* Hub pages emit `./browse/…` relative to the site root. From `/…/browse/current.html` the browser
|
|
14
19
|
* would otherwise resolve that to `…/browse/browse/…`.
|
|
@@ -27,7 +32,7 @@ function rewriteHubRelativeBrowseAnchorsIn(root) {
|
|
|
27
32
|
}
|
|
28
33
|
function runMermaidOnFreshDocNodes(docBody) {
|
|
29
34
|
if (typeof globalThis.location !== "undefined" && globalThis.location.protocol === "file:")
|
|
30
|
-
return;
|
|
35
|
+
return Promise.resolve();
|
|
31
36
|
/** Only fenced diagram sources; Mermaid leaves other `.mermaid` nodes in the tree after render. */
|
|
32
37
|
const allPres = Array.from(docBody.querySelectorAll("pre.mermaid"));
|
|
33
38
|
/** Do not re-run on wrappers that already have SVG (avoids corrupting output after dual-mobile pane flip). */
|
|
@@ -36,12 +41,17 @@ function runMermaidOnFreshDocNodes(docBody) {
|
|
|
36
41
|
return wrap === null || wrap.querySelector("svg") === null;
|
|
37
42
|
});
|
|
38
43
|
if (nodes.length === 0)
|
|
39
|
-
return;
|
|
44
|
+
return Promise.resolve();
|
|
40
45
|
const m = globalThis
|
|
41
46
|
.commentrayMermaid;
|
|
42
47
|
if (!m)
|
|
43
|
-
return;
|
|
44
|
-
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
return m
|
|
50
|
+
.run({ nodes })
|
|
51
|
+
.then(() => {
|
|
52
|
+
dispatchCommentrayMermaidDone();
|
|
53
|
+
})
|
|
54
|
+
.catch((err) => {
|
|
45
55
|
console.error("Commentray: mermaid.run failed", err);
|
|
46
56
|
});
|
|
47
57
|
}
|
|
@@ -54,7 +64,14 @@ function scrollTopToAlignChildTop(scrollEl, child, leadCssPx) {
|
|
|
54
64
|
const sr = scrollEl.getBoundingClientRect();
|
|
55
65
|
return scrollEl.scrollTop + (cr.top - sr.top) - scrollEl.clientTop - leadCssPx;
|
|
56
66
|
}
|
|
57
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Partner write for **proportional** mirror plans (`mirrorI` / `mirrorW`), which produce a fresh
|
|
69
|
+
* target on every driver scroll event. Must be **instant** — wrapping these in `behavior: "smooth"`
|
|
70
|
+
* makes the partner re-ease toward a moving target each frame, which the eye reads as up/down
|
|
71
|
+
* jitter while the user keeps scrolling. Block snaps also use instant writes (see
|
|
72
|
+
* {@link applyRevealChildInPane} and `docs/spec/dual-pane-scroll-sync.md`). Sub-pixel skip avoids
|
|
73
|
+
* feedback loops when zoom math matches the current position.
|
|
74
|
+
*/
|
|
58
75
|
function applyScrollTopClamped(scrollEl, nextTop) {
|
|
59
76
|
const maxY = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
|
|
60
77
|
const clamped = clamp(nextTop, 0, maxY);
|
|
@@ -75,6 +92,41 @@ function rootScrollingElement() {
|
|
|
75
92
|
return s;
|
|
76
93
|
return document.documentElement;
|
|
77
94
|
}
|
|
95
|
+
function readPaneVerticalScroll(pane) {
|
|
96
|
+
return paneUsesInternalYScroll(pane) ? pane.scrollTop : rootScrollingElement().scrollTop;
|
|
97
|
+
}
|
|
98
|
+
/** Monotonic revert must not use {@link applyScrollTopClamped}’s sub-pixel skip, or the partner never moves back. */
|
|
99
|
+
function writePaneVerticalScrollForced(partnerPane, target) {
|
|
100
|
+
if (paneUsesInternalYScroll(partnerPane)) {
|
|
101
|
+
const maxY = Math.max(0, partnerPane.scrollHeight - partnerPane.clientHeight);
|
|
102
|
+
partnerPane.scrollTop = clamp(target, 0, maxY);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const root = rootScrollingElement();
|
|
106
|
+
const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
|
|
107
|
+
root.scrollTop = clamp(target, 0, maxY);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Enforces dual-pane scroll sync monotonicity (`docs/spec/dual-pane-scroll-sync.md`):
|
|
111
|
+
* partner never moves opposite the driver on a decisive scroll step.
|
|
112
|
+
*/
|
|
113
|
+
function enforceScrollSyncMonotonic(args) {
|
|
114
|
+
const { driverDelta, partnerBefore, partnerPane, axis } = args;
|
|
115
|
+
const partnerAfter = readPaneVerticalScroll(partnerPane);
|
|
116
|
+
if (shouldRevertPartnerScrollForMonotonicity({ driverDelta, partnerBefore, partnerAfter })) {
|
|
117
|
+
if (scrollSyncTraceFeatureFlag()) {
|
|
118
|
+
globalThis.console.warn("[commentray:scroll-sync] monotonic.revert", {
|
|
119
|
+
t: Math.round(performance.now()),
|
|
120
|
+
axis: axis ?? "?",
|
|
121
|
+
driverDelta,
|
|
122
|
+
partnerBefore,
|
|
123
|
+
partnerAfter,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
writePaneVerticalScrollForced(partnerPane, partnerBefore);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Window-root counterpart of {@link applyScrollTopClamped} for the narrow single-flow layout — same instant-vs-jitter rationale. */
|
|
78
130
|
function applyWindowScrollRatio(ratio) {
|
|
79
131
|
const root = rootScrollingElement();
|
|
80
132
|
const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
|
|
@@ -86,10 +138,16 @@ function applyWindowScrollRatio(ratio) {
|
|
|
86
138
|
/**
|
|
87
139
|
* Reveal `child` near the top of the reading surface: the pane’s own scrollport when it scrolls
|
|
88
140
|
* internally (desktop dual-pane), otherwise the document root (narrow flow layout).
|
|
141
|
+
*
|
|
142
|
+
* **Always instant** (`scrollTop` / `scrollTo` auto): smooth interpolation on a partner pane during
|
|
143
|
+
* bidirectional sync multiplies `scroll` events, shrinks the useful echo-suppression window, and
|
|
144
|
+
* fights {@link enforceScrollSyncMonotonic} — reads as rubber-band / desync. See
|
|
145
|
+
* `docs/spec/dual-pane-scroll-sync.md` (“Scroll-behavior on dual-pane scrollports”).
|
|
89
146
|
*/
|
|
90
147
|
function applyRevealChildInPane(scrollport, child, leadCssPx) {
|
|
91
148
|
if (paneUsesInternalYScroll(scrollport)) {
|
|
92
|
-
|
|
149
|
+
const target = Math.round(scrollTopToAlignChildTop(scrollport, child, leadCssPx));
|
|
150
|
+
applyScrollTopClamped(scrollport, target);
|
|
93
151
|
return;
|
|
94
152
|
}
|
|
95
153
|
const root = rootScrollingElement();
|
|
@@ -101,23 +159,228 @@ function applyRevealChildInPane(scrollport, child, leadCssPx) {
|
|
|
101
159
|
return;
|
|
102
160
|
root.scrollTop = clamped;
|
|
103
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* True when `child` is already placed like {@link applyRevealChildInPane} would (same few-pixel
|
|
164
|
+
* tolerance as code→doc’s {@link commentrayBlockAnchorAlignedWithLead}).
|
|
165
|
+
*/
|
|
166
|
+
function revealTargetAlreadyAtLead(scrollport, child, leadCssPx) {
|
|
167
|
+
if (paneUsesInternalYScroll(scrollport)) {
|
|
168
|
+
const cr = child.getBoundingClientRect();
|
|
169
|
+
const sr = scrollport.getBoundingClientRect();
|
|
170
|
+
const dist = cr.top - sr.top - scrollport.clientTop;
|
|
171
|
+
return Math.abs(dist - leadCssPx) <= READING_LEAD_ALIGN_TOLERANCE_CSS_PX;
|
|
172
|
+
}
|
|
173
|
+
const cr = child.getBoundingClientRect();
|
|
174
|
+
return Math.abs(cr.top - leadCssPx) <= READING_LEAD_ALIGN_TOLERANCE_CSS_PX;
|
|
175
|
+
}
|
|
176
|
+
/** True when the block anchor for `mdLine0` is already aligned like {@link applyRevealChildInPane} would. */
|
|
177
|
+
function commentrayBlockAnchorAlignedWithLead(docPane, mdLine0, leadCssPx) {
|
|
178
|
+
const anchor = docPane.querySelector(`[data-commentray-line="${String(mdLine0)}"]`);
|
|
179
|
+
if (!(anchor instanceof HTMLElement))
|
|
180
|
+
return false;
|
|
181
|
+
return revealTargetAlreadyAtLead(docPane, anchor, leadCssPx);
|
|
182
|
+
}
|
|
183
|
+
/** Resolve the code line element used for doc→code block snaps (same lookup as {@link applyDocToCodeFlipPlanImpl}). */
|
|
184
|
+
function findCodeLineElementForBlockSnap(codePane, lineIdPrefix, src0) {
|
|
185
|
+
const exact = codePane.querySelector(`#${lineIdPrefix}${String(src0)}`);
|
|
186
|
+
if (exact instanceof HTMLElement)
|
|
187
|
+
return exact;
|
|
188
|
+
return findAnchorAtOrAfter(sourceAnchorsFromPrefix(lineIdPrefix), src0);
|
|
189
|
+
}
|
|
190
|
+
/** True when the source line for `src0` is already aligned like {@link applyRevealChildInPane} would. */
|
|
191
|
+
function sourceCodeLineAnchorAlignedWithLead(codePane, lineIdPrefix, src0, leadCssPx) {
|
|
192
|
+
const el = findCodeLineElementForBlockSnap(codePane, lineIdPrefix, src0);
|
|
193
|
+
if (!el)
|
|
194
|
+
return false;
|
|
195
|
+
return revealTargetAlreadyAtLead(codePane, el, leadCssPx);
|
|
196
|
+
}
|
|
197
|
+
/** If the code line head is already at the block-snap lead, doc→code should not re-issue a reveal (symmetric with code→doc). */
|
|
198
|
+
function docToCodePlanIfCodeAnchorAlreadyAligned(codePane, lineIdPrefix, src0, traceReason, extraFields) {
|
|
199
|
+
if (!sourceCodeLineAnchorAlignedWithLead(codePane, lineIdPrefix, src0, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
scrollSyncTrace("doc→code.plan", {
|
|
203
|
+
reason: traceReason,
|
|
204
|
+
...extraFields,
|
|
205
|
+
src0,
|
|
206
|
+
lineIdPrefix,
|
|
207
|
+
});
|
|
208
|
+
return { k: "noop", skipProportionalFallbackOnFlip: true };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* After the strict `[lo, hiExclusive)` span ends, keep treating the same block as active for a few
|
|
212
|
+
* more source lines so `line1` probe noise at the boundary does not flip code→doc between block
|
|
213
|
+
* snap and gap mirror every frame.
|
|
214
|
+
*/
|
|
215
|
+
const SOURCE_BLOCK_TRAILING_SLACK_LINES = 6;
|
|
216
|
+
function blockForCodeToDocSync(links, line1, sticky) {
|
|
217
|
+
const strict = blockStrictlyContainingSourceViewportLine(links, line1);
|
|
218
|
+
if (strict)
|
|
219
|
+
return strict;
|
|
220
|
+
const id = sticky.sourceSticky.lockedId;
|
|
221
|
+
if (id === null)
|
|
222
|
+
return null;
|
|
223
|
+
const b = links.find((x) => x.id === id);
|
|
224
|
+
if (!b)
|
|
225
|
+
return null;
|
|
226
|
+
const { lo, hiExclusive } = b.markerViewportHalfOpen1Based;
|
|
227
|
+
const slack = SOURCE_BLOCK_TRAILING_SLACK_LINES;
|
|
228
|
+
if (line1 >= lo && line1 < hiExclusive + slack)
|
|
229
|
+
return b;
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
104
232
|
function windowScrollRatio() {
|
|
105
233
|
const root = rootScrollingElement();
|
|
106
234
|
const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
|
|
107
235
|
return maxY > 0 ? clamp(root.scrollTop / maxY, 0, 1) : 0;
|
|
108
236
|
}
|
|
237
|
+
const SCROLL_SYNC_DEBUG_FLAG = "commentrayDebugScroll";
|
|
238
|
+
function scrollSyncDebugQueryOn() {
|
|
239
|
+
const v = new URLSearchParams(globalThis.location.search).get(SCROLL_SYNC_DEBUG_FLAG);
|
|
240
|
+
return v === "1" || v === "true" || v === "";
|
|
241
|
+
}
|
|
242
|
+
function scrollSyncDebugStorageOn(s) {
|
|
243
|
+
try {
|
|
244
|
+
const v = s.getItem(SCROLL_SYNC_DEBUG_FLAG);
|
|
245
|
+
return v === "1" || v === "true";
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function scrollSyncDebugHashOn() {
|
|
252
|
+
const h = globalThis.location.hash;
|
|
253
|
+
return h === `#${SCROLL_SYNC_DEBUG_FLAG}` || h === `#${SCROLL_SYNC_DEBUG_FLAG}=1`;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* True on common dev hostnames (`commentray serve`, local preview). Used only for the one-line
|
|
257
|
+
* boot banner — **not** for high-volume per-scroll tracing (that stays opt-in below).
|
|
258
|
+
*/
|
|
259
|
+
function scrollSyncDebugDevHostOn() {
|
|
260
|
+
const host = globalThis.location.hostname;
|
|
261
|
+
return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "::1";
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* High-volume scroll-sync tracing (`scrollSyncTrace`, `wire.*.driver`, plan/apply lines): opt-in
|
|
265
|
+
* only via URL, hash, or Web Storage — **not** implied by localhost alone (keeps DevTools quiet
|
|
266
|
+
* during normal reading).
|
|
267
|
+
*
|
|
268
|
+
* Turn on any one of:
|
|
269
|
+
* - Query: `?commentrayDebugScroll=1` (or `=true`, or present with an empty value)
|
|
270
|
+
* - Hash: `#commentrayDebugScroll` or `#commentrayDebugScroll=1` (fragment is not sent to the server)
|
|
271
|
+
* - DevTools, then reload: `sessionStorage.setItem("commentrayDebugScroll", "1")` or the same for `localStorage`
|
|
272
|
+
*
|
|
273
|
+
* Log lines (filter DevTools console on `[commentray:scroll-sync]`):
|
|
274
|
+
* - `code→doc.plan` / `doc→code.plan` — which branch built the flip plan (`reason` field)
|
|
275
|
+
* - `code→doc.apply` / `doc→code.apply` — partner `scrollTop` before vs after apply, `driverDelta`, `plan`
|
|
276
|
+
* - `wire.code.driver` / `wire.doc.driver` — RAF-coalesced driver flush (delta, pane `scrollTop`)
|
|
277
|
+
* - `wire.code.flush-skipped` / `wire.doc.flush-skipped` — flush skipped (`partner-echo` throttled ~5/s; `sync-in-progress` immediate)
|
|
278
|
+
* - `monotonic.revert` — `console.warn` when the partner move was opposite the driver and was rolled back
|
|
279
|
+
*
|
|
280
|
+
* Tracing consults a **cached feature flag** (rechecked at most every 500ms and on `hashchange` /
|
|
281
|
+
* cross-tab `storage`) so hot scroll paths avoid reading storage every tick.
|
|
282
|
+
*/
|
|
283
|
+
function scrollSyncVerboseTraceEnabled() {
|
|
284
|
+
try {
|
|
285
|
+
if (scrollSyncDebugQueryOn())
|
|
286
|
+
return true;
|
|
287
|
+
if (scrollSyncDebugHashOn())
|
|
288
|
+
return true;
|
|
289
|
+
if (scrollSyncDebugStorageOn(globalThis.sessionStorage))
|
|
290
|
+
return true;
|
|
291
|
+
if (scrollSyncDebugStorageOn(globalThis.localStorage))
|
|
292
|
+
return true;
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Throttle for re-reading URL/storage into {@link scrollSyncTraceFeatureOn}. */
|
|
300
|
+
const SCROLL_SYNC_TRACE_FLAG_RECHECK_MS = 500;
|
|
301
|
+
/**
|
|
302
|
+
* Feature flag for scroll-sync tracing: mirrors {@link scrollSyncVerboseTraceEnabled} but cached so
|
|
303
|
+
* high-frequency scroll paths do not hit Web Storage on every event.
|
|
304
|
+
*/
|
|
305
|
+
let scrollSyncTraceFeatureOn = false;
|
|
306
|
+
let scrollSyncTraceFeatureLastCheckMs = -Infinity;
|
|
307
|
+
function invalidateScrollSyncTraceFeatureFlag() {
|
|
308
|
+
scrollSyncTraceFeatureLastCheckMs = -Infinity;
|
|
309
|
+
}
|
|
310
|
+
function scrollSyncTraceFeatureFlag() {
|
|
311
|
+
const now = performance.now();
|
|
312
|
+
if (now - scrollSyncTraceFeatureLastCheckMs < SCROLL_SYNC_TRACE_FLAG_RECHECK_MS) {
|
|
313
|
+
return scrollSyncTraceFeatureOn;
|
|
314
|
+
}
|
|
315
|
+
scrollSyncTraceFeatureLastCheckMs = now;
|
|
316
|
+
scrollSyncTraceFeatureOn = scrollSyncVerboseTraceEnabled();
|
|
317
|
+
return scrollSyncTraceFeatureOn;
|
|
318
|
+
}
|
|
319
|
+
if (typeof globalThis.addEventListener === "function") {
|
|
320
|
+
globalThis.addEventListener("hashchange", invalidateScrollSyncTraceFeatureFlag);
|
|
321
|
+
globalThis.addEventListener("storage", invalidateScrollSyncTraceFeatureFlag);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Boot announcement: **always** fires on dev hosts so a stale bundle is impossible to miss.
|
|
325
|
+
* On production hosts it still fires only when the opt-in flag is on. Uses `console.log`
|
|
326
|
+
* (visible at the default DevTools "Default" level filter) so a Verbose toggle is not required.
|
|
327
|
+
*/
|
|
328
|
+
function announceScrollSyncTraceOnBoot() {
|
|
329
|
+
const devHost = scrollSyncDebugDevHostOn();
|
|
330
|
+
const traceVerbose = scrollSyncVerboseTraceEnabled();
|
|
331
|
+
if (!devHost && !traceVerbose)
|
|
332
|
+
return;
|
|
333
|
+
globalThis.console.log("[commentray:scroll-sync] boot", {
|
|
334
|
+
t: Math.round(performance.now()),
|
|
335
|
+
href: globalThis.location.href,
|
|
336
|
+
devHost,
|
|
337
|
+
traceVerbose,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Structured scroll-sync log line when {@link scrollSyncTraceFeatureFlag} is on. Tags you can filter on:
|
|
342
|
+
* - `code→doc.plan` — branch taken inside {@link buildCodeToDocFlipPlanBlockAware} (`reason` explains why)
|
|
343
|
+
* - `doc→code.plan` — branch inside {@link buildDocToCodeFlipPlanBlockAware}
|
|
344
|
+
* - `code→doc.apply` / `doc→code.apply` — partner `scrollTop` before/after apply for that driver step
|
|
345
|
+
* - `wire.code.flush-skipped` / `wire.doc.flush-skipped` — RAF driver flush skipped (echo gate or `syncing`)
|
|
346
|
+
* - `wire.code.driver` / `wire.doc.driver` — driver pane ran sync this frame (`delta`, `scrollTop`)
|
|
347
|
+
* - `monotonic.revert` — emitted with `console.warn` (see {@link enforceScrollSyncMonotonic})
|
|
348
|
+
*/
|
|
349
|
+
function scrollSyncTrace(tag, fields) {
|
|
350
|
+
if (!scrollSyncTraceFeatureFlag())
|
|
351
|
+
return;
|
|
352
|
+
globalThis.console.log(`[commentray:scroll-sync] ${tag}`, {
|
|
353
|
+
t: Math.round(performance.now()),
|
|
354
|
+
...fields,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
function formatDocToCodePlanForLog(p) {
|
|
358
|
+
if (p.k === "noop") {
|
|
359
|
+
return p.skipProportionalFallbackOnFlip === true ? "noop(skipFlipFallback)" : "noop";
|
|
360
|
+
}
|
|
361
|
+
if (p.k === "block")
|
|
362
|
+
return `block(src0=${String(p.src0)})`;
|
|
363
|
+
if (p.k === "mirrorW")
|
|
364
|
+
return `mirrorW(ratio=${p.ratio.toFixed(4)})`;
|
|
365
|
+
return `mirrorI(docTop=${String(p.docTop)})`;
|
|
366
|
+
}
|
|
367
|
+
function formatCodeToDocPlanForLog(p) {
|
|
368
|
+
if (p.k === "noop")
|
|
369
|
+
return "noop";
|
|
370
|
+
if (p.k === "block")
|
|
371
|
+
return `block(mdLine0=${String(p.mdLine0)})`;
|
|
372
|
+
if (p.k === "mirrorW")
|
|
373
|
+
return `mirrorW(ratio=${p.ratio.toFixed(4)})`;
|
|
374
|
+
return `mirrorI(codeTop=${String(p.codeTop)})`;
|
|
375
|
+
}
|
|
109
376
|
function applyDocToCodeFlipPlanImpl(codePane, _docPane, plan, lineIdPrefix = "code-line-") {
|
|
377
|
+
if (plan.k === "noop")
|
|
378
|
+
return;
|
|
110
379
|
const narrowSinglePane = globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ).matches;
|
|
111
380
|
if (plan.k === "block") {
|
|
112
|
-
const
|
|
113
|
-
const el = exact instanceof HTMLElement
|
|
114
|
-
? exact
|
|
115
|
-
: findAnchorAtOrAfter(sourceAnchorsFromPrefix(lineIdPrefix), plan.src0);
|
|
381
|
+
const el = findCodeLineElementForBlockSnap(codePane, lineIdPrefix, plan.src0);
|
|
116
382
|
if (el) {
|
|
117
|
-
applyRevealChildInPane(codePane, el,
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
applyWindowScrollRatio(plan.winRatio);
|
|
383
|
+
applyRevealChildInPane(codePane, el, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX);
|
|
121
384
|
}
|
|
122
385
|
return;
|
|
123
386
|
}
|
|
@@ -146,13 +409,12 @@ function applyDocToCodeFlipPlanImpl(codePane, _docPane, plan, lineIdPrefix = "co
|
|
|
146
409
|
applyWindowScrollRatio(clamp(nextTop / denom, 0, 1));
|
|
147
410
|
}
|
|
148
411
|
function applyCodeToDocFlipPlanImpl(_codePane, docPane, plan) {
|
|
412
|
+
if (plan.k === "noop")
|
|
413
|
+
return;
|
|
149
414
|
if (plan.k === "block") {
|
|
150
415
|
const anchor = docPane.querySelector(`[data-commentray-line="${String(plan.mdLine0)}"]`);
|
|
151
416
|
if (anchor instanceof HTMLElement) {
|
|
152
|
-
applyRevealChildInPane(docPane, anchor,
|
|
153
|
-
}
|
|
154
|
-
else {
|
|
155
|
-
applyWindowScrollRatio(plan.winRatio);
|
|
417
|
+
applyRevealChildInPane(docPane, anchor, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX);
|
|
156
418
|
}
|
|
157
419
|
return;
|
|
158
420
|
}
|
|
@@ -174,45 +436,254 @@ function applyCodeToDocFlipPlanImpl(_codePane, docPane, plan) {
|
|
|
174
436
|
const denom = Math.max(1, docPane.scrollHeight - docPane.clientHeight);
|
|
175
437
|
applyWindowScrollRatio(clamp(nextTop / denom, 0, 1));
|
|
176
438
|
}
|
|
177
|
-
function
|
|
439
|
+
function resetBlockScrollStickyIfLinksChanged(sticky, links) {
|
|
440
|
+
const key = links.map((l) => l.id).join("\0");
|
|
441
|
+
if (key !== sticky.linksKey.current) {
|
|
442
|
+
sticky.sourceSticky.lockedId = null;
|
|
443
|
+
sticky.commentraySticky.lockedId = null;
|
|
444
|
+
sticky.linksKey.current = key;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function tryDocToCodeFlipPlanFromMdProbe(codePane, links, sticky, lineIdPrefix, winRatio, mdLine0) {
|
|
448
|
+
if (mdLine0 === null)
|
|
449
|
+
return null;
|
|
450
|
+
const block = pickBlockScrollLinkForCommentrayScroll(links, mdLine0);
|
|
451
|
+
const src0 = block !== null ? block.markerViewportHalfOpen1Based.lo - 1 : null;
|
|
452
|
+
if (block !== null) {
|
|
453
|
+
sticky.commentraySticky.lockedId = block.id;
|
|
454
|
+
}
|
|
455
|
+
if (src0 === null)
|
|
456
|
+
return null;
|
|
457
|
+
const alignedNoop = docToCodePlanIfCodeAnchorAlreadyAligned(codePane, lineIdPrefix, src0, "block-code-anchor-already-aligned-noop", { mdLine0, blockId: block?.id ?? null });
|
|
458
|
+
if (alignedNoop)
|
|
459
|
+
return alignedNoop;
|
|
460
|
+
const plan = { k: "block", src0, winRatio };
|
|
461
|
+
scrollSyncTrace("doc→code.plan", {
|
|
462
|
+
reason: "block-from-md-probe",
|
|
463
|
+
plan: formatDocToCodePlanForLog(plan),
|
|
464
|
+
mdLine0,
|
|
465
|
+
blockId: block?.id ?? null,
|
|
466
|
+
markerSpan: block?.markerViewportHalfOpen1Based ?? null,
|
|
467
|
+
});
|
|
468
|
+
return plan;
|
|
469
|
+
}
|
|
470
|
+
function tryDocToCodeFlipPlanFromPageBreakPull(docPane, codePane, lineIdPrefix, winRatio, mdLine0) {
|
|
471
|
+
const pulledSrc0 = pulledSourceLine0FromPageBreak(docPane);
|
|
472
|
+
if (pulledSrc0 === null)
|
|
473
|
+
return null;
|
|
474
|
+
const pbAlignedNoop = docToCodePlanIfCodeAnchorAlreadyAligned(codePane, lineIdPrefix, pulledSrc0, "page-break-pull-code-anchor-already-aligned-noop", { mdLine0, pulledSrc0 });
|
|
475
|
+
if (pbAlignedNoop)
|
|
476
|
+
return pbAlignedNoop;
|
|
477
|
+
const plan = { k: "block", src0: pulledSrc0, winRatio };
|
|
478
|
+
scrollSyncTrace("doc→code.plan", {
|
|
479
|
+
reason: "page-break-pull",
|
|
480
|
+
plan: formatDocToCodePlanForLog(plan),
|
|
481
|
+
pulledSrc0,
|
|
482
|
+
mdLine0,
|
|
483
|
+
});
|
|
484
|
+
return plan;
|
|
485
|
+
}
|
|
486
|
+
function buildDocToCodeFlipPlanBlockAware(docPane, codePane, getLinks, sticky, lineIdPrefix, allowProportionalMirror) {
|
|
178
487
|
const winRatio = paneUsesInternalYScroll(docPane)
|
|
179
488
|
? clamp(docPane.scrollTop / Math.max(1, docPane.scrollHeight - docPane.clientHeight), 0, 1)
|
|
180
489
|
: windowScrollRatio();
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Block info is authoritative — `pulledSourceLine0FromPageBreak` is "active" for the entire span
|
|
492
|
+
* from a page-break element to its `nextAnchor`, which can cover whole blocks. If we let the
|
|
493
|
+
* page-break heuristic run first, it snaps code far forward (e.g. to the next segment's source
|
|
494
|
+
* line) while the doc viewport is still genuinely inside an earlier block; the later real-block
|
|
495
|
+
* resolution is then opposite the prior snap and `enforceScrollSyncMonotonic` reverts it, so
|
|
496
|
+
* code gets stuck hundreds of pixels ahead of the doc. Only fall back to `page-break-pull`
|
|
497
|
+
* when no indexed block matches the doc viewport (true inter-block / inter-segment gap).
|
|
498
|
+
*/
|
|
184
499
|
const links = getLinks();
|
|
500
|
+
resetBlockScrollStickyIfLinksChanged(sticky, links);
|
|
185
501
|
const mdLine0 = probeCommentrayLine0FromDoc(docPane);
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
return
|
|
502
|
+
const fromMd = tryDocToCodeFlipPlanFromMdProbe(codePane, links, sticky, lineIdPrefix, winRatio, mdLine0);
|
|
503
|
+
if (fromMd !== null)
|
|
504
|
+
return fromMd;
|
|
505
|
+
const fromPb = tryDocToCodeFlipPlanFromPageBreakPull(docPane, codePane, lineIdPrefix, winRatio, mdLine0);
|
|
506
|
+
if (fromPb !== null)
|
|
507
|
+
return fromPb;
|
|
508
|
+
/**
|
|
509
|
+
* Index-backed pair but no confident block anchor for this viewport (e.g. missing
|
|
510
|
+
* `.commentray-block-anchor` nodes, or probe could not map the doc band to a link).
|
|
511
|
+
* With {@link allowProportionalMirror}, mirror like the no-index path so static dual
|
|
512
|
+
* browse still scrolls both panes together; block-snap-only keeps the historical noop.
|
|
513
|
+
*/
|
|
514
|
+
if (links.length > 0) {
|
|
515
|
+
scrollSyncTrace("doc→code.plan", {
|
|
516
|
+
reason: allowProportionalMirror
|
|
517
|
+
? "indexed-fallback-mirror-no-confident-block"
|
|
518
|
+
: "indexed-noop-no-src0",
|
|
519
|
+
mdLine0,
|
|
520
|
+
linkCount: links.length,
|
|
521
|
+
allowProportionalMirror,
|
|
522
|
+
});
|
|
523
|
+
if (!allowProportionalMirror) {
|
|
524
|
+
return { k: "noop" };
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else if (!allowProportionalMirror) {
|
|
528
|
+
scrollSyncTrace("doc→code.plan", { reason: "snap-only-no-index-noop", linkCount: 0 });
|
|
529
|
+
return { k: "noop" };
|
|
530
|
+
}
|
|
189
531
|
if (paneUsesInternalYScroll(docPane)) {
|
|
190
|
-
|
|
532
|
+
const plan = {
|
|
191
533
|
k: "mirrorI",
|
|
192
534
|
docTop: docPane.scrollTop,
|
|
193
535
|
docSH: docPane.scrollHeight,
|
|
194
536
|
docCH: docPane.clientHeight,
|
|
195
537
|
};
|
|
538
|
+
scrollSyncTrace("doc→code.plan", {
|
|
539
|
+
reason: "no-index-mirror-internal",
|
|
540
|
+
plan: formatDocToCodePlanForLog(plan),
|
|
541
|
+
});
|
|
542
|
+
return plan;
|
|
543
|
+
}
|
|
544
|
+
const plan = { k: "mirrorW", ratio: winRatio };
|
|
545
|
+
scrollSyncTrace("doc→code.plan", {
|
|
546
|
+
reason: "no-index-mirror-window",
|
|
547
|
+
plan: formatDocToCodePlanForLog(plan),
|
|
548
|
+
});
|
|
549
|
+
return plan;
|
|
550
|
+
}
|
|
551
|
+
/** Mirror plan for the current source pane geometry — internal scroller (`mirrorI`) or window flow (`mirrorW`). */
|
|
552
|
+
function codeMirrorPlan(codePane, winRatio) {
|
|
553
|
+
if (paneUsesInternalYScroll(codePane)) {
|
|
554
|
+
return {
|
|
555
|
+
k: "mirrorI",
|
|
556
|
+
codeTop: codePane.scrollTop,
|
|
557
|
+
codeSH: codePane.scrollHeight,
|
|
558
|
+
codeCH: codePane.clientHeight,
|
|
559
|
+
};
|
|
196
560
|
}
|
|
197
561
|
return { k: "mirrorW", ratio: winRatio };
|
|
198
562
|
}
|
|
199
|
-
|
|
563
|
+
/** Source viewport sits **above** every indexed span — pin partner to the very first block head. */
|
|
564
|
+
function preludeBlockAwareCodeToDocPlan(links, docPane, sticky, line1, lineIdPrefix, winRatio) {
|
|
565
|
+
const sorted = [...links].sort((a, b) => a.markerViewportHalfOpen1Based.lo - b.markerViewportHalfOpen1Based.lo);
|
|
566
|
+
const first = sorted[0];
|
|
567
|
+
if (!first) {
|
|
568
|
+
scrollSyncTrace("code→doc.plan", { reason: "prelude-missing-first-link", line1, lineIdPrefix });
|
|
569
|
+
return { k: "noop" };
|
|
570
|
+
}
|
|
571
|
+
sticky.sourceSticky.lockedId = first.id;
|
|
572
|
+
const mdLine0 = first.commentrayLine;
|
|
573
|
+
if (commentrayBlockAnchorAlignedWithLead(docPane, mdLine0, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX)) {
|
|
574
|
+
scrollSyncTrace("code→doc.plan", {
|
|
575
|
+
reason: "prelude-anchor-already-aligned-noop",
|
|
576
|
+
line1,
|
|
577
|
+
blockId: first.id,
|
|
578
|
+
mdLine0,
|
|
579
|
+
});
|
|
580
|
+
return { k: "noop" };
|
|
581
|
+
}
|
|
582
|
+
const plan = { k: "block", mdLine0, winRatio };
|
|
583
|
+
scrollSyncTrace("code→doc.plan", {
|
|
584
|
+
reason: "prelude-first-block",
|
|
585
|
+
line1,
|
|
586
|
+
blockId: first.id,
|
|
587
|
+
mdLine0,
|
|
588
|
+
plan: formatCodeToDocPlanForLog(plan),
|
|
589
|
+
});
|
|
590
|
+
return plan;
|
|
591
|
+
}
|
|
592
|
+
/** Source viewport falls inside (or in trailing-slack of) an indexed block — reveal that block's anchor. */
|
|
593
|
+
function activeBlockCodeToDocPlan(active, strictContaining, docPane, sticky, line1, winRatio) {
|
|
594
|
+
sticky.sourceSticky.lockedId = active.id;
|
|
595
|
+
const mdLine0 = active.commentrayLine;
|
|
596
|
+
const pickMode = strictContaining !== null && strictContaining.id === active.id ? "strict" : "trailing-slack";
|
|
597
|
+
if (commentrayBlockAnchorAlignedWithLead(docPane, mdLine0, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX)) {
|
|
598
|
+
scrollSyncTrace("code→doc.plan", {
|
|
599
|
+
reason: "active-block-anchor-aligned-noop",
|
|
600
|
+
line1,
|
|
601
|
+
pickMode,
|
|
602
|
+
blockId: active.id,
|
|
603
|
+
mdLine0,
|
|
604
|
+
});
|
|
605
|
+
return { k: "noop" };
|
|
606
|
+
}
|
|
607
|
+
const plan = { k: "block", mdLine0, winRatio };
|
|
608
|
+
scrollSyncTrace("code→doc.plan", {
|
|
609
|
+
reason: "active-block-reveal",
|
|
610
|
+
line1,
|
|
611
|
+
pickMode,
|
|
612
|
+
blockId: active.id,
|
|
613
|
+
mdLine0,
|
|
614
|
+
markerSpan: active.markerViewportHalfOpen1Based,
|
|
615
|
+
plan: formatCodeToDocPlanForLog(plan),
|
|
616
|
+
});
|
|
617
|
+
return plan;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Releases the sticky source lock once the viewport is past the previous block plus its trailing
|
|
621
|
+
* slack — a true inter-block gap. Without this clear, the gap-mirror branch never reactivates after
|
|
622
|
+
* the slack expires.
|
|
623
|
+
*/
|
|
624
|
+
function clearStickySourceLockPastTrailingSlack(sticky, links, line1) {
|
|
625
|
+
const sid = sticky.sourceSticky.lockedId;
|
|
626
|
+
if (!sid)
|
|
627
|
+
return;
|
|
628
|
+
const prev = links.find((x) => x.id === sid);
|
|
629
|
+
if (!prev) {
|
|
630
|
+
sticky.sourceSticky.lockedId = null;
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const hi = prev.markerViewportHalfOpen1Based.hiExclusive;
|
|
634
|
+
if (line1 >= hi + SOURCE_BLOCK_TRAILING_SLACK_LINES) {
|
|
635
|
+
sticky.sourceSticky.lockedId = null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix, sticky, allowProportionalMirror) {
|
|
200
639
|
const winRatio = windowScrollRatio();
|
|
201
640
|
const links = getLinks();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
codeCH: codePane.clientHeight,
|
|
211
|
-
};
|
|
641
|
+
resetBlockScrollStickyIfLinksChanged(sticky, links);
|
|
642
|
+
if (links.length === 0) {
|
|
643
|
+
if (!allowProportionalMirror) {
|
|
644
|
+
scrollSyncTrace("code→doc.plan", {
|
|
645
|
+
reason: "snap-only-no-block-links-noop",
|
|
646
|
+
lineIdPrefix,
|
|
647
|
+
});
|
|
648
|
+
return { k: "noop" };
|
|
212
649
|
}
|
|
213
|
-
|
|
650
|
+
const plan = codeMirrorPlan(codePane, winRatio);
|
|
651
|
+
scrollSyncTrace("code→doc.plan", {
|
|
652
|
+
reason: plan.k === "mirrorI" ? "no-block-links-mirror-internal" : "no-block-links-mirror-window",
|
|
653
|
+
lineIdPrefix,
|
|
654
|
+
plan: formatCodeToDocPlanForLog(plan),
|
|
655
|
+
});
|
|
656
|
+
return plan;
|
|
214
657
|
}
|
|
215
|
-
|
|
658
|
+
const line1 = probeCodeLine1FromViewport(codePane, lineIdPrefix);
|
|
659
|
+
if (sourceTopLineStrictlyBeforeFirstIndexLine(links, line1)) {
|
|
660
|
+
return preludeBlockAwareCodeToDocPlan(links, docPane, sticky, line1, lineIdPrefix, winRatio);
|
|
661
|
+
}
|
|
662
|
+
const strictContaining = blockStrictlyContainingSourceViewportLine(links, line1);
|
|
663
|
+
const active = blockForCodeToDocSync(links, line1, sticky);
|
|
664
|
+
if (active) {
|
|
665
|
+
return activeBlockCodeToDocPlan(active, strictContaining, docPane, sticky, line1, winRatio);
|
|
666
|
+
}
|
|
667
|
+
/** Source viewport sits in a true gap between indexed spans — mirror so the doc never snaps to an unrelated block head. */
|
|
668
|
+
clearStickySourceLockPastTrailingSlack(sticky, links, line1);
|
|
669
|
+
if (!allowProportionalMirror) {
|
|
670
|
+
scrollSyncTrace("code→doc.plan", {
|
|
671
|
+
reason: "snap-only-source-gap-noop",
|
|
672
|
+
line1,
|
|
673
|
+
lineIdPrefix,
|
|
674
|
+
stickyLockAfter: sticky.sourceSticky.lockedId,
|
|
675
|
+
});
|
|
676
|
+
return { k: "noop" };
|
|
677
|
+
}
|
|
678
|
+
const plan = codeMirrorPlan(codePane, winRatio);
|
|
679
|
+
scrollSyncTrace("code→doc.plan", {
|
|
680
|
+
reason: plan.k === "mirrorI" ? "source-gap-mirror-internal" : "source-gap-mirror-window",
|
|
681
|
+
line1,
|
|
682
|
+
lineIdPrefix,
|
|
683
|
+
stickyLockAfter: sticky.sourceSticky.lockedId,
|
|
684
|
+
plan: formatCodeToDocPlanForLog(plan),
|
|
685
|
+
});
|
|
686
|
+
return plan;
|
|
216
687
|
}
|
|
217
688
|
function buildDocToCodeFlipPlanProportional(docPane) {
|
|
218
689
|
if (paneUsesInternalYScroll(docPane)) {
|
|
@@ -412,7 +883,7 @@ function emptySearchBrowsePreviewInnerHtml(hint, rows, ctx) {
|
|
|
412
883
|
function scrollDocToMarkdownLine0(docScrollEl, line0, mdLineCount) {
|
|
413
884
|
const el = docScrollEl.querySelector(`#commentray-md-line-${String(line0)}`);
|
|
414
885
|
if (el instanceof HTMLElement) {
|
|
415
|
-
const top = Math.round(scrollTopToAlignChildTop(docScrollEl, el,
|
|
886
|
+
const top = Math.round(scrollTopToAlignChildTop(docScrollEl, el, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX));
|
|
416
887
|
const maxY = Math.round(Math.max(0, docScrollEl.scrollHeight - docScrollEl.clientHeight));
|
|
417
888
|
docScrollEl.scrollTo({ top: clamp(top, 0, maxY), behavior: "smooth" });
|
|
418
889
|
return;
|
|
@@ -488,6 +959,12 @@ function findSearchHitButton(leaf, searchResults) {
|
|
|
488
959
|
}
|
|
489
960
|
return null;
|
|
490
961
|
}
|
|
962
|
+
function listSearchHitButtons(searchResults) {
|
|
963
|
+
return [...searchResults.querySelectorAll("button.hit")].filter((el) => el instanceof HTMLButtonElement);
|
|
964
|
+
}
|
|
965
|
+
function listDocumentedTreeFileLinks(treeHost) {
|
|
966
|
+
return [...treeHost.querySelectorAll("a.tree-file-link")].filter((el) => el instanceof HTMLAnchorElement);
|
|
967
|
+
}
|
|
491
968
|
function scrollCodeHitToView(line) {
|
|
492
969
|
const el = document.getElementById(`code-line-${String(line)}`);
|
|
493
970
|
if (el)
|
|
@@ -561,12 +1038,77 @@ function emptyBrowsePreviewInnerHtml(scope, filePathLabel, mutable) {
|
|
|
561
1038
|
const hint = emptyBrowsePreviewHint("commentray-and-paths", fb.length, fb.length, true);
|
|
562
1039
|
return emptySearchBrowsePreviewInnerHtml(hint, fb, hitCtx);
|
|
563
1040
|
}
|
|
1041
|
+
function wireSearchResultsHitListKeyboard(searchResults, searchInput) {
|
|
1042
|
+
searchResults.addEventListener("keydown", (e) => {
|
|
1043
|
+
if (e.isComposing || searchResults.hidden)
|
|
1044
|
+
return;
|
|
1045
|
+
const hits = listSearchHitButtons(searchResults);
|
|
1046
|
+
if (hits.length === 0)
|
|
1047
|
+
return;
|
|
1048
|
+
const active = document.activeElement;
|
|
1049
|
+
if (!(active instanceof HTMLButtonElement) || !active.classList.contains("hit"))
|
|
1050
|
+
return;
|
|
1051
|
+
const idx = hits.indexOf(active);
|
|
1052
|
+
if (idx < 0)
|
|
1053
|
+
return;
|
|
1054
|
+
if (e.key === "ArrowDown" && idx < hits.length - 1) {
|
|
1055
|
+
hits[idx + 1].focus({ preventScroll: true });
|
|
1056
|
+
e.preventDefault();
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (e.key === "ArrowUp") {
|
|
1060
|
+
if (idx > 0) {
|
|
1061
|
+
hits[idx - 1].focus({ preventScroll: true });
|
|
1062
|
+
e.preventDefault();
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
searchInput.focus({ preventScroll: true });
|
|
1066
|
+
e.preventDefault();
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
function wireSearchInputKeyboard(searchInput, searchResults, actions) {
|
|
1071
|
+
const { renderEmptyBrowsePreview, runSearch, cancelDebounceTimer, hitClickDeps } = actions;
|
|
1072
|
+
searchInput.addEventListener("keydown", (e) => {
|
|
1073
|
+
if (e.isComposing)
|
|
1074
|
+
return;
|
|
1075
|
+
if (e.key === "ArrowDown") {
|
|
1076
|
+
if (!searchResults.hidden) {
|
|
1077
|
+
const hits = listSearchHitButtons(searchResults);
|
|
1078
|
+
if (hits.length > 0 && document.activeElement === searchInput) {
|
|
1079
|
+
hits[0].focus({ preventScroll: true });
|
|
1080
|
+
e.preventDefault();
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (tokenizeQuery(searchInput.value).length > 0)
|
|
1085
|
+
return;
|
|
1086
|
+
renderEmptyBrowsePreview();
|
|
1087
|
+
e.preventDefault();
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
if (e.key !== "Enter")
|
|
1091
|
+
return;
|
|
1092
|
+
cancelDebounceTimer();
|
|
1093
|
+
if (tokenizeQuery(searchInput.value).length > 0) {
|
|
1094
|
+
runSearch();
|
|
1095
|
+
}
|
|
1096
|
+
const hits = listSearchHitButtons(searchResults);
|
|
1097
|
+
if (!searchResults.hidden && hits.length > 0 && document.activeElement === searchInput) {
|
|
1098
|
+
e.preventDefault();
|
|
1099
|
+
handleSearchHitButtonClick(hits[0], hitClickDeps);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
564
1103
|
function wireSearchUi(ctx) {
|
|
565
1104
|
const { scope, filePathLabel, mutable, rawCode, searchInput, searchClear, searchResults, docScrollEl, } = ctx;
|
|
566
1105
|
let debounceTimer;
|
|
567
|
-
function
|
|
1106
|
+
function cancelDebounceTimer() {
|
|
568
1107
|
clearTimeout(debounceTimer);
|
|
569
1108
|
debounceTimer = undefined;
|
|
1109
|
+
}
|
|
1110
|
+
function clearSearch() {
|
|
1111
|
+
cancelDebounceTimer();
|
|
570
1112
|
searchInput.value = "";
|
|
571
1113
|
searchResults.innerHTML = "";
|
|
572
1114
|
searchResults.hidden = true;
|
|
@@ -610,17 +1152,16 @@ function wireSearchUi(ctx) {
|
|
|
610
1152
|
return;
|
|
611
1153
|
handleSearchHitButtonClick(hit, hitClickDeps);
|
|
612
1154
|
});
|
|
1155
|
+
wireSearchResultsHitListKeyboard(searchResults, searchInput);
|
|
613
1156
|
searchInput.addEventListener("input", () => {
|
|
614
1157
|
clearTimeout(debounceTimer);
|
|
615
1158
|
debounceTimer = setTimeout(runSearch, 200);
|
|
616
1159
|
});
|
|
617
|
-
searchInput
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
renderEmptyBrowsePreview();
|
|
623
|
-
e.preventDefault();
|
|
1160
|
+
wireSearchInputKeyboard(searchInput, searchResults, {
|
|
1161
|
+
renderEmptyBrowsePreview,
|
|
1162
|
+
runSearch,
|
|
1163
|
+
cancelDebounceTimer,
|
|
1164
|
+
hitClickDeps,
|
|
624
1165
|
});
|
|
625
1166
|
searchClear.addEventListener("click", clearSearch);
|
|
626
1167
|
document.addEventListener("keydown", (e) => {
|
|
@@ -726,13 +1267,13 @@ function parseScrollBlockLinksFromShell(b64) {
|
|
|
726
1267
|
return [];
|
|
727
1268
|
}
|
|
728
1269
|
}
|
|
729
|
-
function rootScrollNearDocumentEnd(edgePx =
|
|
1270
|
+
function rootScrollNearDocumentEnd(edgePx = READING_VIEWPORT_BOTTOM_EDGE_CSS_PX) {
|
|
730
1271
|
const root = rootScrollingElement();
|
|
731
1272
|
const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
|
|
732
1273
|
return maxY > 0 && root.scrollTop >= maxY - edgePx;
|
|
733
1274
|
}
|
|
734
1275
|
/** When the pane itself is the scrollport (dual desktop), mirror root “near end” behavior. */
|
|
735
|
-
function paneScrollNearEnd(pane, edgePx =
|
|
1276
|
+
function paneScrollNearEnd(pane, edgePx = READING_VIEWPORT_BOTTOM_EDGE_CSS_PX) {
|
|
736
1277
|
const maxY = Math.max(0, pane.scrollHeight - pane.clientHeight);
|
|
737
1278
|
return maxY > 0 && pane.scrollTop >= maxY - edgePx;
|
|
738
1279
|
}
|
|
@@ -753,10 +1294,15 @@ function bestCommentrayAnchorLine0AtOrAboveY(anchors, y) {
|
|
|
753
1294
|
return maxCommentrayAnchorLine0AtOrAboveViewportY(readings, y);
|
|
754
1295
|
}
|
|
755
1296
|
function lastCommentrayAnchorLine0(anchors) {
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1297
|
+
for (let i = anchors.length - 1; i >= 0; i--) {
|
|
1298
|
+
const el = anchors.item(i);
|
|
1299
|
+
if (!el)
|
|
1300
|
+
continue;
|
|
1301
|
+
const v = readCommentrayLine0FromAnchor(el);
|
|
1302
|
+
if (v !== null)
|
|
1303
|
+
return v;
|
|
1304
|
+
}
|
|
1305
|
+
return null;
|
|
760
1306
|
}
|
|
761
1307
|
function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
|
|
762
1308
|
const rows = codePane.querySelectorAll(`[id^="${lineIdPrefix}"]`);
|
|
@@ -774,7 +1320,7 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
|
|
|
774
1320
|
const vh = globalThis.innerHeight;
|
|
775
1321
|
const clipT = Math.max(0, sr.top);
|
|
776
1322
|
const clipB = Math.min(vh, sr.bottom);
|
|
777
|
-
const y = clipT +
|
|
1323
|
+
const y = clipT + readingViewportTopInsetCssPx(clipB - clipT);
|
|
778
1324
|
for (const el of rows) {
|
|
779
1325
|
const r = el.getBoundingClientRect();
|
|
780
1326
|
if (r.bottom > y - 1e-3) {
|
|
@@ -794,7 +1340,7 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
|
|
|
794
1340
|
return rows.length;
|
|
795
1341
|
}
|
|
796
1342
|
const sr = codePane.getBoundingClientRect();
|
|
797
|
-
const y = sr.top + codePane.clientTop +
|
|
1343
|
+
const y = sr.top + codePane.clientTop + readingViewportTopInsetCssPx(codePane.clientHeight);
|
|
798
1344
|
for (const el of rows) {
|
|
799
1345
|
const r = el.getBoundingClientRect();
|
|
800
1346
|
if (r.bottom > y - 1e-3) {
|
|
@@ -809,22 +1355,28 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
|
|
|
809
1355
|
function probeCommentrayLine0FromDoc(docPane) {
|
|
810
1356
|
const anchors = docPane.querySelectorAll(".commentray-block-anchor");
|
|
811
1357
|
if (anchors.length === 0)
|
|
812
|
-
return
|
|
1358
|
+
return null;
|
|
813
1359
|
if (!paneUsesInternalYScroll(docPane)) {
|
|
814
|
-
if (rootScrollNearDocumentEnd())
|
|
815
|
-
|
|
1360
|
+
if (rootScrollNearDocumentEnd()) {
|
|
1361
|
+
const tail = lastCommentrayAnchorLine0(anchors);
|
|
1362
|
+
if (tail !== null)
|
|
1363
|
+
return tail;
|
|
1364
|
+
}
|
|
816
1365
|
const dr = docPane.getBoundingClientRect();
|
|
817
1366
|
const vh = globalThis.innerHeight;
|
|
818
1367
|
const clipT = Math.max(0, dr.top);
|
|
819
1368
|
const clipB = Math.min(vh, dr.bottom);
|
|
820
|
-
const y = clipT +
|
|
1369
|
+
const y = clipT + readingViewportTopInsetCssPx(clipB - clipT);
|
|
821
1370
|
return bestCommentrayAnchorLine0AtOrAboveY(anchors, y);
|
|
822
1371
|
}
|
|
823
|
-
if (paneScrollNearEnd(docPane))
|
|
824
|
-
|
|
1372
|
+
if (paneScrollNearEnd(docPane)) {
|
|
1373
|
+
const tail = lastCommentrayAnchorLine0(anchors);
|
|
1374
|
+
if (tail !== null)
|
|
1375
|
+
return tail;
|
|
1376
|
+
}
|
|
825
1377
|
const dr = docPane.getBoundingClientRect();
|
|
826
|
-
/** Same band as the root-scroll branch:
|
|
827
|
-
const y = dr.top + docPane.clientTop +
|
|
1378
|
+
/** Same band as the root-scroll branch: inset below the pane top so anchors qualify while body text sits in the comfort zone. */
|
|
1379
|
+
const y = dr.top + docPane.clientTop + readingViewportTopInsetCssPx(docPane.clientHeight);
|
|
828
1380
|
return bestCommentrayAnchorLine0AtOrAboveY(anchors, y);
|
|
829
1381
|
}
|
|
830
1382
|
function pageBreakPullEnabled() {
|
|
@@ -839,10 +1391,10 @@ function docProbeTopY(docPane) {
|
|
|
839
1391
|
const vh = globalThis.innerHeight;
|
|
840
1392
|
const clipT = Math.max(0, dr.top);
|
|
841
1393
|
const clipB = Math.min(vh, dr.bottom);
|
|
842
|
-
return clipT +
|
|
1394
|
+
return clipT + readingViewportTopInsetCssPx(clipB - clipT);
|
|
843
1395
|
}
|
|
844
1396
|
const dr = docPane.getBoundingClientRect();
|
|
845
|
-
return dr.top + docPane.clientTop +
|
|
1397
|
+
return dr.top + docPane.clientTop + readingViewportTopInsetCssPx(docPane.clientHeight);
|
|
846
1398
|
}
|
|
847
1399
|
/**
|
|
848
1400
|
* In long synthetic page-break gaps, shift source toward the next block once
|
|
@@ -852,13 +1404,13 @@ function pulledSourceLine0FromPageBreak(docPane) {
|
|
|
852
1404
|
if (!pageBreakPullEnabled())
|
|
853
1405
|
return null;
|
|
854
1406
|
const topY = docProbeTopY(docPane);
|
|
855
|
-
const breaks = Array.from(docPane.querySelectorAll(".commentray-page-break[data-next-source-
|
|
1407
|
+
const breaks = Array.from(docPane.querySelectorAll(".commentray-page-break[data-next-source-viewport-line]"));
|
|
856
1408
|
for (const pageBreak of breaks) {
|
|
857
|
-
const
|
|
858
|
-
if (!
|
|
1409
|
+
const nextViewportLineRaw = pageBreak.getAttribute("data-next-source-viewport-line");
|
|
1410
|
+
if (!nextViewportLineRaw)
|
|
859
1411
|
continue;
|
|
860
|
-
const
|
|
861
|
-
if (!Number.isFinite(
|
|
1412
|
+
const nextViewportLine1Based = Number.parseInt(nextViewportLineRaw, 10);
|
|
1413
|
+
if (!Number.isFinite(nextViewportLine1Based) || nextViewportLine1Based <= 0)
|
|
862
1414
|
continue;
|
|
863
1415
|
const breakTop = pageBreak.getBoundingClientRect().top;
|
|
864
1416
|
const nextLineRaw = pageBreak.getAttribute("data-next-commentray-line");
|
|
@@ -877,91 +1429,251 @@ function pulledSourceLine0FromPageBreak(docPane) {
|
|
|
877
1429
|
const pullThreshold = narrow ? 0.2 : 0.35;
|
|
878
1430
|
if (progress < pullThreshold)
|
|
879
1431
|
return null;
|
|
880
|
-
return
|
|
1432
|
+
return nextViewportLine1Based - 1;
|
|
881
1433
|
}
|
|
882
1434
|
return null;
|
|
883
1435
|
}
|
|
884
1436
|
/**
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
1437
|
+
* After we move the **partner** pane from a driver sync, its `scroll` handler must not run the
|
|
1438
|
+
* opposite mapper until things settle — one user wheel can produce dozens of programmatic scroll
|
|
1439
|
+
* events (layout, smooth interpolation, nested scrollports). A **fixed event budget** was
|
|
1440
|
+
* exhausted mid-gesture and caused doc↔code ping-pong. We extend a **deadline** instead; each driver
|
|
1441
|
+
* sync pushes `until` forward so continuous scrolling stays stable.
|
|
890
1442
|
*/
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1443
|
+
/**
|
|
1444
|
+
* Ignore partner `scroll` echoes after a driver sync. Must cover the tail of
|
|
1445
|
+
* tail of a partner programmatic scroll; otherwise the partner’s frames can be treated as a driver
|
|
1446
|
+
* and the panes ping-pong. Uses {@link SMOOTH_REVEAL_INFLIGHT_DEDUP_MS} as a conservative ceiling
|
|
1447
|
+
* for any remaining smooth paths (e.g. hash navigation helpers).
|
|
1448
|
+
*/
|
|
1449
|
+
const PARTNER_SCROLL_ECHO_SUPPRESS_MS = Math.max(320, SMOOTH_REVEAL_INFLIGHT_DEDUP_MS + 250);
|
|
1450
|
+
function armPartnerScrollEchoGate(gate) {
|
|
1451
|
+
const next = performance.now() + PARTNER_SCROLL_ECHO_SUPPRESS_MS;
|
|
1452
|
+
gate.until = Math.max(gate.until, next);
|
|
1453
|
+
}
|
|
1454
|
+
function partnerScrollEchoActive(gate) {
|
|
1455
|
+
return performance.now() < gate.until;
|
|
1456
|
+
}
|
|
1457
|
+
/** Throttle for the `wire.*.flush-skipped` `partner-echo` log line — without it we get one per animation frame. */
|
|
1458
|
+
const WIRE_ECHO_SKIP_TRACE_MIN_MS = 200;
|
|
1459
|
+
function emitFlushSkippedPartnerEcho(axis, state, ownEchoGate, driverPane) {
|
|
1460
|
+
const t = performance.now();
|
|
1461
|
+
if (!scrollSyncTraceFeatureFlag() ||
|
|
1462
|
+
t - state.lastEchoSkipTraceAt < WIRE_ECHO_SKIP_TRACE_MIN_MS) {
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
state.lastEchoSkipTraceAt = t;
|
|
1466
|
+
scrollSyncTrace(`wire.${axis}.flush-skipped`, {
|
|
1467
|
+
reason: "partner-echo",
|
|
1468
|
+
echoUntilMs: Math.round(ownEchoGate.until),
|
|
1469
|
+
[`${axis}ScrollTop`]: driverPane.scrollTop,
|
|
899
1470
|
});
|
|
900
1471
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
if (
|
|
907
|
-
|
|
1472
|
+
/** Builds the RAF flush closure that consumes one driver pane's coalesced scroll bursts. */
|
|
1473
|
+
function makeDriverFlush(args) {
|
|
1474
|
+
const { axis, driverPane, state, syncingRef, ownEchoGate, partnerEchoGate, syncFromDriver } = args;
|
|
1475
|
+
return () => {
|
|
1476
|
+
state.pendingRaf = 0;
|
|
1477
|
+
if (partnerScrollEchoActive(ownEchoGate)) {
|
|
1478
|
+
emitFlushSkippedPartnerEcho(axis, state, ownEchoGate, driverPane);
|
|
1479
|
+
state.lastSeenTop = driverPane.scrollTop;
|
|
908
1480
|
return;
|
|
909
1481
|
}
|
|
910
|
-
if (
|
|
1482
|
+
if (syncingRef.current !== "none") {
|
|
1483
|
+
scrollSyncTrace(`wire.${axis}.flush-skipped`, {
|
|
1484
|
+
reason: "sync-in-progress",
|
|
1485
|
+
syncing: syncingRef.current,
|
|
1486
|
+
});
|
|
911
1487
|
return;
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1488
|
+
}
|
|
1489
|
+
const now = driverPane.scrollTop;
|
|
1490
|
+
const delta = now - state.lastSeenTop;
|
|
1491
|
+
state.lastSeenTop = now;
|
|
1492
|
+
syncingRef.current = axis;
|
|
1493
|
+
armPartnerScrollEchoGate(partnerEchoGate);
|
|
1494
|
+
scrollSyncTrace(`wire.${axis}.driver`, { delta, [`${axis}ScrollTop`]: now });
|
|
1495
|
+
syncFromDriver(delta);
|
|
1496
|
+
syncingRef.current = "none";
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
/** Installs the `scroll` listener on one driver pane that schedules at most one flush per frame. */
|
|
1500
|
+
function attachDriverScrollListener(args) {
|
|
1501
|
+
const { driverPane, state, syncingRef, ownEchoGate, flush } = args;
|
|
1502
|
+
driverPane.addEventListener("scroll", () => {
|
|
1503
|
+
if (partnerScrollEchoActive(ownEchoGate)) {
|
|
1504
|
+
state.lastSeenTop = driverPane.scrollTop;
|
|
1505
|
+
if (state.pendingRaf !== 0) {
|
|
1506
|
+
cancelAnimationFrame(state.pendingRaf);
|
|
1507
|
+
state.pendingRaf = 0;
|
|
1508
|
+
}
|
|
920
1509
|
return;
|
|
921
1510
|
}
|
|
922
|
-
if (
|
|
1511
|
+
if (syncingRef.current !== "none") {
|
|
1512
|
+
state.lastSeenTop = driverPane.scrollTop;
|
|
923
1513
|
return;
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1514
|
+
}
|
|
1515
|
+
if (state.pendingRaf !== 0) {
|
|
1516
|
+
cancelAnimationFrame(state.pendingRaf);
|
|
1517
|
+
}
|
|
1518
|
+
state.pendingRaf = requestAnimationFrame(flush);
|
|
928
1519
|
}, { passive: true });
|
|
929
1520
|
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Coalesces high-frequency `scroll` bursts into **one** partner sync per pane per animation
|
|
1523
|
+
* frame so block-aware snaps do not chain immediate reverse syncs (“slot machine” feel).
|
|
1524
|
+
*/
|
|
1525
|
+
function wireBidirectionalScroll(codePane, docPane, syncFromCode, syncFromDoc) {
|
|
1526
|
+
const syncingRef = { current: "none" };
|
|
1527
|
+
/** Code pane: ignore scroll echoes while we are applying a doc→code partner update. */
|
|
1528
|
+
const suppressCodeScrollEcho = { until: 0 };
|
|
1529
|
+
/** Doc pane: ignore scroll echoes while we are applying a code→doc partner update. */
|
|
1530
|
+
const suppressDocScrollEcho = { until: 0 };
|
|
1531
|
+
const codeState = {
|
|
1532
|
+
lastSeenTop: codePane.scrollTop,
|
|
1533
|
+
pendingRaf: 0,
|
|
1534
|
+
lastEchoSkipTraceAt: -Infinity,
|
|
1535
|
+
};
|
|
1536
|
+
const docState = {
|
|
1537
|
+
lastSeenTop: docPane.scrollTop,
|
|
1538
|
+
pendingRaf: 0,
|
|
1539
|
+
lastEchoSkipTraceAt: -Infinity,
|
|
1540
|
+
};
|
|
1541
|
+
const flushCode = makeDriverFlush({
|
|
1542
|
+
axis: "code",
|
|
1543
|
+
driverPane: codePane,
|
|
1544
|
+
state: codeState,
|
|
1545
|
+
syncingRef,
|
|
1546
|
+
ownEchoGate: suppressCodeScrollEcho,
|
|
1547
|
+
partnerEchoGate: suppressDocScrollEcho,
|
|
1548
|
+
syncFromDriver: syncFromCode,
|
|
1549
|
+
});
|
|
1550
|
+
const flushDoc = makeDriverFlush({
|
|
1551
|
+
axis: "doc",
|
|
1552
|
+
driverPane: docPane,
|
|
1553
|
+
state: docState,
|
|
1554
|
+
syncingRef,
|
|
1555
|
+
ownEchoGate: suppressDocScrollEcho,
|
|
1556
|
+
partnerEchoGate: suppressCodeScrollEcho,
|
|
1557
|
+
syncFromDriver: syncFromDoc,
|
|
1558
|
+
});
|
|
1559
|
+
attachDriverScrollListener({
|
|
1560
|
+
driverPane: codePane,
|
|
1561
|
+
state: codeState,
|
|
1562
|
+
syncingRef,
|
|
1563
|
+
ownEchoGate: suppressCodeScrollEcho,
|
|
1564
|
+
flush: flushCode,
|
|
1565
|
+
});
|
|
1566
|
+
attachDriverScrollListener({
|
|
1567
|
+
driverPane: docPane,
|
|
1568
|
+
state: docState,
|
|
1569
|
+
syncingRef,
|
|
1570
|
+
ownEchoGate: suppressDocScrollEcho,
|
|
1571
|
+
flush: flushDoc,
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
function blockAwareSyncFromCodeToDoc(bundle, driverDelta) {
|
|
1575
|
+
const { codePane, docPane, getLinks, lineIdPrefix, sticky } = bundle;
|
|
1576
|
+
const docBefore = readPaneVerticalScroll(docPane);
|
|
1577
|
+
const prefix = lineIdPrefix();
|
|
1578
|
+
const p = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, prefix, sticky, bundle.allowProportionalMirror);
|
|
1579
|
+
applyCodeToDocFlipPlanImpl(codePane, docPane, p);
|
|
1580
|
+
const docAfter = readPaneVerticalScroll(docPane);
|
|
1581
|
+
scrollSyncTrace("code→doc.apply", {
|
|
1582
|
+
driverDelta,
|
|
1583
|
+
lineIdPrefix: prefix,
|
|
1584
|
+
plan: formatCodeToDocPlanForLog(p),
|
|
1585
|
+
docScrollTopBefore: docBefore,
|
|
1586
|
+
docScrollTopAfter: docAfter,
|
|
1587
|
+
docDelta: docAfter - docBefore,
|
|
1588
|
+
codeScrollTop: codePane.scrollTop,
|
|
1589
|
+
});
|
|
1590
|
+
enforceScrollSyncMonotonic({
|
|
1591
|
+
driverDelta,
|
|
1592
|
+
partnerBefore: docBefore,
|
|
1593
|
+
partnerPane: docPane,
|
|
1594
|
+
axis: "code→doc",
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
function blockAwareSyncFromDocToCode(bundle, driverDelta) {
|
|
1598
|
+
const { codePane, docPane, getLinks, lineIdPrefix, sticky } = bundle;
|
|
1599
|
+
const codeBefore = readPaneVerticalScroll(codePane);
|
|
1600
|
+
const prefix = lineIdPrefix();
|
|
1601
|
+
const p = buildDocToCodeFlipPlanBlockAware(docPane, codePane, getLinks, sticky, prefix, bundle.allowProportionalMirror);
|
|
1602
|
+
applyDocToCodeFlipPlanImpl(codePane, docPane, p, prefix);
|
|
1603
|
+
const codeAfter = readPaneVerticalScroll(codePane);
|
|
1604
|
+
scrollSyncTrace("doc→code.apply", {
|
|
1605
|
+
driverDelta,
|
|
1606
|
+
lineIdPrefix: prefix,
|
|
1607
|
+
plan: formatDocToCodePlanForLog(p),
|
|
1608
|
+
codeScrollTopBefore: codeBefore,
|
|
1609
|
+
codeScrollTopAfter: codeAfter,
|
|
1610
|
+
codeDelta: codeAfter - codeBefore,
|
|
1611
|
+
docScrollTop: docPane.scrollTop,
|
|
1612
|
+
});
|
|
1613
|
+
enforceScrollSyncMonotonic({
|
|
1614
|
+
driverDelta,
|
|
1615
|
+
partnerBefore: codeBefore,
|
|
1616
|
+
partnerPane: codePane,
|
|
1617
|
+
axis: "doc→code",
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
930
1620
|
/** Index-backed scroll sync when `data-scroll-block-links-b64` is present; else see proportional fallback. */
|
|
931
|
-
function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, shouldUseProportionalDocToCodeOnMobileFlip) {
|
|
1621
|
+
function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, shouldUseProportionalDocToCodeOnMobileFlip, options) {
|
|
1622
|
+
const allowProportionalMirror = options?.allowProportionalMirror !== false;
|
|
932
1623
|
let pendingDocToCode = null;
|
|
933
1624
|
let pendingCodeToDoc = null;
|
|
1625
|
+
const sticky = {
|
|
1626
|
+
sourceSticky: { lockedId: null },
|
|
1627
|
+
commentraySticky: { lockedId: null },
|
|
1628
|
+
linksKey: { current: "" },
|
|
1629
|
+
};
|
|
1630
|
+
const bundle = {
|
|
1631
|
+
codePane,
|
|
1632
|
+
docPane,
|
|
1633
|
+
getLinks,
|
|
1634
|
+
lineIdPrefix,
|
|
1635
|
+
sticky,
|
|
1636
|
+
allowProportionalMirror,
|
|
1637
|
+
};
|
|
1638
|
+
const syncFromCodeToDocInner = (d) => blockAwareSyncFromCodeToDoc(bundle, d);
|
|
1639
|
+
const syncFromDocToCodeInner = (d) => blockAwareSyncFromDocToCode(bundle, d);
|
|
934
1640
|
const syncFromCodeToDoc = () => {
|
|
935
|
-
|
|
1641
|
+
blockAwareSyncFromCodeToDoc(bundle, 0);
|
|
936
1642
|
};
|
|
937
1643
|
const syncFromDocToCode = () => {
|
|
938
|
-
|
|
1644
|
+
blockAwareSyncFromDocToCode(bundle, 0);
|
|
939
1645
|
};
|
|
940
1646
|
const prepareMobileFlipToCode = () => {
|
|
941
|
-
if (shouldUseProportionalDocToCodeOnMobileFlip?.() === true) {
|
|
1647
|
+
if (allowProportionalMirror && shouldUseProportionalDocToCodeOnMobileFlip?.() === true) {
|
|
942
1648
|
pendingDocToCode = { k: "mirrorW", ratio: windowScrollRatio() };
|
|
943
1649
|
return;
|
|
944
1650
|
}
|
|
945
|
-
pendingDocToCode = buildDocToCodeFlipPlanBlockAware(docPane, getLinks);
|
|
1651
|
+
pendingDocToCode = buildDocToCodeFlipPlanBlockAware(docPane, codePane, getLinks, sticky, lineIdPrefix(), allowProportionalMirror);
|
|
946
1652
|
};
|
|
947
1653
|
const finishMobileFlipToCode = () => {
|
|
948
1654
|
if (!pendingDocToCode)
|
|
949
1655
|
return;
|
|
950
|
-
|
|
1656
|
+
let p = pendingDocToCode;
|
|
951
1657
|
pendingDocToCode = null;
|
|
1658
|
+
if (p.k === "noop" && p.skipProportionalFallbackOnFlip !== true && allowProportionalMirror) {
|
|
1659
|
+
p = buildDocToCodeFlipPlanProportional(docPane);
|
|
1660
|
+
}
|
|
952
1661
|
applyDocToCodeFlipPlanImpl(codePane, docPane, p, lineIdPrefix());
|
|
953
1662
|
};
|
|
954
1663
|
const prepareMobileFlipToDoc = () => {
|
|
955
|
-
pendingCodeToDoc = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix());
|
|
1664
|
+
pendingCodeToDoc = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix(), sticky, allowProportionalMirror);
|
|
956
1665
|
};
|
|
957
1666
|
const finishMobileFlipToDoc = () => {
|
|
958
1667
|
if (!pendingCodeToDoc)
|
|
959
1668
|
return;
|
|
960
|
-
|
|
1669
|
+
let p = pendingCodeToDoc;
|
|
961
1670
|
pendingCodeToDoc = null;
|
|
1671
|
+
if (p.k === "noop" && allowProportionalMirror) {
|
|
1672
|
+
p = buildCodeToDocFlipPlanProportional(codePane);
|
|
1673
|
+
}
|
|
962
1674
|
applyCodeToDocFlipPlanImpl(codePane, docPane, p);
|
|
963
1675
|
};
|
|
964
|
-
wireBidirectionalScroll(codePane, docPane,
|
|
1676
|
+
wireBidirectionalScroll(codePane, docPane, syncFromCodeToDocInner, syncFromDocToCodeInner);
|
|
965
1677
|
return {
|
|
966
1678
|
syncFromCodeToDoc,
|
|
967
1679
|
syncFromDocToCode,
|
|
@@ -975,11 +1687,49 @@ function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, sho
|
|
|
975
1687
|
function wireProportionalScrollSync(codePane, docPane) {
|
|
976
1688
|
let pendingDocToCode = null;
|
|
977
1689
|
let pendingCodeToDoc = null;
|
|
1690
|
+
const syncFromCodeToDocInner = (driverDelta) => {
|
|
1691
|
+
const docBefore = readPaneVerticalScroll(docPane);
|
|
1692
|
+
const p = buildCodeToDocFlipPlanProportional(codePane);
|
|
1693
|
+
applyCodeToDocFlipPlanImpl(codePane, docPane, p);
|
|
1694
|
+
scrollSyncTrace("code→doc.apply", {
|
|
1695
|
+
mode: "proportional-shell",
|
|
1696
|
+
driverDelta,
|
|
1697
|
+
plan: formatCodeToDocPlanForLog(p),
|
|
1698
|
+
docScrollTopBefore: docBefore,
|
|
1699
|
+
docScrollTopAfter: readPaneVerticalScroll(docPane),
|
|
1700
|
+
codeScrollTop: codePane.scrollTop,
|
|
1701
|
+
});
|
|
1702
|
+
enforceScrollSyncMonotonic({
|
|
1703
|
+
driverDelta,
|
|
1704
|
+
partnerBefore: docBefore,
|
|
1705
|
+
partnerPane: docPane,
|
|
1706
|
+
axis: "code→doc",
|
|
1707
|
+
});
|
|
1708
|
+
};
|
|
1709
|
+
const syncFromDocToCodeInner = (driverDelta) => {
|
|
1710
|
+
const codeBefore = readPaneVerticalScroll(codePane);
|
|
1711
|
+
const p = buildDocToCodeFlipPlanProportional(docPane);
|
|
1712
|
+
applyDocToCodeFlipPlanImpl(codePane, docPane, p);
|
|
1713
|
+
scrollSyncTrace("doc→code.apply", {
|
|
1714
|
+
mode: "proportional-shell",
|
|
1715
|
+
driverDelta,
|
|
1716
|
+
plan: formatDocToCodePlanForLog(p),
|
|
1717
|
+
codeScrollTopBefore: codeBefore,
|
|
1718
|
+
codeScrollTopAfter: readPaneVerticalScroll(codePane),
|
|
1719
|
+
docScrollTop: docPane.scrollTop,
|
|
1720
|
+
});
|
|
1721
|
+
enforceScrollSyncMonotonic({
|
|
1722
|
+
driverDelta,
|
|
1723
|
+
partnerBefore: codeBefore,
|
|
1724
|
+
partnerPane: codePane,
|
|
1725
|
+
axis: "doc→code",
|
|
1726
|
+
});
|
|
1727
|
+
};
|
|
978
1728
|
const syncFromCodeToDoc = () => {
|
|
979
|
-
|
|
1729
|
+
syncFromCodeToDocInner(0);
|
|
980
1730
|
};
|
|
981
1731
|
const syncFromDocToCode = () => {
|
|
982
|
-
|
|
1732
|
+
syncFromDocToCodeInner(0);
|
|
983
1733
|
};
|
|
984
1734
|
const prepareMobileFlipToCode = () => {
|
|
985
1735
|
pendingDocToCode = buildDocToCodeFlipPlanProportional(docPane);
|
|
@@ -1001,7 +1751,7 @@ function wireProportionalScrollSync(codePane, docPane) {
|
|
|
1001
1751
|
pendingCodeToDoc = null;
|
|
1002
1752
|
applyCodeToDocFlipPlanImpl(codePane, docPane, p);
|
|
1003
1753
|
};
|
|
1004
|
-
wireBidirectionalScroll(codePane, docPane,
|
|
1754
|
+
wireBidirectionalScroll(codePane, docPane, syncFromCodeToDocInner, syncFromDocToCodeInner);
|
|
1005
1755
|
return {
|
|
1006
1756
|
syncFromCodeToDoc,
|
|
1007
1757
|
syncFromDocToCode,
|
|
@@ -1032,9 +1782,7 @@ function commentaryBandEndYViewport(docScrollEl, next, docTop, clipThroughPageBr
|
|
|
1032
1782
|
const nextTop = nextEl.getBoundingClientRect().top - 3;
|
|
1033
1783
|
if (!clipThroughPageBreakGaps)
|
|
1034
1784
|
return nextTop;
|
|
1035
|
-
|
|
1036
|
-
const contentBottom = maxRenderableCommentaryContentBottomViewport(docScrollEl, docTop, nextEl);
|
|
1037
|
-
return Math.min(nextTop, Math.max(docBandTop, contentBottom));
|
|
1785
|
+
return commentaryGutterDocBandBottomViewport(docScrollEl, docTop, nextEl);
|
|
1038
1786
|
}
|
|
1039
1787
|
const dr = docScrollEl.getBoundingClientRect();
|
|
1040
1788
|
let bottom = dr.bottom - 4;
|
|
@@ -1107,6 +1855,15 @@ function subscribeBlockRayRedraw(gutter, codePane, docScrollEl, scheduleDraw) {
|
|
|
1107
1855
|
if (shell)
|
|
1108
1856
|
ro.observe(shell);
|
|
1109
1857
|
}
|
|
1858
|
+
/** Doc-aligned active block matches visible commentary; code-only probe can lag in page gaps. */
|
|
1859
|
+
function activeBlockIdForGutterRays(links, docScrollEl, probeTopSourceLine1Based) {
|
|
1860
|
+
const mdLine0ForRay = probeCommentrayLine0FromDoc(docScrollEl);
|
|
1861
|
+
const haveAnchors = docScrollEl.querySelector(".commentray-block-anchor") !== null;
|
|
1862
|
+
if (haveAnchors && mdLine0ForRay !== null) {
|
|
1863
|
+
return activeBlockIdForCommentrayLine0(links, mdLine0ForRay);
|
|
1864
|
+
}
|
|
1865
|
+
return activeBlockIdForViewport(links, probeTopSourceLine1Based());
|
|
1866
|
+
}
|
|
1110
1867
|
function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSourceLine1Based, lineIdPrefix) {
|
|
1111
1868
|
const links = dedupeBlockScrollLinksById(getLinks());
|
|
1112
1869
|
const sorted = sortBlockLinksBySource(links);
|
|
@@ -1117,10 +1874,7 @@ function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSource
|
|
|
1117
1874
|
svg.replaceChildren();
|
|
1118
1875
|
return;
|
|
1119
1876
|
}
|
|
1120
|
-
|
|
1121
|
-
const activeId = docScrollEl.querySelector(".commentray-block-anchor") !== null
|
|
1122
|
-
? activeBlockIdForCommentrayLine0(links, probeCommentrayLine0FromDoc(docScrollEl))
|
|
1123
|
-
: activeBlockIdForViewport(links, probeTopSourceLine1Based());
|
|
1877
|
+
const activeId = activeBlockIdForGutterRays(links, docScrollEl, probeTopSourceLine1Based);
|
|
1124
1878
|
const clipGutterRaysThroughPageBreakGaps = pageBreakPullEnabled();
|
|
1125
1879
|
svg.setAttribute("viewBox", `0 0 ${String(w)} ${String(h)}`);
|
|
1126
1880
|
svg.setAttribute("preserveAspectRatio", "none");
|
|
@@ -1569,12 +2323,52 @@ function wireDocumentedFilesTree() {
|
|
|
1569
2323
|
sum.focus({ preventScroll: true });
|
|
1570
2324
|
}
|
|
1571
2325
|
document.addEventListener("keydown", onDocumentedFilesHubEscape, true);
|
|
2326
|
+
treeMount.addEventListener("keydown", (e) => {
|
|
2327
|
+
if (!detailsHub.open || e.isComposing)
|
|
2328
|
+
return;
|
|
2329
|
+
const t = e.target;
|
|
2330
|
+
if (!(t instanceof HTMLAnchorElement) || !t.classList.contains("tree-file-link"))
|
|
2331
|
+
return;
|
|
2332
|
+
const links = listDocumentedTreeFileLinks(treeMount);
|
|
2333
|
+
if (links.length === 0)
|
|
2334
|
+
return;
|
|
2335
|
+
const idx = links.indexOf(t);
|
|
2336
|
+
if (idx < 0)
|
|
2337
|
+
return;
|
|
2338
|
+
if (e.key === "ArrowDown") {
|
|
2339
|
+
if (idx < links.length - 1) {
|
|
2340
|
+
links[idx + 1].focus({ preventScroll: true });
|
|
2341
|
+
e.preventDefault();
|
|
2342
|
+
}
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if (e.key === "ArrowUp") {
|
|
2346
|
+
if (idx > 0) {
|
|
2347
|
+
links[idx - 1].focus({ preventScroll: true });
|
|
2348
|
+
e.preventDefault();
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
if (filterInput instanceof HTMLInputElement) {
|
|
2352
|
+
filterInput.focus({ preventScroll: true });
|
|
2353
|
+
e.preventDefault();
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
1572
2357
|
if (filterInput instanceof HTMLInputElement) {
|
|
1573
2358
|
filterInput.addEventListener("input", () => {
|
|
1574
2359
|
if (!detailsHub.open || cachedPairs === null)
|
|
1575
2360
|
return;
|
|
1576
2361
|
applyFilterAndRender();
|
|
1577
2362
|
});
|
|
2363
|
+
filterInput.addEventListener("keydown", (e) => {
|
|
2364
|
+
if (!detailsHub.open || e.isComposing || e.key !== "ArrowDown")
|
|
2365
|
+
return;
|
|
2366
|
+
const links = listDocumentedTreeFileLinks(treeMount);
|
|
2367
|
+
if (links.length === 0)
|
|
2368
|
+
return;
|
|
2369
|
+
links[0].focus({ preventScroll: true });
|
|
2370
|
+
e.preventDefault();
|
|
2371
|
+
});
|
|
1578
2372
|
}
|
|
1579
2373
|
}
|
|
1580
2374
|
function wireSplitter(storageSplit, shell, codePane, gutter, initialPct) {
|
|
@@ -1620,6 +2414,13 @@ function normalizedDualMobilePane(v) {
|
|
|
1620
2414
|
function isNarrowViewport() {
|
|
1621
2415
|
return globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ).matches;
|
|
1622
2416
|
}
|
|
2417
|
+
function syncSinglePaneShellState(shell, narrowSinglePane) {
|
|
2418
|
+
if (narrowSinglePane) {
|
|
2419
|
+
shell.setAttribute("data-mobile-single-pane", "true");
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
shell.removeAttribute("data-mobile-single-pane");
|
|
2423
|
+
}
|
|
1623
2424
|
function wireWideModeIntroTrigger(shell) {
|
|
1624
2425
|
const btn = document.getElementById("commentray-help-tour");
|
|
1625
2426
|
if (!(btn instanceof HTMLButtonElement))
|
|
@@ -1677,7 +2478,7 @@ function scheduleMermaidWhenDualDocPaneVisible(shell, mq) {
|
|
|
1677
2478
|
const docBody = document.getElementById("doc-pane-body");
|
|
1678
2479
|
if (!(docBody instanceof HTMLElement))
|
|
1679
2480
|
return;
|
|
1680
|
-
runMermaidOnFreshDocNodes(docBody);
|
|
2481
|
+
void runMermaidOnFreshDocNodes(docBody);
|
|
1681
2482
|
};
|
|
1682
2483
|
queueMicrotask(() => {
|
|
1683
2484
|
kick();
|
|
@@ -1716,7 +2517,7 @@ function wireDualMobilePaneFlipScrollAffordance(primaryFlip, scrollFlip, mq) {
|
|
|
1716
2517
|
mq.addEventListener("change", tick);
|
|
1717
2518
|
globalThis.requestAnimationFrame(tick);
|
|
1718
2519
|
}
|
|
1719
|
-
function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
|
|
2520
|
+
function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip, signal) {
|
|
1720
2521
|
const hideScroll = () => {
|
|
1721
2522
|
scrollFlip.hidden = true;
|
|
1722
2523
|
scrollFlip.classList.remove("is-visible");
|
|
@@ -1726,6 +2527,10 @@ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
|
|
|
1726
2527
|
scrollFlip.classList.add("is-visible");
|
|
1727
2528
|
};
|
|
1728
2529
|
const tick = () => {
|
|
2530
|
+
if (primaryFlip.hidden || primaryFlip.getClientRects().length === 0) {
|
|
2531
|
+
hideScroll();
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
1729
2534
|
const r = primaryFlip.getBoundingClientRect();
|
|
1730
2535
|
const vh = globalThis.innerHeight;
|
|
1731
2536
|
const margin = 10;
|
|
@@ -1735,10 +2540,27 @@ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
|
|
|
1735
2540
|
else
|
|
1736
2541
|
hideScroll();
|
|
1737
2542
|
};
|
|
1738
|
-
globalThis.addEventListener("scroll", tick, { passive: true });
|
|
1739
|
-
globalThis.addEventListener("resize", tick, { passive: true });
|
|
2543
|
+
globalThis.addEventListener("scroll", tick, { passive: true, signal });
|
|
2544
|
+
globalThis.addEventListener("resize", tick, { passive: true, signal });
|
|
1740
2545
|
globalThis.requestAnimationFrame(tick);
|
|
1741
2546
|
}
|
|
2547
|
+
function syncSourceMarkdownToggleVisibility(shell, primaryFlip, scrollFlip) {
|
|
2548
|
+
const hidden = isNarrowViewport() &&
|
|
2549
|
+
normalizedDualMobilePane(shell.getAttribute("data-dual-mobile-pane")) !== "code";
|
|
2550
|
+
primaryFlip.hidden = hidden;
|
|
2551
|
+
if (hidden) {
|
|
2552
|
+
primaryFlip.setAttribute("aria-hidden", "true");
|
|
2553
|
+
primaryFlip.style.setProperty("display", "none", "important");
|
|
2554
|
+
}
|
|
2555
|
+
else {
|
|
2556
|
+
primaryFlip.removeAttribute("aria-hidden");
|
|
2557
|
+
primaryFlip.style.removeProperty("display");
|
|
2558
|
+
}
|
|
2559
|
+
if (!(scrollFlip instanceof HTMLButtonElement))
|
|
2560
|
+
return;
|
|
2561
|
+
scrollFlip.hidden = true;
|
|
2562
|
+
scrollFlip.classList.remove("is-visible");
|
|
2563
|
+
}
|
|
1742
2564
|
function closestSourceLine0ForPaneTop(codePane, idPrefix) {
|
|
1743
2565
|
const rows = codePane.querySelectorAll(`[id^="${idPrefix}"]`);
|
|
1744
2566
|
if (rows.length === 0)
|
|
@@ -1763,13 +2585,17 @@ function closestSourceLine0ForPaneTop(codePane, idPrefix) {
|
|
|
1763
2585
|
return null;
|
|
1764
2586
|
return Number.parseInt(m[1], 10);
|
|
1765
2587
|
}
|
|
1766
|
-
function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onAfterFlip) {
|
|
2588
|
+
function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, signal, onAfterFlip) {
|
|
2589
|
+
function sourceMarkdownBodies() {
|
|
2590
|
+
return Array.from(codePane.querySelectorAll('[data-source-markdown-body="true"]'));
|
|
2591
|
+
}
|
|
1767
2592
|
function syncSourceMarkdownFlipA11y() {
|
|
1768
2593
|
const mode = sourcePaneModeForShell(shell);
|
|
1769
2594
|
const renderedActive = mode === "rendered-markdown";
|
|
1770
2595
|
const nextModeLabel = renderedActive ? "markdown source" : "rendered markdown";
|
|
2596
|
+
const currentModeLabel = renderedActive ? "rendered markdown" : "markdown source";
|
|
1771
2597
|
const ariaLabel = `Switch source pane to ${nextModeLabel}`;
|
|
1772
|
-
const title = `
|
|
2598
|
+
const title = `Switch source pane to ${nextModeLabel} (currently ${currentModeLabel})`;
|
|
1773
2599
|
const apply = (btn) => {
|
|
1774
2600
|
if (!(btn instanceof HTMLButtonElement))
|
|
1775
2601
|
return;
|
|
@@ -1780,10 +2606,21 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onA
|
|
|
1780
2606
|
apply(flipBtn);
|
|
1781
2607
|
apply(flipScrollBtn);
|
|
1782
2608
|
}
|
|
1783
|
-
// Keep initial behavior deterministic: source pane starts in rendered markdown mode.
|
|
1784
|
-
shell.setAttribute("data-source-pane-mode", "rendered-markdown");
|
|
1785
2609
|
syncSourceMarkdownFlipA11y();
|
|
1786
2610
|
syncWrapLinesVisibilityForSourcePaneMode(shell);
|
|
2611
|
+
syncSourceMarkdownToggleVisibility(shell, flipBtn, flipScrollBtn);
|
|
2612
|
+
const onViewportChange = () => {
|
|
2613
|
+
syncSourceMarkdownToggleVisibility(shell, flipBtn, flipScrollBtn);
|
|
2614
|
+
};
|
|
2615
|
+
globalThis.addEventListener("resize", onViewportChange, { passive: true, signal });
|
|
2616
|
+
const mobileMq = globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ);
|
|
2617
|
+
mobileMq.addEventListener("change", onViewportChange, { signal });
|
|
2618
|
+
const observer = new MutationObserver(onViewportChange);
|
|
2619
|
+
observer.observe(shell, {
|
|
2620
|
+
attributes: true,
|
|
2621
|
+
attributeFilter: ["data-dual-mobile-pane", "data-source-pane-mode"],
|
|
2622
|
+
});
|
|
2623
|
+
signal.addEventListener("abort", () => observer.disconnect(), { once: true });
|
|
1787
2624
|
const runFlip = () => {
|
|
1788
2625
|
const cur = sourcePaneModeForShell(shell);
|
|
1789
2626
|
const currentPrefix = cur === "rendered-markdown" ? "code-md-line-" : "code-line-";
|
|
@@ -1792,27 +2629,34 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onA
|
|
|
1792
2629
|
const nextPrefix = next === "rendered-markdown" ? "code-md-line-" : "code-line-";
|
|
1793
2630
|
shell.setAttribute("data-source-pane-mode", next);
|
|
1794
2631
|
writeWebStorageItem(localStorage, STORAGE_SOURCE_MARKDOWN_PANE_MODE, next);
|
|
2632
|
+
if (isNarrowViewport()) {
|
|
2633
|
+
const curPane = normalizedDualMobilePane(shell.getAttribute("data-dual-mobile-pane"));
|
|
2634
|
+
if (curPane === "doc") {
|
|
2635
|
+
shell.setAttribute("data-dual-mobile-pane", "code");
|
|
2636
|
+
writeWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE, "code");
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
1795
2639
|
syncSourceMarkdownFlipA11y();
|
|
1796
2640
|
syncWrapLinesVisibilityForSourcePaneMode(shell);
|
|
1797
|
-
|
|
2641
|
+
const shouldWriteRevealScroll = !isNarrowViewport() || paneUsesInternalYScroll(codePane);
|
|
2642
|
+
if (line0 !== null && shouldWriteRevealScroll) {
|
|
1798
2643
|
const row = codePane.querySelector(`#${nextPrefix}${String(line0)}`);
|
|
1799
2644
|
if (row instanceof HTMLElement) {
|
|
1800
|
-
applyRevealChildInPane(codePane, row,
|
|
2645
|
+
applyRevealChildInPane(codePane, row, 0);
|
|
1801
2646
|
}
|
|
1802
2647
|
}
|
|
1803
2648
|
if (next === "rendered-markdown") {
|
|
1804
|
-
const sourceMdBody
|
|
1805
|
-
|
|
1806
|
-
runMermaidOnFreshDocNodes(sourceMdBody);
|
|
2649
|
+
for (const sourceMdBody of sourceMarkdownBodies()) {
|
|
2650
|
+
void runMermaidOnFreshDocNodes(sourceMdBody);
|
|
1807
2651
|
rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
|
|
1808
2652
|
}
|
|
1809
2653
|
}
|
|
1810
2654
|
onAfterFlip?.();
|
|
1811
2655
|
};
|
|
1812
|
-
flipBtn.addEventListener("click", runFlip);
|
|
2656
|
+
flipBtn.addEventListener("click", runFlip, { signal });
|
|
1813
2657
|
if (flipScrollBtn) {
|
|
1814
|
-
flipScrollBtn.addEventListener("click", runFlip);
|
|
1815
|
-
wireSourceMarkdownPaneFlipAffordance(flipBtn, flipScrollBtn);
|
|
2658
|
+
flipScrollBtn.addEventListener("click", runFlip, { signal });
|
|
2659
|
+
wireSourceMarkdownPaneFlipAffordance(flipBtn, flipScrollBtn, signal);
|
|
1816
2660
|
}
|
|
1817
2661
|
}
|
|
1818
2662
|
function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
|
|
@@ -1822,9 +2666,11 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
|
|
|
1822
2666
|
}
|
|
1823
2667
|
function applyForViewport() {
|
|
1824
2668
|
if (mq.matches) {
|
|
2669
|
+
syncSinglePaneShellState(shell, true);
|
|
1825
2670
|
shell.setAttribute("data-dual-mobile-pane", readStoredPane());
|
|
1826
2671
|
}
|
|
1827
2672
|
else {
|
|
2673
|
+
syncSinglePaneShellState(shell, false);
|
|
1828
2674
|
shell.removeAttribute("data-dual-mobile-pane");
|
|
1829
2675
|
}
|
|
1830
2676
|
}
|
|
@@ -1870,13 +2716,282 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
|
|
|
1870
2716
|
mq.addEventListener("change", applyForViewport);
|
|
1871
2717
|
applyForViewport();
|
|
1872
2718
|
}
|
|
1873
|
-
function
|
|
2719
|
+
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());
|
|
2730
|
+
}
|
|
2731
|
+
else {
|
|
2732
|
+
syncSinglePaneShellState(shell, false);
|
|
2733
|
+
shell.removeAttribute("data-dual-mobile-pane");
|
|
2734
|
+
}
|
|
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(() => {
|
|
2747
|
+
requestAnimationFrame(() => {
|
|
2748
|
+
void runMermaidOnFreshDocNodes(shell);
|
|
2749
|
+
});
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
};
|
|
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();
|
|
2760
|
+
}
|
|
2761
|
+
/** Multi-angle stretch swaps `#shell` innerHTML; disconnect the previous table observer so listeners do not accumulate. */
|
|
2762
|
+
let stretchRowBufferSyncHandle = null;
|
|
2763
|
+
let stretchGutterConnectorHandle = null;
|
|
2764
|
+
function stretchFlowSynchronizerEnabled(shell) {
|
|
2765
|
+
return shell.getAttribute("data-stretch-buffer-sync") === "flow-synchronizer";
|
|
2766
|
+
}
|
|
2767
|
+
function stretchBlockRows(table) {
|
|
2768
|
+
return Array.from(table.querySelectorAll("tbody tr.stretch-row--block[data-commentray-stretch-sync-id]")).filter((row) => (row.dataset.commentrayStretchSyncId?.trim() ?? "").length > 0);
|
|
2769
|
+
}
|
|
2770
|
+
function stretchRowPairRects(row) {
|
|
2771
|
+
const id = row.dataset.commentrayStretchSyncId?.trim() ?? "";
|
|
2772
|
+
if (id.length === 0)
|
|
2773
|
+
return null;
|
|
2774
|
+
const codeTd = row.querySelector(":scope > td.stretch-code");
|
|
2775
|
+
const docTd = row.querySelector(":scope > td.stretch-doc");
|
|
2776
|
+
if (!(codeTd instanceof HTMLTableCellElement) || !(docTd instanceof HTMLTableCellElement))
|
|
2777
|
+
return null;
|
|
2778
|
+
return { id, codeRect: codeTd.getBoundingClientRect(), docRect: docTd.getBoundingClientRect() };
|
|
2779
|
+
}
|
|
2780
|
+
function visibleElementRect(el) {
|
|
2781
|
+
if (!(el instanceof HTMLElement))
|
|
2782
|
+
return null;
|
|
2783
|
+
const rect = el.getBoundingClientRect();
|
|
2784
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
2785
|
+
return null;
|
|
2786
|
+
const style = globalThis.getComputedStyle(el);
|
|
2787
|
+
if (style.display === "none" || style.visibility === "hidden")
|
|
2788
|
+
return null;
|
|
2789
|
+
return rect;
|
|
2790
|
+
}
|
|
2791
|
+
function stretchVisibleSourcePane(codeTd, shell) {
|
|
2792
|
+
const preferredSel = sourcePaneModeForShell(shell) === "rendered-markdown"
|
|
2793
|
+
? ".source-pane--rendered-md"
|
|
2794
|
+
: ".source-pane--code";
|
|
2795
|
+
const preferred = codeTd.querySelector(preferredSel);
|
|
2796
|
+
if (visibleElementRect(preferred) !== null)
|
|
2797
|
+
return preferred;
|
|
2798
|
+
const fallbacks = codeTd.querySelectorAll(".source-pane--code, .source-pane--rendered-md");
|
|
2799
|
+
for (const el of fallbacks) {
|
|
2800
|
+
if (visibleElementRect(el) !== null)
|
|
2801
|
+
return el;
|
|
2802
|
+
}
|
|
2803
|
+
return null;
|
|
2804
|
+
}
|
|
2805
|
+
function stretchCodeContentBottomViewport(codeTd, shell) {
|
|
2806
|
+
const visiblePane = stretchVisibleSourcePane(codeTd, shell);
|
|
2807
|
+
const paneRect = visibleElementRect(visiblePane);
|
|
2808
|
+
if (paneRect !== null)
|
|
2809
|
+
return paneRect.bottom;
|
|
2810
|
+
return codeTd.getBoundingClientRect().bottom;
|
|
2811
|
+
}
|
|
2812
|
+
function stretchDocContentBottomViewport(docTd) {
|
|
2813
|
+
const inner = docTd.querySelector(":scope .stretch-doc-inner");
|
|
2814
|
+
const innerRect = visibleElementRect(inner);
|
|
2815
|
+
if (innerRect !== null)
|
|
2816
|
+
return innerRect.bottom;
|
|
2817
|
+
return docTd.getBoundingClientRect().bottom;
|
|
2818
|
+
}
|
|
2819
|
+
function activeStretchSyncId(shell, rows) {
|
|
2820
|
+
const shellRect = shell.getBoundingClientRect();
|
|
2821
|
+
const probeY = shellRect.top + Math.min(Math.max(shellRect.height * 0.18, 24), 120);
|
|
2822
|
+
for (const row of rows) {
|
|
2823
|
+
const rect = row.getBoundingClientRect();
|
|
2824
|
+
if (rect.top <= probeY && rect.bottom >= probeY) {
|
|
2825
|
+
return row.dataset.commentrayStretchSyncId?.trim() ?? null;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
return rows[0]?.dataset.commentrayStretchSyncId?.trim() ?? null;
|
|
2829
|
+
}
|
|
2830
|
+
function drawStretchGutterConnectorsIntoSvg(svg, shell, table, gutter) {
|
|
2831
|
+
const rows = stretchBlockRows(table);
|
|
2832
|
+
const gutterRect = gutter.getBoundingClientRect();
|
|
2833
|
+
const w = gutterRect.width;
|
|
2834
|
+
const h = gutterRect.height;
|
|
2835
|
+
if (w <= 0 || h <= 0 || rows.length === 0) {
|
|
2836
|
+
svg.replaceChildren();
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
svg.setAttribute("viewBox", `0 0 ${String(w)} ${String(h)}`);
|
|
2840
|
+
svg.setAttribute("preserveAspectRatio", "none");
|
|
2841
|
+
const activeId = activeStretchSyncId(shell, rows);
|
|
2842
|
+
const parts = [];
|
|
2843
|
+
for (const row of rows) {
|
|
2844
|
+
const pair = stretchRowPairRects(row);
|
|
2845
|
+
if (pair === null)
|
|
2846
|
+
continue;
|
|
2847
|
+
const codeTd = row.querySelector(":scope > td.stretch-code");
|
|
2848
|
+
const docTd = row.querySelector(":scope > td.stretch-doc");
|
|
2849
|
+
if (!(codeTd instanceof HTMLTableCellElement) || !(docTd instanceof HTMLTableCellElement))
|
|
2850
|
+
continue;
|
|
2851
|
+
const codeContentBottom = stretchCodeContentBottomViewport(codeTd, shell);
|
|
2852
|
+
const docContentBottom = stretchDocContentBottomViewport(docTd);
|
|
2853
|
+
const codeTop = clampViewportYToGutterLocal(pair.codeRect.top + 1, gutterRect.top, h);
|
|
2854
|
+
const docTop = clampViewportYToGutterLocal(pair.docRect.top + 1, gutterRect.top, h);
|
|
2855
|
+
const codeBottom = clampViewportYToGutterLocal(codeContentBottom - 1, gutterRect.top, h);
|
|
2856
|
+
const docBottom = clampViewportYToGutterLocal(docContentBottom - 1, gutterRect.top, h);
|
|
2857
|
+
const strokeClass = pair.id === activeId ? "gutter__rays-path gutter__rays-path--active" : "gutter__rays-path";
|
|
2858
|
+
const trailClass = `${strokeClass} gutter__rays-path--trail`;
|
|
2859
|
+
const topPaths = gutterRayBezierPaths(0, codeTop.y, w, docTop.y, {
|
|
2860
|
+
tension: 0.38,
|
|
2861
|
+
clipStart: codeTop.clipped,
|
|
2862
|
+
clipEnd: docTop.clipped,
|
|
2863
|
+
});
|
|
2864
|
+
const bottomPaths = gutterRayBezierPaths(0, codeBottom.y, w, docBottom.y, {
|
|
2865
|
+
tension: 0.38,
|
|
2866
|
+
clipStart: codeBottom.clipped,
|
|
2867
|
+
clipEnd: docBottom.clipped,
|
|
2868
|
+
});
|
|
2869
|
+
const topExtra = topPaths.dotted ? `<path class="${trailClass}" d="${topPaths.dotted}" />` : "";
|
|
2870
|
+
const bottomExtra = bottomPaths.dotted
|
|
2871
|
+
? `<path class="${trailClass}" d="${bottomPaths.dotted}" />`
|
|
2872
|
+
: "";
|
|
2873
|
+
parts.push(`<g class="gutter__rays-block" data-commentray-block="${escapeHtmlText(pair.id)}">` +
|
|
2874
|
+
`<path class="${strokeClass}" d="${topPaths.solid}" />` +
|
|
2875
|
+
topExtra +
|
|
2876
|
+
`<path class="${strokeClass}" d="${bottomPaths.solid}" />` +
|
|
2877
|
+
bottomExtra +
|
|
2878
|
+
`</g>`);
|
|
2879
|
+
}
|
|
2880
|
+
svg.innerHTML = parts.join("");
|
|
2881
|
+
}
|
|
2882
|
+
function wireStretchGutterConnectors(shell, table, gutter) {
|
|
2883
|
+
gutter.querySelector(":scope > .gutter__rays")?.remove();
|
|
2884
|
+
const svgNs = "http://www.w3.org/2000/svg";
|
|
2885
|
+
const host = document.createElement("div");
|
|
2886
|
+
host.className = "gutter__rays";
|
|
2887
|
+
host.setAttribute("aria-hidden", "true");
|
|
2888
|
+
const svg = document.createElementNS(svgNs, "svg");
|
|
2889
|
+
host.appendChild(svg);
|
|
2890
|
+
gutter.appendChild(host);
|
|
2891
|
+
let raf = 0;
|
|
2892
|
+
const scheduleDraw = () => {
|
|
2893
|
+
if (raf !== 0)
|
|
2894
|
+
return;
|
|
2895
|
+
raf = globalThis.requestAnimationFrame(() => {
|
|
2896
|
+
raf = 0;
|
|
2897
|
+
drawStretchGutterConnectorsIntoSvg(svg, shell, table, gutter);
|
|
2898
|
+
});
|
|
2899
|
+
};
|
|
2900
|
+
const onScrollOrResize = () => scheduleDraw();
|
|
2901
|
+
shell.addEventListener("scroll", onScrollOrResize, { passive: true });
|
|
2902
|
+
globalThis.addEventListener("resize", onScrollOrResize);
|
|
2903
|
+
const ro = new ResizeObserver(onScrollOrResize);
|
|
2904
|
+
ro.observe(shell);
|
|
2905
|
+
ro.observe(table);
|
|
2906
|
+
ro.observe(gutter);
|
|
2907
|
+
scheduleDraw();
|
|
2908
|
+
globalThis.requestAnimationFrame(() => {
|
|
2909
|
+
scheduleDraw();
|
|
2910
|
+
globalThis.requestAnimationFrame(scheduleDraw);
|
|
2911
|
+
});
|
|
2912
|
+
return {
|
|
2913
|
+
disconnect: () => {
|
|
2914
|
+
if (raf !== 0)
|
|
2915
|
+
globalThis.cancelAnimationFrame(raf);
|
|
2916
|
+
shell.removeEventListener("scroll", onScrollOrResize);
|
|
2917
|
+
globalThis.removeEventListener("resize", onScrollOrResize);
|
|
2918
|
+
ro.disconnect();
|
|
2919
|
+
host.remove();
|
|
2920
|
+
},
|
|
2921
|
+
request: scheduleDraw,
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
function wireStretchSplitter(shell, codePane) {
|
|
2925
|
+
const gutter = document.getElementById("stretch-gutter");
|
|
2926
|
+
if (!(gutter instanceof HTMLElement))
|
|
2927
|
+
return;
|
|
2928
|
+
const stored = Number.parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) ?? "");
|
|
2929
|
+
const initial = clamp(Number.isFinite(stored) ? stored : 50, 15, 85);
|
|
2930
|
+
shell.style.setProperty("--stretch-code-pct", `${String(initial)}%`);
|
|
2931
|
+
let dragging = false;
|
|
2932
|
+
const onMove = (ev) => {
|
|
2933
|
+
if (!dragging)
|
|
2934
|
+
return;
|
|
2935
|
+
const rect = codePane.getBoundingClientRect();
|
|
2936
|
+
if (rect.width <= 0)
|
|
2937
|
+
return;
|
|
2938
|
+
const x = ev.clientX - rect.left;
|
|
2939
|
+
const pct = clamp((x / rect.width) * 100, 15, 85);
|
|
2940
|
+
shell.style.setProperty("--stretch-code-pct", `${String(pct)}%`);
|
|
2941
|
+
writeWebStorageItem(localStorage, STORAGE_SPLIT_PCT, String(pct));
|
|
2942
|
+
stretchGutterConnectorHandle?.request();
|
|
2943
|
+
};
|
|
2944
|
+
const onUp = () => {
|
|
2945
|
+
if (!dragging)
|
|
2946
|
+
return;
|
|
2947
|
+
dragging = false;
|
|
2948
|
+
document.body.style.cursor = "";
|
|
2949
|
+
document.body.style.userSelect = "";
|
|
2950
|
+
globalThis.removeEventListener("mousemove", onMove);
|
|
2951
|
+
globalThis.removeEventListener("mouseup", onUp);
|
|
2952
|
+
};
|
|
2953
|
+
gutter.addEventListener("mousedown", (ev) => {
|
|
2954
|
+
ev.preventDefault();
|
|
2955
|
+
dragging = true;
|
|
2956
|
+
document.body.style.cursor = "col-resize";
|
|
2957
|
+
document.body.style.userSelect = "none";
|
|
2958
|
+
globalThis.addEventListener("mousemove", onMove);
|
|
2959
|
+
globalThis.addEventListener("mouseup", onUp);
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
/** Wrap toggle always; `BufferingFlowSynchronizer` padding pass only when `#shell` has `data-stretch-buffer-sync="flow-synchronizer"`. */
|
|
2963
|
+
function wireStretchLayoutChrome(shell, codePane) {
|
|
2964
|
+
if (codePane instanceof HTMLTableElement && stretchFlowSynchronizerEnabled(shell)) {
|
|
2965
|
+
stretchRowBufferSyncHandle?.disconnect();
|
|
2966
|
+
stretchRowBufferSyncHandle = wireBlockStretchBufferSync(codePane);
|
|
2967
|
+
}
|
|
2968
|
+
else {
|
|
2969
|
+
stretchRowBufferSyncHandle?.disconnect();
|
|
2970
|
+
stretchRowBufferSyncHandle = null;
|
|
2971
|
+
}
|
|
1874
2972
|
const wrapCb = document.getElementById("wrap-lines");
|
|
1875
2973
|
if (wrapCb) {
|
|
1876
2974
|
wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb, () => {
|
|
1877
2975
|
globalThis.dispatchEvent(new Event("resize"));
|
|
1878
2976
|
});
|
|
1879
2977
|
}
|
|
2978
|
+
const gutter = document.getElementById("stretch-gutter");
|
|
2979
|
+
stretchGutterConnectorHandle?.disconnect();
|
|
2980
|
+
stretchGutterConnectorHandle =
|
|
2981
|
+
codePane instanceof HTMLElement && gutter instanceof HTMLElement
|
|
2982
|
+
? wireStretchGutterConnectors(shell, codePane, gutter)
|
|
2983
|
+
: null;
|
|
2984
|
+
wireStretchSplitter(shell, codePane);
|
|
2985
|
+
}
|
|
2986
|
+
function multiAngleAngleRowLooksValid(row, layoutMode) {
|
|
2987
|
+
if (row === null || typeof row !== "object")
|
|
2988
|
+
return false;
|
|
2989
|
+
const a = row;
|
|
2990
|
+
if (typeof a.id !== "string" || typeof a.docInnerHtmlB64 !== "string")
|
|
2991
|
+
return false;
|
|
2992
|
+
if (layoutMode !== "stretch")
|
|
2993
|
+
return true;
|
|
2994
|
+
return typeof a.stretchSwapInnerB64 === "string" && a.stretchSwapInnerB64.trim().length > 0;
|
|
1880
2995
|
}
|
|
1881
2996
|
function parseMultiAnglePayload(script) {
|
|
1882
2997
|
const t = script?.textContent?.trim() ?? "";
|
|
@@ -1886,29 +3001,133 @@ function parseMultiAnglePayload(script) {
|
|
|
1886
3001
|
const raw = JSON.parse(decodeBase64Utf8(t));
|
|
1887
3002
|
if (!raw || !Array.isArray(raw.angles) || raw.angles.length < 2)
|
|
1888
3003
|
return null;
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
}
|
|
1893
|
-
return raw;
|
|
3004
|
+
const layoutMode = raw.layoutMode === "stretch" ? "stretch" : "dual";
|
|
3005
|
+
if (!raw.angles.every((row) => multiAngleAngleRowLooksValid(row, layoutMode)))
|
|
3006
|
+
return null;
|
|
3007
|
+
return { ...raw, layoutMode };
|
|
1894
3008
|
}
|
|
1895
3009
|
catch {
|
|
1896
3010
|
return null;
|
|
1897
3011
|
}
|
|
1898
3012
|
}
|
|
1899
|
-
function
|
|
3013
|
+
function applyMultiAngleStretchAngleToShell(shell, angle) {
|
|
3014
|
+
const innerB64 = angle.stretchSwapInnerB64;
|
|
3015
|
+
if (innerB64 === undefined || innerB64.trim().length === 0)
|
|
3016
|
+
return;
|
|
3017
|
+
shell.innerHTML = decodeBase64Utf8(innerB64);
|
|
3018
|
+
const nextCodePane = document.getElementById("code-pane");
|
|
3019
|
+
if (nextCodePane instanceof HTMLElement) {
|
|
3020
|
+
wireStretchLayoutChrome(shell, nextCodePane);
|
|
3021
|
+
wireSourceMarkdownControls(shell, nextCodePane, () => {
|
|
3022
|
+
globalThis.dispatchEvent(new Event("resize"));
|
|
3023
|
+
});
|
|
3024
|
+
const flipBtn = document.getElementById("mobile-pane-flip");
|
|
3025
|
+
const flipScrollBtn = document.getElementById("mobile-pane-flip-scroll");
|
|
3026
|
+
if (flipBtn instanceof HTMLButtonElement) {
|
|
3027
|
+
wireStretchMobilePaneFlip(shell, nextCodePane, flipBtn, flipScrollBtn instanceof HTMLButtonElement ? flipScrollBtn : null, () => {
|
|
3028
|
+
globalThis.dispatchEvent(new Event("resize"));
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
shell.setAttribute("data-scroll-block-links-b64", angle.scrollBlockLinksB64);
|
|
3033
|
+
shell.setAttribute("data-raw-md-b64", angle.rawMdB64);
|
|
3034
|
+
shell.setAttribute("data-search-commentray-path", angle.commentrayPathForSearch);
|
|
3035
|
+
const crIdentity = normPosixPath(angle.commentrayPathForSearch);
|
|
3036
|
+
if (crIdentity.length > 0)
|
|
3037
|
+
shell.setAttribute("data-commentray-pair-commentray-path", crIdentity);
|
|
3038
|
+
else
|
|
3039
|
+
shell.removeAttribute("data-commentray-pair-commentray-path");
|
|
3040
|
+
const docPathEl = document.getElementById("nav-rail-doc-path");
|
|
3041
|
+
if (docPathEl) {
|
|
3042
|
+
const path = angle.commentrayPathForSearch.trim();
|
|
3043
|
+
docPathEl.textContent = path.length > 0 ? path : "—";
|
|
3044
|
+
if (path.length > 0)
|
|
3045
|
+
docPathEl.setAttribute("title", path);
|
|
3046
|
+
else
|
|
3047
|
+
docPathEl.removeAttribute("title");
|
|
3048
|
+
}
|
|
3049
|
+
const browse = angle.staticBrowseUrl?.trim() ?? "";
|
|
3050
|
+
if (browse.length > 0) {
|
|
3051
|
+
const resolved = staticBrowseHrefForShellDataAttribute(browse, globalThis.location.pathname, globalThis.location.origin);
|
|
3052
|
+
shell.setAttribute("data-commentray-pair-browse-href", resolved);
|
|
3053
|
+
}
|
|
3054
|
+
else {
|
|
3055
|
+
const ghu = angle.commentrayOnGithubUrl?.trim();
|
|
3056
|
+
if (ghu)
|
|
3057
|
+
shell.setAttribute("data-commentray-pair-browse-href", ghu);
|
|
3058
|
+
else
|
|
3059
|
+
shell.removeAttribute("data-commentray-pair-browse-href");
|
|
3060
|
+
}
|
|
3061
|
+
applyDocumentedTreeCurrentPairHighlight();
|
|
3062
|
+
assignLocationToCanonicalBrowsePermalinkIfNeeded(shell);
|
|
3063
|
+
void runMermaidOnFreshDocNodes(shell);
|
|
3064
|
+
rewriteHubRelativeBrowseAnchorsIn(shell);
|
|
3065
|
+
rootScrollingElement().scrollTop = 0;
|
|
3066
|
+
}
|
|
3067
|
+
function wireMultiAngleStretchAngleSelect(shell, payload, onAngleApplied) {
|
|
3068
|
+
if (payload.layoutMode !== "stretch")
|
|
3069
|
+
return;
|
|
3070
|
+
const angleSel = document.getElementById("angle-select");
|
|
3071
|
+
if (!angleSel)
|
|
3072
|
+
return;
|
|
3073
|
+
angleSel.addEventListener("change", () => {
|
|
3074
|
+
const a = payload.angles.find((x) => x.id === angleSel.value);
|
|
3075
|
+
if (!a)
|
|
3076
|
+
return;
|
|
3077
|
+
applyMultiAngleStretchAngleToShell(shell, a);
|
|
3078
|
+
onAngleApplied?.(a);
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
3081
|
+
function wireStretchSearchUi(shell, codePane) {
|
|
3082
|
+
const searchDom = readDualPaneSearchDom();
|
|
3083
|
+
if (!searchDom)
|
|
3084
|
+
return;
|
|
3085
|
+
const bundle = buildDualPaneSearcherBundle(shell, codePane);
|
|
3086
|
+
const { searchInput, searchClear, searchResults } = searchDom;
|
|
3087
|
+
wireSearchUi({
|
|
3088
|
+
scope: bundle.scope,
|
|
3089
|
+
filePathLabel: bundle.filePathLabel,
|
|
3090
|
+
mutable: bundle.mutable,
|
|
3091
|
+
rawCode: bundle.rawCode,
|
|
3092
|
+
searchInput,
|
|
3093
|
+
searchClear,
|
|
3094
|
+
searchResults,
|
|
3095
|
+
docScrollEl: shell,
|
|
3096
|
+
});
|
|
3097
|
+
wireDualPaneNavSearchFetch(shell, bundle.pathInit.documentedPairs, bundle.indexState, bundle.mutable, bundle.rebuildSearcher, searchInput);
|
|
3098
|
+
const multiScript = document.getElementById("commentray-multi-angle-b64");
|
|
3099
|
+
const multiPayload = parseMultiAnglePayload(multiScript);
|
|
3100
|
+
if (multiPayload?.layoutMode !== "stretch")
|
|
3101
|
+
return;
|
|
3102
|
+
wireMultiAngleStretchAngleSelect(shell, multiPayload, (angle) => {
|
|
3103
|
+
bundle.mutable.rawMd = decodeBase64Utf8(angle.rawMdB64);
|
|
3104
|
+
bundle.mutable.mdLines = bundle.mutable.rawMd.split("\n");
|
|
3105
|
+
bundle.mutable.commentrayPathLabel = angle.commentrayPathForSearch;
|
|
3106
|
+
bundle.rebuildSearcher();
|
|
3107
|
+
searchInput.value = "";
|
|
3108
|
+
searchResults.innerHTML = "";
|
|
3109
|
+
searchResults.hidden = true;
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
function readDualPaneCoreDom() {
|
|
1900
3113
|
const docPane = document.getElementById("doc-pane");
|
|
1901
3114
|
const gutter = document.getElementById("gutter");
|
|
1902
3115
|
const wrapCb = document.getElementById("wrap-lines");
|
|
3116
|
+
if (!docPane || !gutter || !wrapCb) {
|
|
3117
|
+
return null;
|
|
3118
|
+
}
|
|
3119
|
+
const docBody = document.getElementById("doc-pane-body");
|
|
3120
|
+
const docScrollEl = docBody instanceof HTMLElement ? docBody : docPane;
|
|
3121
|
+
return { docBody, docScrollEl, gutter, wrapCb };
|
|
3122
|
+
}
|
|
3123
|
+
function readDualPaneSearchDom() {
|
|
1903
3124
|
const searchInput = document.getElementById("search-q");
|
|
1904
3125
|
const searchClear = document.getElementById("search-clear");
|
|
1905
3126
|
const searchResults = document.getElementById("search-results");
|
|
1906
|
-
if (!
|
|
3127
|
+
if (!searchInput || !searchClear || !searchResults) {
|
|
1907
3128
|
return null;
|
|
1908
3129
|
}
|
|
1909
|
-
|
|
1910
|
-
const docScrollEl = docBody instanceof HTMLElement ? docBody : docPane;
|
|
1911
|
-
return { docBody, docScrollEl, gutter, wrapCb, searchInput, searchClear, searchResults };
|
|
3130
|
+
return { searchInput, searchClear, searchResults };
|
|
1912
3131
|
}
|
|
1913
3132
|
function hubSearcherRowsForDualPane(args) {
|
|
1914
3133
|
const { scope, rawCode, filePathLabel, hubNavRows, pathRowsForOrdering, rawMd, commentrayPathLabel, } = args;
|
|
@@ -2014,7 +3233,7 @@ function wireDualPaneNavSearchFetch(shell, embeddedPairs, indexState, mutable, r
|
|
|
2014
3233
|
function applySelectedMultiAngle(args) {
|
|
2015
3234
|
const { angle, docBody, mutable, rebuildSearcher, scrollLinksRef, shell, searchInput, searchResults, requestBlockRayRedraw, } = args;
|
|
2016
3235
|
docBody.innerHTML = decodeBase64Utf8(angle.docInnerHtmlB64);
|
|
2017
|
-
runMermaidOnFreshDocNodes(docBody);
|
|
3236
|
+
void runMermaidOnFreshDocNodes(docBody);
|
|
2018
3237
|
rewriteHubRelativeBrowseAnchorsIn(docBody);
|
|
2019
3238
|
mutable.rawMd = decodeBase64Utf8(angle.rawMdB64);
|
|
2020
3239
|
mutable.mdLines = mutable.rawMd.split("\n");
|
|
@@ -2050,9 +3269,12 @@ function applySelectedMultiAngle(args) {
|
|
|
2050
3269
|
else
|
|
2051
3270
|
shell.removeAttribute("data-commentray-pair-browse-href");
|
|
2052
3271
|
}
|
|
2053
|
-
searchInput
|
|
2054
|
-
|
|
2055
|
-
|
|
3272
|
+
if (searchInput && searchResults) {
|
|
3273
|
+
searchInput.value = "";
|
|
3274
|
+
searchResults.innerHTML = "";
|
|
3275
|
+
searchResults.hidden = true;
|
|
3276
|
+
}
|
|
3277
|
+
assignLocationToCanonicalBrowsePermalinkIfNeeded(shell);
|
|
2056
3278
|
requestBlockRayRedraw?.();
|
|
2057
3279
|
globalThis.requestAnimationFrame(() => {
|
|
2058
3280
|
requestBlockRayRedraw?.();
|
|
@@ -2061,10 +3283,14 @@ function applySelectedMultiAngle(args) {
|
|
|
2061
3283
|
});
|
|
2062
3284
|
});
|
|
2063
3285
|
}
|
|
2064
|
-
|
|
3286
|
+
/**
|
|
3287
|
+
* Indexed / multi-angle wiring plus proportional fallback when there is no block map.
|
|
3288
|
+
* `allowProportionalMirror: false` implements **block-snap-only** (partner holds in gaps / no index).
|
|
3289
|
+
*/
|
|
3290
|
+
function wireDualPaneScrollIndexedOrProportional(args, blockAwareOpts) {
|
|
2065
3291
|
const { codePane, docScrollEl, docBody, shell, scrollLinksRef, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, requestBlockRayRedraw, } = args;
|
|
2066
3292
|
if (multiPayload) {
|
|
2067
|
-
const runners = wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current, () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown");
|
|
3293
|
+
const runners = wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current, () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown", blockAwareOpts);
|
|
2068
3294
|
const angleSel = document.getElementById("angle-select");
|
|
2069
3295
|
if (angleSel && docBody) {
|
|
2070
3296
|
angleSel.addEventListener("change", () => {
|
|
@@ -2087,9 +3313,19 @@ function wireDualPaneMultiAngleAndScroll(args) {
|
|
|
2087
3313
|
return runners;
|
|
2088
3314
|
}
|
|
2089
3315
|
if (scrollLinksRef.current.length > 0) {
|
|
2090
|
-
return wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current, () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown");
|
|
3316
|
+
return wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current, () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown", blockAwareOpts);
|
|
2091
3317
|
}
|
|
2092
|
-
|
|
3318
|
+
if (blockAwareOpts.allowProportionalMirror) {
|
|
3319
|
+
return wireProportionalScrollSync(codePane, docScrollEl);
|
|
3320
|
+
}
|
|
3321
|
+
return wireBlockAwareScrollSync(codePane, docScrollEl, () => [], () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown", blockAwareOpts);
|
|
3322
|
+
}
|
|
3323
|
+
function wireDualPaneMultiAngleAndScroll(args) {
|
|
3324
|
+
const strategy = parseDualPaneScrollSyncStrategy(args.shell.getAttribute("data-scroll-sync-strategy"));
|
|
3325
|
+
const blockAwareOpts = strategy === "block-snap-only"
|
|
3326
|
+
? { allowProportionalMirror: false }
|
|
3327
|
+
: { allowProportionalMirror: true };
|
|
3328
|
+
return wireDualPaneScrollIndexedOrProportional(args, blockAwareOpts);
|
|
2093
3329
|
}
|
|
2094
3330
|
function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
|
|
2095
3331
|
function commentrayMdLineFromLocationHash(rawHash) {
|
|
@@ -2124,18 +3360,23 @@ function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
|
|
|
2124
3360
|
function initializeSourceMarkdownPane(shell) {
|
|
2125
3361
|
if (sourcePaneModeForShell(shell) !== "rendered-markdown")
|
|
2126
3362
|
return;
|
|
2127
|
-
const
|
|
2128
|
-
if (!(
|
|
3363
|
+
const codePane = document.getElementById("code-pane");
|
|
3364
|
+
if (!(codePane instanceof HTMLElement))
|
|
2129
3365
|
return;
|
|
2130
|
-
|
|
2131
|
-
|
|
3366
|
+
for (const sourceMdBody of codePane.querySelectorAll('[data-source-markdown-body="true"]')) {
|
|
3367
|
+
void runMermaidOnFreshDocNodes(sourceMdBody);
|
|
3368
|
+
rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
|
|
3369
|
+
}
|
|
2132
3370
|
}
|
|
3371
|
+
let sourceMarkdownControlsAbort = null;
|
|
2133
3372
|
function wireSourceMarkdownControls(shell, codePane, onAfterFlip) {
|
|
2134
3373
|
const sourceMdFlip = document.getElementById("source-markdown-pane-flip");
|
|
2135
3374
|
const sourceMdFlipScroll = document.getElementById("source-markdown-pane-flip-scroll");
|
|
2136
3375
|
if (!(sourceMdFlip instanceof HTMLButtonElement))
|
|
2137
3376
|
return;
|
|
2138
|
-
|
|
3377
|
+
sourceMarkdownControlsAbort?.abort();
|
|
3378
|
+
sourceMarkdownControlsAbort = new AbortController();
|
|
3379
|
+
wireSourceMarkdownPaneFlip(shell, codePane, sourceMdFlip, sourceMdFlipScroll instanceof HTMLButtonElement ? sourceMdFlipScroll : null, sourceMarkdownControlsAbort.signal, onAfterFlip);
|
|
2139
3380
|
initializeSourceMarkdownPane(shell);
|
|
2140
3381
|
}
|
|
2141
3382
|
function buildDualPaneSearcherBundle(shell, codePane) {
|
|
@@ -2186,23 +3427,28 @@ function buildDualPaneSearcherBundle(shell, codePane) {
|
|
|
2186
3427
|
};
|
|
2187
3428
|
}
|
|
2188
3429
|
function wireDualPaneCodeBrowser(shell, codePane) {
|
|
2189
|
-
const
|
|
2190
|
-
if (!
|
|
3430
|
+
const coreDom = readDualPaneCoreDom();
|
|
3431
|
+
if (!coreDom) {
|
|
2191
3432
|
return;
|
|
2192
|
-
|
|
3433
|
+
}
|
|
3434
|
+
const { docBody, docScrollEl, gutter, wrapCb } = coreDom;
|
|
3435
|
+
const searchDom = readDualPaneSearchDom();
|
|
2193
3436
|
const bundle = buildDualPaneSearcherBundle(shell, codePane);
|
|
2194
3437
|
rewriteHubRelativeBrowseAnchorsIn(document);
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
3438
|
+
if (searchDom) {
|
|
3439
|
+
const { searchInput, searchClear, searchResults } = searchDom;
|
|
3440
|
+
wireSearchUi({
|
|
3441
|
+
scope: bundle.scope,
|
|
3442
|
+
filePathLabel: bundle.filePathLabel,
|
|
3443
|
+
mutable: bundle.mutable,
|
|
3444
|
+
rawCode: bundle.rawCode,
|
|
3445
|
+
searchInput,
|
|
3446
|
+
searchClear,
|
|
3447
|
+
searchResults,
|
|
3448
|
+
docScrollEl,
|
|
3449
|
+
});
|
|
3450
|
+
wireDualPaneNavSearchFetch(shell, bundle.pathInit.documentedPairs, bundle.indexState, bundle.mutable, bundle.rebuildSearcher, searchInput);
|
|
3451
|
+
}
|
|
2206
3452
|
const pct0 = parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) || "46");
|
|
2207
3453
|
const pct = clamp(Number.isFinite(pct0) ? pct0 : 46, 15, 85);
|
|
2208
3454
|
codePane.style.flex = `0 0 ${pct}%`;
|
|
@@ -2238,8 +3484,8 @@ function wireDualPaneCodeBrowser(shell, codePane) {
|
|
|
2238
3484
|
multiPayload,
|
|
2239
3485
|
mutable: bundle.mutable,
|
|
2240
3486
|
rebuildSearcher: bundle.rebuildSearcher,
|
|
2241
|
-
searchInput,
|
|
2242
|
-
searchResults,
|
|
3487
|
+
searchInput: searchDom?.searchInput,
|
|
3488
|
+
searchResults: searchDom?.searchResults,
|
|
2243
3489
|
requestBlockRayRedraw,
|
|
2244
3490
|
});
|
|
2245
3491
|
const flipBtn = document.getElementById("mobile-pane-flip");
|
|
@@ -2365,46 +3611,9 @@ function safePermalinkHref(raw) {
|
|
|
2365
3611
|
return null;
|
|
2366
3612
|
}
|
|
2367
3613
|
}
|
|
2368
|
-
function humaneBrowseAliasPathForSource(sourcePath) {
|
|
2369
|
-
return sourcePath
|
|
2370
|
-
.split("/")
|
|
2371
|
-
.filter((seg) => seg.length > 0)
|
|
2372
|
-
.map((seg) => seg.startsWith(".") ? `%2E${encodeURIComponent(seg.slice(1))}` : encodeURIComponent(seg))
|
|
2373
|
-
.join("/");
|
|
2374
|
-
}
|
|
2375
|
-
function companionStemFromCommentrayPath(commentrayPath) {
|
|
2376
|
-
const norm = normPosixPath(commentrayPath);
|
|
2377
|
-
const last = norm.split("/").filter(Boolean).at(-1) ?? "";
|
|
2378
|
-
return last.replace(/\.md$/i, "").trim();
|
|
2379
|
-
}
|
|
2380
3614
|
function makeAbsoluteUrlAgainst(raw, baseHref) {
|
|
2381
3615
|
return new URL(raw, baseHref).toString();
|
|
2382
3616
|
}
|
|
2383
|
-
function shellEligibleForHumaneBackfill(shell, pathname) {
|
|
2384
|
-
if ((shell.getAttribute("data-layout") ?? "dual") !== "dual")
|
|
2385
|
-
return false;
|
|
2386
|
-
if (pathname.includes("/browse/"))
|
|
2387
|
-
return false;
|
|
2388
|
-
return pathname.endsWith("/") || pathname.endsWith("/index.html");
|
|
2389
|
-
}
|
|
2390
|
-
function nextHumaneBrowsePathForShell(shell, pathname) {
|
|
2391
|
-
const sourcePath = normPosixPath(shell.getAttribute("data-commentray-pair-source-path") ?? "");
|
|
2392
|
-
if (sourcePath.length === 0)
|
|
2393
|
-
return null;
|
|
2394
|
-
const alias = humaneBrowseAliasPathForSource(sourcePath);
|
|
2395
|
-
if (alias.length === 0)
|
|
2396
|
-
return null;
|
|
2397
|
-
const angleSel = document.getElementById("angle-select");
|
|
2398
|
-
const selectedAngle = angleSel instanceof HTMLSelectElement ? angleSel.value.trim() : "";
|
|
2399
|
-
const commentrayPath = shell.getAttribute("data-commentray-pair-commentray-path") ?? "";
|
|
2400
|
-
const stem = companionStemFromCommentrayPath(commentrayPath);
|
|
2401
|
-
const angleName = selectedAngle.length > 0 ? selectedAngle : stem;
|
|
2402
|
-
if (angleName.length === 0)
|
|
2403
|
-
return null;
|
|
2404
|
-
const angleAlias = `${alias}@${encodeURIComponent(angleName)}.html`;
|
|
2405
|
-
const siteRoot = siteRootPathnameFromPathname(pathname);
|
|
2406
|
-
return siteRoot === "/" ? `/browse/${angleAlias}` : `${siteRoot}/browse/${angleAlias}`;
|
|
2407
|
-
}
|
|
2408
3617
|
function absolutizeNavJsonUrls(shell, beforeHref) {
|
|
2409
3618
|
const navSearchRaw = shell.getAttribute("data-nav-search-json-url")?.trim() ?? "";
|
|
2410
3619
|
if (navSearchRaw.length > 0) {
|
|
@@ -2424,14 +3633,6 @@ function normalizePairBrowseHrefForCurrentPath(shell, pathname) {
|
|
|
2424
3633
|
shell.setAttribute("data-commentray-pair-browse-href", resolveStaticBrowseHref(pairBrowseRaw, pathname, globalThis.location.origin));
|
|
2425
3634
|
}
|
|
2426
3635
|
}
|
|
2427
|
-
function normalizeDocumentationHomeHrefForCurrentPath() {
|
|
2428
|
-
const home = document.querySelector('a[aria-label="Documentation home"]');
|
|
2429
|
-
if (!(home instanceof HTMLAnchorElement))
|
|
2430
|
-
return;
|
|
2431
|
-
const siteRoot = siteRootPathnameFromPathname(globalThis.location.pathname);
|
|
2432
|
-
const normalized = siteRoot === "/" ? "/" : `${siteRoot}/`;
|
|
2433
|
-
home.setAttribute("href", normalized);
|
|
2434
|
-
}
|
|
2435
3636
|
function activeCommentrayHashTokenFromViewport() {
|
|
2436
3637
|
const docPane = document.getElementById("doc-pane");
|
|
2437
3638
|
if (!(docPane instanceof HTMLElement))
|
|
@@ -2442,42 +3643,33 @@ function activeCommentrayHashTokenFromViewport() {
|
|
|
2442
3643
|
if (anchors.length === 0)
|
|
2443
3644
|
return null;
|
|
2444
3645
|
const mdLine0 = probeCommentrayLine0FromDoc(docScrollEl);
|
|
2445
|
-
if (!Number.isFinite(mdLine0) || mdLine0 < 0)
|
|
3646
|
+
if (mdLine0 === null || !Number.isFinite(mdLine0) || mdLine0 < 0)
|
|
2446
3647
|
return null;
|
|
2447
3648
|
return `commentray-md-line-${String(mdLine0)}`;
|
|
2448
3649
|
}
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
3650
|
+
/**
|
|
3651
|
+
* Resolves hub-relative `data-nav-*` and pair-browse attributes against the current document URL so
|
|
3652
|
+
* `fetch()` and toolbar targets work from deep `/browse/…/index.html` pages (static HTML stays portable).
|
|
3653
|
+
*/
|
|
3654
|
+
function resolveEmbeddedStaticNavUrlsForCurrentPage(shell) {
|
|
3655
|
+
if ((shell.getAttribute("data-layout") ?? "dual") !== "dual")
|
|
2452
3656
|
return;
|
|
2453
3657
|
const pathname = globalThis.location.pathname;
|
|
2454
|
-
normalizeDocumentationHomeHrefForCurrentPath();
|
|
2455
|
-
if (!shellEligibleForHumaneBackfill(shell, pathname))
|
|
2456
|
-
return;
|
|
2457
3658
|
const beforeHref = globalThis.location.href;
|
|
2458
3659
|
absolutizeNavJsonUrls(shell, beforeHref);
|
|
2459
3660
|
normalizePairBrowseHrefForCurrentPath(shell, pathname);
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
}
|
|
2473
|
-
catch {
|
|
2474
|
-
return null;
|
|
2475
|
-
}
|
|
2476
|
-
})();
|
|
2477
|
-
const nextPath = canonicalBrowsePathname ?? nextHumaneBrowsePathForShell(shell, pathname);
|
|
2478
|
-
if (nextPath === null)
|
|
2479
|
-
return;
|
|
2480
|
-
globalThis.history.replaceState(null, "", `${nextPath}${globalThis.location.search}${globalThis.location.hash}`);
|
|
3661
|
+
}
|
|
3662
|
+
/**
|
|
3663
|
+
* Absolute base URL for the pair browse / permalink target from `#shell` `data-commentray-pair-browse-href`
|
|
3664
|
+
* (hub-relative `./browse/…` resolved via {@link resolveStaticBrowseHref}; same contract as static build).
|
|
3665
|
+
*/
|
|
3666
|
+
function pairBrowsePermalinkBaseHrefFromShell(shell) {
|
|
3667
|
+
const raw = shell.getAttribute("data-commentray-pair-browse-href") ?? "";
|
|
3668
|
+
const t = raw.trim();
|
|
3669
|
+
const canonical = isHubRelativeStaticBrowseHref(t) && t.length > 0
|
|
3670
|
+
? resolveStaticBrowseHref(t, globalThis.location.pathname, globalThis.location.origin)
|
|
3671
|
+
: safePermalinkHref(t);
|
|
3672
|
+
return canonical ?? globalThis.location.href;
|
|
2481
3673
|
}
|
|
2482
3674
|
function permalinkHashSuffixFromUi() {
|
|
2483
3675
|
const tokens = [];
|
|
@@ -2501,16 +3693,31 @@ function permalinkHashSuffixFromUi() {
|
|
|
2501
3693
|
return tokens.length > 0 ? `#${tokens.join("&")}` : "";
|
|
2502
3694
|
}
|
|
2503
3695
|
function sharePermalinkFromShell(shell) {
|
|
2504
|
-
const
|
|
2505
|
-
const canonical = isHubRelativeStaticBrowseHref(raw.trim()) && raw.trim().length > 0
|
|
2506
|
-
? resolveStaticBrowseHref(raw.trim(), globalThis.location.pathname, globalThis.location.origin)
|
|
2507
|
-
: safePermalinkHref(raw);
|
|
2508
|
-
const base = canonical ?? globalThis.location.href;
|
|
2509
|
-
const u = new URL(base, globalThis.location.href);
|
|
3696
|
+
const u = new URL(pairBrowsePermalinkBaseHrefFromShell(shell), globalThis.location.href);
|
|
2510
3697
|
const hash = permalinkHashSuffixFromUi();
|
|
2511
3698
|
u.hash = hash.length > 0 ? hash.slice(1) : "";
|
|
2512
3699
|
return u.toString();
|
|
2513
3700
|
}
|
|
3701
|
+
/**
|
|
3702
|
+
* Static multi-angle: each angle has its own `/browse/…/<angle>/index.html`. After `#angle-select`
|
|
3703
|
+
* changes, load that canonical page without a `#angle-…` fragment (path is the permalink).
|
|
3704
|
+
*/
|
|
3705
|
+
function assignLocationToCanonicalBrowsePermalinkIfNeeded(shell) {
|
|
3706
|
+
let target;
|
|
3707
|
+
try {
|
|
3708
|
+
target = new URL(pairBrowsePermalinkBaseHrefFromShell(shell), globalThis.location.href);
|
|
3709
|
+
}
|
|
3710
|
+
catch {
|
|
3711
|
+
return;
|
|
3712
|
+
}
|
|
3713
|
+
if (target.origin !== globalThis.location.origin)
|
|
3714
|
+
return;
|
|
3715
|
+
target.hash = "";
|
|
3716
|
+
const here = new URL(globalThis.location.href);
|
|
3717
|
+
if (here.pathname === target.pathname && here.search === target.search)
|
|
3718
|
+
return;
|
|
3719
|
+
globalThis.location.assign(target.toString());
|
|
3720
|
+
}
|
|
2514
3721
|
async function writeTextToClipboard(text) {
|
|
2515
3722
|
try {
|
|
2516
3723
|
await navigator.clipboard.writeText(text);
|
|
@@ -2563,6 +3770,7 @@ function wireSharePermalinkButton() {
|
|
|
2563
3770
|
});
|
|
2564
3771
|
}
|
|
2565
3772
|
function main() {
|
|
3773
|
+
announceScrollSyncTraceOnBoot();
|
|
2566
3774
|
wireSharePermalinkButton();
|
|
2567
3775
|
wireColorThemeToolbar();
|
|
2568
3776
|
wireDocumentedFilesTree();
|
|
@@ -2576,11 +3784,22 @@ function main() {
|
|
|
2576
3784
|
wireWideModeIntroTrigger(shell);
|
|
2577
3785
|
const layout = shell.getAttribute("data-layout") || "dual";
|
|
2578
3786
|
if (layout === "stretch") {
|
|
2579
|
-
wireStretchLayoutChrome(codePane);
|
|
3787
|
+
wireStretchLayoutChrome(shell, codePane);
|
|
3788
|
+
wireStretchSearchUi(shell, codePane);
|
|
3789
|
+
wireSourceMarkdownControls(shell, codePane, () => {
|
|
3790
|
+
globalThis.dispatchEvent(new Event("resize"));
|
|
3791
|
+
});
|
|
3792
|
+
const flipBtn = document.getElementById("mobile-pane-flip");
|
|
3793
|
+
const flipScrollBtn = document.getElementById("mobile-pane-flip-scroll");
|
|
3794
|
+
if (flipBtn instanceof HTMLButtonElement) {
|
|
3795
|
+
wireStretchMobilePaneFlip(shell, codePane, flipBtn, flipScrollBtn instanceof HTMLButtonElement ? flipScrollBtn : null, () => {
|
|
3796
|
+
globalThis.dispatchEvent(new Event("resize"));
|
|
3797
|
+
});
|
|
3798
|
+
}
|
|
2580
3799
|
return;
|
|
2581
3800
|
}
|
|
2582
3801
|
wireDualPaneCodeBrowser(shell, codePane);
|
|
2583
|
-
|
|
3802
|
+
resolveEmbeddedStaticNavUrlsForCurrentPage(shell);
|
|
2584
3803
|
}
|
|
2585
3804
|
if (document.readyState === "loading") {
|
|
2586
3805
|
document.addEventListener("DOMContentLoaded", main);
|