@commentray/render 0.2.0 → 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.js +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 +1366 -257
- 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 +350 -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;
|
|
@@ -796,13 +1267,13 @@ function parseScrollBlockLinksFromShell(b64) {
|
|
|
796
1267
|
return [];
|
|
797
1268
|
}
|
|
798
1269
|
}
|
|
799
|
-
function rootScrollNearDocumentEnd(edgePx =
|
|
1270
|
+
function rootScrollNearDocumentEnd(edgePx = READING_VIEWPORT_BOTTOM_EDGE_CSS_PX) {
|
|
800
1271
|
const root = rootScrollingElement();
|
|
801
1272
|
const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
|
|
802
1273
|
return maxY > 0 && root.scrollTop >= maxY - edgePx;
|
|
803
1274
|
}
|
|
804
1275
|
/** When the pane itself is the scrollport (dual desktop), mirror root “near end” behavior. */
|
|
805
|
-
function paneScrollNearEnd(pane, edgePx =
|
|
1276
|
+
function paneScrollNearEnd(pane, edgePx = READING_VIEWPORT_BOTTOM_EDGE_CSS_PX) {
|
|
806
1277
|
const maxY = Math.max(0, pane.scrollHeight - pane.clientHeight);
|
|
807
1278
|
return maxY > 0 && pane.scrollTop >= maxY - edgePx;
|
|
808
1279
|
}
|
|
@@ -823,10 +1294,15 @@ function bestCommentrayAnchorLine0AtOrAboveY(anchors, y) {
|
|
|
823
1294
|
return maxCommentrayAnchorLine0AtOrAboveViewportY(readings, y);
|
|
824
1295
|
}
|
|
825
1296
|
function lastCommentrayAnchorLine0(anchors) {
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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;
|
|
830
1306
|
}
|
|
831
1307
|
function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
|
|
832
1308
|
const rows = codePane.querySelectorAll(`[id^="${lineIdPrefix}"]`);
|
|
@@ -844,7 +1320,7 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
|
|
|
844
1320
|
const vh = globalThis.innerHeight;
|
|
845
1321
|
const clipT = Math.max(0, sr.top);
|
|
846
1322
|
const clipB = Math.min(vh, sr.bottom);
|
|
847
|
-
const y = clipT +
|
|
1323
|
+
const y = clipT + readingViewportTopInsetCssPx(clipB - clipT);
|
|
848
1324
|
for (const el of rows) {
|
|
849
1325
|
const r = el.getBoundingClientRect();
|
|
850
1326
|
if (r.bottom > y - 1e-3) {
|
|
@@ -864,7 +1340,7 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
|
|
|
864
1340
|
return rows.length;
|
|
865
1341
|
}
|
|
866
1342
|
const sr = codePane.getBoundingClientRect();
|
|
867
|
-
const y = sr.top + codePane.clientTop +
|
|
1343
|
+
const y = sr.top + codePane.clientTop + readingViewportTopInsetCssPx(codePane.clientHeight);
|
|
868
1344
|
for (const el of rows) {
|
|
869
1345
|
const r = el.getBoundingClientRect();
|
|
870
1346
|
if (r.bottom > y - 1e-3) {
|
|
@@ -879,22 +1355,28 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
|
|
|
879
1355
|
function probeCommentrayLine0FromDoc(docPane) {
|
|
880
1356
|
const anchors = docPane.querySelectorAll(".commentray-block-anchor");
|
|
881
1357
|
if (anchors.length === 0)
|
|
882
|
-
return
|
|
1358
|
+
return null;
|
|
883
1359
|
if (!paneUsesInternalYScroll(docPane)) {
|
|
884
|
-
if (rootScrollNearDocumentEnd())
|
|
885
|
-
|
|
1360
|
+
if (rootScrollNearDocumentEnd()) {
|
|
1361
|
+
const tail = lastCommentrayAnchorLine0(anchors);
|
|
1362
|
+
if (tail !== null)
|
|
1363
|
+
return tail;
|
|
1364
|
+
}
|
|
886
1365
|
const dr = docPane.getBoundingClientRect();
|
|
887
1366
|
const vh = globalThis.innerHeight;
|
|
888
1367
|
const clipT = Math.max(0, dr.top);
|
|
889
1368
|
const clipB = Math.min(vh, dr.bottom);
|
|
890
|
-
const y = clipT +
|
|
1369
|
+
const y = clipT + readingViewportTopInsetCssPx(clipB - clipT);
|
|
891
1370
|
return bestCommentrayAnchorLine0AtOrAboveY(anchors, y);
|
|
892
1371
|
}
|
|
893
|
-
if (paneScrollNearEnd(docPane))
|
|
894
|
-
|
|
1372
|
+
if (paneScrollNearEnd(docPane)) {
|
|
1373
|
+
const tail = lastCommentrayAnchorLine0(anchors);
|
|
1374
|
+
if (tail !== null)
|
|
1375
|
+
return tail;
|
|
1376
|
+
}
|
|
895
1377
|
const dr = docPane.getBoundingClientRect();
|
|
896
|
-
/** Same band as the root-scroll branch:
|
|
897
|
-
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);
|
|
898
1380
|
return bestCommentrayAnchorLine0AtOrAboveY(anchors, y);
|
|
899
1381
|
}
|
|
900
1382
|
function pageBreakPullEnabled() {
|
|
@@ -909,10 +1391,10 @@ function docProbeTopY(docPane) {
|
|
|
909
1391
|
const vh = globalThis.innerHeight;
|
|
910
1392
|
const clipT = Math.max(0, dr.top);
|
|
911
1393
|
const clipB = Math.min(vh, dr.bottom);
|
|
912
|
-
return clipT +
|
|
1394
|
+
return clipT + readingViewportTopInsetCssPx(clipB - clipT);
|
|
913
1395
|
}
|
|
914
1396
|
const dr = docPane.getBoundingClientRect();
|
|
915
|
-
return dr.top + docPane.clientTop +
|
|
1397
|
+
return dr.top + docPane.clientTop + readingViewportTopInsetCssPx(docPane.clientHeight);
|
|
916
1398
|
}
|
|
917
1399
|
/**
|
|
918
1400
|
* In long synthetic page-break gaps, shift source toward the next block once
|
|
@@ -922,13 +1404,13 @@ function pulledSourceLine0FromPageBreak(docPane) {
|
|
|
922
1404
|
if (!pageBreakPullEnabled())
|
|
923
1405
|
return null;
|
|
924
1406
|
const topY = docProbeTopY(docPane);
|
|
925
|
-
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]"));
|
|
926
1408
|
for (const pageBreak of breaks) {
|
|
927
|
-
const
|
|
928
|
-
if (!
|
|
1409
|
+
const nextViewportLineRaw = pageBreak.getAttribute("data-next-source-viewport-line");
|
|
1410
|
+
if (!nextViewportLineRaw)
|
|
929
1411
|
continue;
|
|
930
|
-
const
|
|
931
|
-
if (!Number.isFinite(
|
|
1412
|
+
const nextViewportLine1Based = Number.parseInt(nextViewportLineRaw, 10);
|
|
1413
|
+
if (!Number.isFinite(nextViewportLine1Based) || nextViewportLine1Based <= 0)
|
|
932
1414
|
continue;
|
|
933
1415
|
const breakTop = pageBreak.getBoundingClientRect().top;
|
|
934
1416
|
const nextLineRaw = pageBreak.getAttribute("data-next-commentray-line");
|
|
@@ -947,91 +1429,251 @@ function pulledSourceLine0FromPageBreak(docPane) {
|
|
|
947
1429
|
const pullThreshold = narrow ? 0.2 : 0.35;
|
|
948
1430
|
if (progress < pullThreshold)
|
|
949
1431
|
return null;
|
|
950
|
-
return
|
|
1432
|
+
return nextViewportLine1Based - 1;
|
|
951
1433
|
}
|
|
952
1434
|
return null;
|
|
953
1435
|
}
|
|
954
1436
|
/**
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
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.
|
|
960
1442
|
*/
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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,
|
|
969
1470
|
});
|
|
970
1471
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
if (
|
|
977
|
-
|
|
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;
|
|
978
1480
|
return;
|
|
979
1481
|
}
|
|
980
|
-
if (
|
|
1482
|
+
if (syncingRef.current !== "none") {
|
|
1483
|
+
scrollSyncTrace(`wire.${axis}.flush-skipped`, {
|
|
1484
|
+
reason: "sync-in-progress",
|
|
1485
|
+
syncing: syncingRef.current,
|
|
1486
|
+
});
|
|
981
1487
|
return;
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
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
|
+
}
|
|
990
1509
|
return;
|
|
991
1510
|
}
|
|
992
|
-
if (
|
|
1511
|
+
if (syncingRef.current !== "none") {
|
|
1512
|
+
state.lastSeenTop = driverPane.scrollTop;
|
|
993
1513
|
return;
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1514
|
+
}
|
|
1515
|
+
if (state.pendingRaf !== 0) {
|
|
1516
|
+
cancelAnimationFrame(state.pendingRaf);
|
|
1517
|
+
}
|
|
1518
|
+
state.pendingRaf = requestAnimationFrame(flush);
|
|
998
1519
|
}, { passive: true });
|
|
999
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
|
+
}
|
|
1000
1620
|
/** Index-backed scroll sync when `data-scroll-block-links-b64` is present; else see proportional fallback. */
|
|
1001
|
-
function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, shouldUseProportionalDocToCodeOnMobileFlip) {
|
|
1621
|
+
function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, shouldUseProportionalDocToCodeOnMobileFlip, options) {
|
|
1622
|
+
const allowProportionalMirror = options?.allowProportionalMirror !== false;
|
|
1002
1623
|
let pendingDocToCode = null;
|
|
1003
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);
|
|
1004
1640
|
const syncFromCodeToDoc = () => {
|
|
1005
|
-
|
|
1641
|
+
blockAwareSyncFromCodeToDoc(bundle, 0);
|
|
1006
1642
|
};
|
|
1007
1643
|
const syncFromDocToCode = () => {
|
|
1008
|
-
|
|
1644
|
+
blockAwareSyncFromDocToCode(bundle, 0);
|
|
1009
1645
|
};
|
|
1010
1646
|
const prepareMobileFlipToCode = () => {
|
|
1011
|
-
if (shouldUseProportionalDocToCodeOnMobileFlip?.() === true) {
|
|
1647
|
+
if (allowProportionalMirror && shouldUseProportionalDocToCodeOnMobileFlip?.() === true) {
|
|
1012
1648
|
pendingDocToCode = { k: "mirrorW", ratio: windowScrollRatio() };
|
|
1013
1649
|
return;
|
|
1014
1650
|
}
|
|
1015
|
-
pendingDocToCode = buildDocToCodeFlipPlanBlockAware(docPane, getLinks);
|
|
1651
|
+
pendingDocToCode = buildDocToCodeFlipPlanBlockAware(docPane, codePane, getLinks, sticky, lineIdPrefix(), allowProportionalMirror);
|
|
1016
1652
|
};
|
|
1017
1653
|
const finishMobileFlipToCode = () => {
|
|
1018
1654
|
if (!pendingDocToCode)
|
|
1019
1655
|
return;
|
|
1020
|
-
|
|
1656
|
+
let p = pendingDocToCode;
|
|
1021
1657
|
pendingDocToCode = null;
|
|
1658
|
+
if (p.k === "noop" && p.skipProportionalFallbackOnFlip !== true && allowProportionalMirror) {
|
|
1659
|
+
p = buildDocToCodeFlipPlanProportional(docPane);
|
|
1660
|
+
}
|
|
1022
1661
|
applyDocToCodeFlipPlanImpl(codePane, docPane, p, lineIdPrefix());
|
|
1023
1662
|
};
|
|
1024
1663
|
const prepareMobileFlipToDoc = () => {
|
|
1025
|
-
pendingCodeToDoc = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix());
|
|
1664
|
+
pendingCodeToDoc = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix(), sticky, allowProportionalMirror);
|
|
1026
1665
|
};
|
|
1027
1666
|
const finishMobileFlipToDoc = () => {
|
|
1028
1667
|
if (!pendingCodeToDoc)
|
|
1029
1668
|
return;
|
|
1030
|
-
|
|
1669
|
+
let p = pendingCodeToDoc;
|
|
1031
1670
|
pendingCodeToDoc = null;
|
|
1671
|
+
if (p.k === "noop" && allowProportionalMirror) {
|
|
1672
|
+
p = buildCodeToDocFlipPlanProportional(codePane);
|
|
1673
|
+
}
|
|
1032
1674
|
applyCodeToDocFlipPlanImpl(codePane, docPane, p);
|
|
1033
1675
|
};
|
|
1034
|
-
wireBidirectionalScroll(codePane, docPane,
|
|
1676
|
+
wireBidirectionalScroll(codePane, docPane, syncFromCodeToDocInner, syncFromDocToCodeInner);
|
|
1035
1677
|
return {
|
|
1036
1678
|
syncFromCodeToDoc,
|
|
1037
1679
|
syncFromDocToCode,
|
|
@@ -1045,11 +1687,49 @@ function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, sho
|
|
|
1045
1687
|
function wireProportionalScrollSync(codePane, docPane) {
|
|
1046
1688
|
let pendingDocToCode = null;
|
|
1047
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
|
+
};
|
|
1048
1728
|
const syncFromCodeToDoc = () => {
|
|
1049
|
-
|
|
1729
|
+
syncFromCodeToDocInner(0);
|
|
1050
1730
|
};
|
|
1051
1731
|
const syncFromDocToCode = () => {
|
|
1052
|
-
|
|
1732
|
+
syncFromDocToCodeInner(0);
|
|
1053
1733
|
};
|
|
1054
1734
|
const prepareMobileFlipToCode = () => {
|
|
1055
1735
|
pendingDocToCode = buildDocToCodeFlipPlanProportional(docPane);
|
|
@@ -1071,7 +1751,7 @@ function wireProportionalScrollSync(codePane, docPane) {
|
|
|
1071
1751
|
pendingCodeToDoc = null;
|
|
1072
1752
|
applyCodeToDocFlipPlanImpl(codePane, docPane, p);
|
|
1073
1753
|
};
|
|
1074
|
-
wireBidirectionalScroll(codePane, docPane,
|
|
1754
|
+
wireBidirectionalScroll(codePane, docPane, syncFromCodeToDocInner, syncFromDocToCodeInner);
|
|
1075
1755
|
return {
|
|
1076
1756
|
syncFromCodeToDoc,
|
|
1077
1757
|
syncFromDocToCode,
|
|
@@ -1102,9 +1782,7 @@ function commentaryBandEndYViewport(docScrollEl, next, docTop, clipThroughPageBr
|
|
|
1102
1782
|
const nextTop = nextEl.getBoundingClientRect().top - 3;
|
|
1103
1783
|
if (!clipThroughPageBreakGaps)
|
|
1104
1784
|
return nextTop;
|
|
1105
|
-
|
|
1106
|
-
const contentBottom = maxRenderableCommentaryContentBottomViewport(docScrollEl, docTop, nextEl);
|
|
1107
|
-
return Math.min(nextTop, Math.max(docBandTop, contentBottom));
|
|
1785
|
+
return commentaryGutterDocBandBottomViewport(docScrollEl, docTop, nextEl);
|
|
1108
1786
|
}
|
|
1109
1787
|
const dr = docScrollEl.getBoundingClientRect();
|
|
1110
1788
|
let bottom = dr.bottom - 4;
|
|
@@ -1177,6 +1855,15 @@ function subscribeBlockRayRedraw(gutter, codePane, docScrollEl, scheduleDraw) {
|
|
|
1177
1855
|
if (shell)
|
|
1178
1856
|
ro.observe(shell);
|
|
1179
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
|
+
}
|
|
1180
1867
|
function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSourceLine1Based, lineIdPrefix) {
|
|
1181
1868
|
const links = dedupeBlockScrollLinksById(getLinks());
|
|
1182
1869
|
const sorted = sortBlockLinksBySource(links);
|
|
@@ -1187,10 +1874,7 @@ function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSource
|
|
|
1187
1874
|
svg.replaceChildren();
|
|
1188
1875
|
return;
|
|
1189
1876
|
}
|
|
1190
|
-
|
|
1191
|
-
const activeId = docScrollEl.querySelector(".commentray-block-anchor") !== null
|
|
1192
|
-
? activeBlockIdForCommentrayLine0(links, probeCommentrayLine0FromDoc(docScrollEl))
|
|
1193
|
-
: activeBlockIdForViewport(links, probeTopSourceLine1Based());
|
|
1877
|
+
const activeId = activeBlockIdForGutterRays(links, docScrollEl, probeTopSourceLine1Based);
|
|
1194
1878
|
const clipGutterRaysThroughPageBreakGaps = pageBreakPullEnabled();
|
|
1195
1879
|
svg.setAttribute("viewBox", `0 0 ${String(w)} ${String(h)}`);
|
|
1196
1880
|
svg.setAttribute("preserveAspectRatio", "none");
|
|
@@ -1730,6 +2414,13 @@ function normalizedDualMobilePane(v) {
|
|
|
1730
2414
|
function isNarrowViewport() {
|
|
1731
2415
|
return globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ).matches;
|
|
1732
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
|
+
}
|
|
1733
2424
|
function wireWideModeIntroTrigger(shell) {
|
|
1734
2425
|
const btn = document.getElementById("commentray-help-tour");
|
|
1735
2426
|
if (!(btn instanceof HTMLButtonElement))
|
|
@@ -1787,7 +2478,7 @@ function scheduleMermaidWhenDualDocPaneVisible(shell, mq) {
|
|
|
1787
2478
|
const docBody = document.getElementById("doc-pane-body");
|
|
1788
2479
|
if (!(docBody instanceof HTMLElement))
|
|
1789
2480
|
return;
|
|
1790
|
-
runMermaidOnFreshDocNodes(docBody);
|
|
2481
|
+
void runMermaidOnFreshDocNodes(docBody);
|
|
1791
2482
|
};
|
|
1792
2483
|
queueMicrotask(() => {
|
|
1793
2484
|
kick();
|
|
@@ -1826,7 +2517,7 @@ function wireDualMobilePaneFlipScrollAffordance(primaryFlip, scrollFlip, mq) {
|
|
|
1826
2517
|
mq.addEventListener("change", tick);
|
|
1827
2518
|
globalThis.requestAnimationFrame(tick);
|
|
1828
2519
|
}
|
|
1829
|
-
function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
|
|
2520
|
+
function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip, signal) {
|
|
1830
2521
|
const hideScroll = () => {
|
|
1831
2522
|
scrollFlip.hidden = true;
|
|
1832
2523
|
scrollFlip.classList.remove("is-visible");
|
|
@@ -1836,6 +2527,10 @@ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
|
|
|
1836
2527
|
scrollFlip.classList.add("is-visible");
|
|
1837
2528
|
};
|
|
1838
2529
|
const tick = () => {
|
|
2530
|
+
if (primaryFlip.hidden || primaryFlip.getClientRects().length === 0) {
|
|
2531
|
+
hideScroll();
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
1839
2534
|
const r = primaryFlip.getBoundingClientRect();
|
|
1840
2535
|
const vh = globalThis.innerHeight;
|
|
1841
2536
|
const margin = 10;
|
|
@@ -1845,10 +2540,27 @@ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
|
|
|
1845
2540
|
else
|
|
1846
2541
|
hideScroll();
|
|
1847
2542
|
};
|
|
1848
|
-
globalThis.addEventListener("scroll", tick, { passive: true });
|
|
1849
|
-
globalThis.addEventListener("resize", tick, { passive: true });
|
|
2543
|
+
globalThis.addEventListener("scroll", tick, { passive: true, signal });
|
|
2544
|
+
globalThis.addEventListener("resize", tick, { passive: true, signal });
|
|
1850
2545
|
globalThis.requestAnimationFrame(tick);
|
|
1851
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
|
+
}
|
|
1852
2564
|
function closestSourceLine0ForPaneTop(codePane, idPrefix) {
|
|
1853
2565
|
const rows = codePane.querySelectorAll(`[id^="${idPrefix}"]`);
|
|
1854
2566
|
if (rows.length === 0)
|
|
@@ -1873,13 +2585,17 @@ function closestSourceLine0ForPaneTop(codePane, idPrefix) {
|
|
|
1873
2585
|
return null;
|
|
1874
2586
|
return Number.parseInt(m[1], 10);
|
|
1875
2587
|
}
|
|
1876
|
-
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
|
+
}
|
|
1877
2592
|
function syncSourceMarkdownFlipA11y() {
|
|
1878
2593
|
const mode = sourcePaneModeForShell(shell);
|
|
1879
2594
|
const renderedActive = mode === "rendered-markdown";
|
|
1880
2595
|
const nextModeLabel = renderedActive ? "markdown source" : "rendered markdown";
|
|
2596
|
+
const currentModeLabel = renderedActive ? "rendered markdown" : "markdown source";
|
|
1881
2597
|
const ariaLabel = `Switch source pane to ${nextModeLabel}`;
|
|
1882
|
-
const title = `
|
|
2598
|
+
const title = `Switch source pane to ${nextModeLabel} (currently ${currentModeLabel})`;
|
|
1883
2599
|
const apply = (btn) => {
|
|
1884
2600
|
if (!(btn instanceof HTMLButtonElement))
|
|
1885
2601
|
return;
|
|
@@ -1890,10 +2606,21 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onA
|
|
|
1890
2606
|
apply(flipBtn);
|
|
1891
2607
|
apply(flipScrollBtn);
|
|
1892
2608
|
}
|
|
1893
|
-
// Keep initial behavior deterministic: source pane starts in rendered markdown mode.
|
|
1894
|
-
shell.setAttribute("data-source-pane-mode", "rendered-markdown");
|
|
1895
2609
|
syncSourceMarkdownFlipA11y();
|
|
1896
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 });
|
|
1897
2624
|
const runFlip = () => {
|
|
1898
2625
|
const cur = sourcePaneModeForShell(shell);
|
|
1899
2626
|
const currentPrefix = cur === "rendered-markdown" ? "code-md-line-" : "code-line-";
|
|
@@ -1902,27 +2629,34 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onA
|
|
|
1902
2629
|
const nextPrefix = next === "rendered-markdown" ? "code-md-line-" : "code-line-";
|
|
1903
2630
|
shell.setAttribute("data-source-pane-mode", next);
|
|
1904
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
|
+
}
|
|
1905
2639
|
syncSourceMarkdownFlipA11y();
|
|
1906
2640
|
syncWrapLinesVisibilityForSourcePaneMode(shell);
|
|
1907
|
-
|
|
2641
|
+
const shouldWriteRevealScroll = !isNarrowViewport() || paneUsesInternalYScroll(codePane);
|
|
2642
|
+
if (line0 !== null && shouldWriteRevealScroll) {
|
|
1908
2643
|
const row = codePane.querySelector(`#${nextPrefix}${String(line0)}`);
|
|
1909
2644
|
if (row instanceof HTMLElement) {
|
|
1910
|
-
applyRevealChildInPane(codePane, row,
|
|
2645
|
+
applyRevealChildInPane(codePane, row, 0);
|
|
1911
2646
|
}
|
|
1912
2647
|
}
|
|
1913
2648
|
if (next === "rendered-markdown") {
|
|
1914
|
-
const sourceMdBody
|
|
1915
|
-
|
|
1916
|
-
runMermaidOnFreshDocNodes(sourceMdBody);
|
|
2649
|
+
for (const sourceMdBody of sourceMarkdownBodies()) {
|
|
2650
|
+
void runMermaidOnFreshDocNodes(sourceMdBody);
|
|
1917
2651
|
rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
|
|
1918
2652
|
}
|
|
1919
2653
|
}
|
|
1920
2654
|
onAfterFlip?.();
|
|
1921
2655
|
};
|
|
1922
|
-
flipBtn.addEventListener("click", runFlip);
|
|
2656
|
+
flipBtn.addEventListener("click", runFlip, { signal });
|
|
1923
2657
|
if (flipScrollBtn) {
|
|
1924
|
-
flipScrollBtn.addEventListener("click", runFlip);
|
|
1925
|
-
wireSourceMarkdownPaneFlipAffordance(flipBtn, flipScrollBtn);
|
|
2658
|
+
flipScrollBtn.addEventListener("click", runFlip, { signal });
|
|
2659
|
+
wireSourceMarkdownPaneFlipAffordance(flipBtn, flipScrollBtn, signal);
|
|
1926
2660
|
}
|
|
1927
2661
|
}
|
|
1928
2662
|
function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
|
|
@@ -1932,9 +2666,11 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
|
|
|
1932
2666
|
}
|
|
1933
2667
|
function applyForViewport() {
|
|
1934
2668
|
if (mq.matches) {
|
|
2669
|
+
syncSinglePaneShellState(shell, true);
|
|
1935
2670
|
shell.setAttribute("data-dual-mobile-pane", readStoredPane());
|
|
1936
2671
|
}
|
|
1937
2672
|
else {
|
|
2673
|
+
syncSinglePaneShellState(shell, false);
|
|
1938
2674
|
shell.removeAttribute("data-dual-mobile-pane");
|
|
1939
2675
|
}
|
|
1940
2676
|
}
|
|
@@ -1980,13 +2716,282 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
|
|
|
1980
2716
|
mq.addEventListener("change", applyForViewport);
|
|
1981
2717
|
applyForViewport();
|
|
1982
2718
|
}
|
|
1983
|
-
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
|
+
}
|
|
1984
2972
|
const wrapCb = document.getElementById("wrap-lines");
|
|
1985
2973
|
if (wrapCb) {
|
|
1986
2974
|
wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb, () => {
|
|
1987
2975
|
globalThis.dispatchEvent(new Event("resize"));
|
|
1988
2976
|
});
|
|
1989
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;
|
|
1990
2995
|
}
|
|
1991
2996
|
function parseMultiAnglePayload(script) {
|
|
1992
2997
|
const t = script?.textContent?.trim() ?? "";
|
|
@@ -1996,29 +3001,133 @@ function parseMultiAnglePayload(script) {
|
|
|
1996
3001
|
const raw = JSON.parse(decodeBase64Utf8(t));
|
|
1997
3002
|
if (!raw || !Array.isArray(raw.angles) || raw.angles.length < 2)
|
|
1998
3003
|
return null;
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
}
|
|
2003
|
-
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 };
|
|
2004
3008
|
}
|
|
2005
3009
|
catch {
|
|
2006
3010
|
return null;
|
|
2007
3011
|
}
|
|
2008
3012
|
}
|
|
2009
|
-
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() {
|
|
2010
3113
|
const docPane = document.getElementById("doc-pane");
|
|
2011
3114
|
const gutter = document.getElementById("gutter");
|
|
2012
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() {
|
|
2013
3124
|
const searchInput = document.getElementById("search-q");
|
|
2014
3125
|
const searchClear = document.getElementById("search-clear");
|
|
2015
3126
|
const searchResults = document.getElementById("search-results");
|
|
2016
|
-
if (!
|
|
3127
|
+
if (!searchInput || !searchClear || !searchResults) {
|
|
2017
3128
|
return null;
|
|
2018
3129
|
}
|
|
2019
|
-
|
|
2020
|
-
const docScrollEl = docBody instanceof HTMLElement ? docBody : docPane;
|
|
2021
|
-
return { docBody, docScrollEl, gutter, wrapCb, searchInput, searchClear, searchResults };
|
|
3130
|
+
return { searchInput, searchClear, searchResults };
|
|
2022
3131
|
}
|
|
2023
3132
|
function hubSearcherRowsForDualPane(args) {
|
|
2024
3133
|
const { scope, rawCode, filePathLabel, hubNavRows, pathRowsForOrdering, rawMd, commentrayPathLabel, } = args;
|
|
@@ -2124,7 +3233,7 @@ function wireDualPaneNavSearchFetch(shell, embeddedPairs, indexState, mutable, r
|
|
|
2124
3233
|
function applySelectedMultiAngle(args) {
|
|
2125
3234
|
const { angle, docBody, mutable, rebuildSearcher, scrollLinksRef, shell, searchInput, searchResults, requestBlockRayRedraw, } = args;
|
|
2126
3235
|
docBody.innerHTML = decodeBase64Utf8(angle.docInnerHtmlB64);
|
|
2127
|
-
runMermaidOnFreshDocNodes(docBody);
|
|
3236
|
+
void runMermaidOnFreshDocNodes(docBody);
|
|
2128
3237
|
rewriteHubRelativeBrowseAnchorsIn(docBody);
|
|
2129
3238
|
mutable.rawMd = decodeBase64Utf8(angle.rawMdB64);
|
|
2130
3239
|
mutable.mdLines = mutable.rawMd.split("\n");
|
|
@@ -2160,9 +3269,12 @@ function applySelectedMultiAngle(args) {
|
|
|
2160
3269
|
else
|
|
2161
3270
|
shell.removeAttribute("data-commentray-pair-browse-href");
|
|
2162
3271
|
}
|
|
2163
|
-
searchInput
|
|
2164
|
-
|
|
2165
|
-
|
|
3272
|
+
if (searchInput && searchResults) {
|
|
3273
|
+
searchInput.value = "";
|
|
3274
|
+
searchResults.innerHTML = "";
|
|
3275
|
+
searchResults.hidden = true;
|
|
3276
|
+
}
|
|
3277
|
+
assignLocationToCanonicalBrowsePermalinkIfNeeded(shell);
|
|
2166
3278
|
requestBlockRayRedraw?.();
|
|
2167
3279
|
globalThis.requestAnimationFrame(() => {
|
|
2168
3280
|
requestBlockRayRedraw?.();
|
|
@@ -2171,10 +3283,14 @@ function applySelectedMultiAngle(args) {
|
|
|
2171
3283
|
});
|
|
2172
3284
|
});
|
|
2173
3285
|
}
|
|
2174
|
-
|
|
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) {
|
|
2175
3291
|
const { codePane, docScrollEl, docBody, shell, scrollLinksRef, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, requestBlockRayRedraw, } = args;
|
|
2176
3292
|
if (multiPayload) {
|
|
2177
|
-
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);
|
|
2178
3294
|
const angleSel = document.getElementById("angle-select");
|
|
2179
3295
|
if (angleSel && docBody) {
|
|
2180
3296
|
angleSel.addEventListener("change", () => {
|
|
@@ -2197,9 +3313,19 @@ function wireDualPaneMultiAngleAndScroll(args) {
|
|
|
2197
3313
|
return runners;
|
|
2198
3314
|
}
|
|
2199
3315
|
if (scrollLinksRef.current.length > 0) {
|
|
2200
|
-
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);
|
|
2201
3317
|
}
|
|
2202
|
-
|
|
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);
|
|
2203
3329
|
}
|
|
2204
3330
|
function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
|
|
2205
3331
|
function commentrayMdLineFromLocationHash(rawHash) {
|
|
@@ -2234,18 +3360,23 @@ function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
|
|
|
2234
3360
|
function initializeSourceMarkdownPane(shell) {
|
|
2235
3361
|
if (sourcePaneModeForShell(shell) !== "rendered-markdown")
|
|
2236
3362
|
return;
|
|
2237
|
-
const
|
|
2238
|
-
if (!(
|
|
3363
|
+
const codePane = document.getElementById("code-pane");
|
|
3364
|
+
if (!(codePane instanceof HTMLElement))
|
|
2239
3365
|
return;
|
|
2240
|
-
|
|
2241
|
-
|
|
3366
|
+
for (const sourceMdBody of codePane.querySelectorAll('[data-source-markdown-body="true"]')) {
|
|
3367
|
+
void runMermaidOnFreshDocNodes(sourceMdBody);
|
|
3368
|
+
rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
|
|
3369
|
+
}
|
|
2242
3370
|
}
|
|
3371
|
+
let sourceMarkdownControlsAbort = null;
|
|
2243
3372
|
function wireSourceMarkdownControls(shell, codePane, onAfterFlip) {
|
|
2244
3373
|
const sourceMdFlip = document.getElementById("source-markdown-pane-flip");
|
|
2245
3374
|
const sourceMdFlipScroll = document.getElementById("source-markdown-pane-flip-scroll");
|
|
2246
3375
|
if (!(sourceMdFlip instanceof HTMLButtonElement))
|
|
2247
3376
|
return;
|
|
2248
|
-
|
|
3377
|
+
sourceMarkdownControlsAbort?.abort();
|
|
3378
|
+
sourceMarkdownControlsAbort = new AbortController();
|
|
3379
|
+
wireSourceMarkdownPaneFlip(shell, codePane, sourceMdFlip, sourceMdFlipScroll instanceof HTMLButtonElement ? sourceMdFlipScroll : null, sourceMarkdownControlsAbort.signal, onAfterFlip);
|
|
2249
3380
|
initializeSourceMarkdownPane(shell);
|
|
2250
3381
|
}
|
|
2251
3382
|
function buildDualPaneSearcherBundle(shell, codePane) {
|
|
@@ -2296,23 +3427,28 @@ function buildDualPaneSearcherBundle(shell, codePane) {
|
|
|
2296
3427
|
};
|
|
2297
3428
|
}
|
|
2298
3429
|
function wireDualPaneCodeBrowser(shell, codePane) {
|
|
2299
|
-
const
|
|
2300
|
-
if (!
|
|
3430
|
+
const coreDom = readDualPaneCoreDom();
|
|
3431
|
+
if (!coreDom) {
|
|
2301
3432
|
return;
|
|
2302
|
-
|
|
3433
|
+
}
|
|
3434
|
+
const { docBody, docScrollEl, gutter, wrapCb } = coreDom;
|
|
3435
|
+
const searchDom = readDualPaneSearchDom();
|
|
2303
3436
|
const bundle = buildDualPaneSearcherBundle(shell, codePane);
|
|
2304
3437
|
rewriteHubRelativeBrowseAnchorsIn(document);
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
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
|
+
}
|
|
2316
3452
|
const pct0 = parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) || "46");
|
|
2317
3453
|
const pct = clamp(Number.isFinite(pct0) ? pct0 : 46, 15, 85);
|
|
2318
3454
|
codePane.style.flex = `0 0 ${pct}%`;
|
|
@@ -2348,8 +3484,8 @@ function wireDualPaneCodeBrowser(shell, codePane) {
|
|
|
2348
3484
|
multiPayload,
|
|
2349
3485
|
mutable: bundle.mutable,
|
|
2350
3486
|
rebuildSearcher: bundle.rebuildSearcher,
|
|
2351
|
-
searchInput,
|
|
2352
|
-
searchResults,
|
|
3487
|
+
searchInput: searchDom?.searchInput,
|
|
3488
|
+
searchResults: searchDom?.searchResults,
|
|
2353
3489
|
requestBlockRayRedraw,
|
|
2354
3490
|
});
|
|
2355
3491
|
const flipBtn = document.getElementById("mobile-pane-flip");
|
|
@@ -2475,46 +3611,9 @@ function safePermalinkHref(raw) {
|
|
|
2475
3611
|
return null;
|
|
2476
3612
|
}
|
|
2477
3613
|
}
|
|
2478
|
-
function humaneBrowseAliasPathForSource(sourcePath) {
|
|
2479
|
-
return sourcePath
|
|
2480
|
-
.split("/")
|
|
2481
|
-
.filter((seg) => seg.length > 0)
|
|
2482
|
-
.map((seg) => seg.startsWith(".") ? `%2E${encodeURIComponent(seg.slice(1))}` : encodeURIComponent(seg))
|
|
2483
|
-
.join("/");
|
|
2484
|
-
}
|
|
2485
|
-
function companionStemFromCommentrayPath(commentrayPath) {
|
|
2486
|
-
const norm = normPosixPath(commentrayPath);
|
|
2487
|
-
const last = norm.split("/").filter(Boolean).at(-1) ?? "";
|
|
2488
|
-
return last.replace(/\.md$/i, "").trim();
|
|
2489
|
-
}
|
|
2490
3614
|
function makeAbsoluteUrlAgainst(raw, baseHref) {
|
|
2491
3615
|
return new URL(raw, baseHref).toString();
|
|
2492
3616
|
}
|
|
2493
|
-
function shellEligibleForHumaneBackfill(shell, pathname) {
|
|
2494
|
-
if ((shell.getAttribute("data-layout") ?? "dual") !== "dual")
|
|
2495
|
-
return false;
|
|
2496
|
-
if (pathname.includes("/browse/"))
|
|
2497
|
-
return false;
|
|
2498
|
-
return pathname.endsWith("/") || pathname.endsWith("/index.html");
|
|
2499
|
-
}
|
|
2500
|
-
function nextHumaneBrowsePathForShell(shell, pathname) {
|
|
2501
|
-
const sourcePath = normPosixPath(shell.getAttribute("data-commentray-pair-source-path") ?? "");
|
|
2502
|
-
if (sourcePath.length === 0)
|
|
2503
|
-
return null;
|
|
2504
|
-
const alias = humaneBrowseAliasPathForSource(sourcePath);
|
|
2505
|
-
if (alias.length === 0)
|
|
2506
|
-
return null;
|
|
2507
|
-
const angleSel = document.getElementById("angle-select");
|
|
2508
|
-
const selectedAngle = angleSel instanceof HTMLSelectElement ? angleSel.value.trim() : "";
|
|
2509
|
-
const commentrayPath = shell.getAttribute("data-commentray-pair-commentray-path") ?? "";
|
|
2510
|
-
const stem = companionStemFromCommentrayPath(commentrayPath);
|
|
2511
|
-
const angleName = selectedAngle.length > 0 ? selectedAngle : stem;
|
|
2512
|
-
if (angleName.length === 0)
|
|
2513
|
-
return null;
|
|
2514
|
-
const angleAlias = `${alias}@${encodeURIComponent(angleName)}.html`;
|
|
2515
|
-
const siteRoot = siteRootPathnameFromPathname(pathname);
|
|
2516
|
-
return siteRoot === "/" ? `/browse/${angleAlias}` : `${siteRoot}/browse/${angleAlias}`;
|
|
2517
|
-
}
|
|
2518
3617
|
function absolutizeNavJsonUrls(shell, beforeHref) {
|
|
2519
3618
|
const navSearchRaw = shell.getAttribute("data-nav-search-json-url")?.trim() ?? "";
|
|
2520
3619
|
if (navSearchRaw.length > 0) {
|
|
@@ -2534,14 +3633,6 @@ function normalizePairBrowseHrefForCurrentPath(shell, pathname) {
|
|
|
2534
3633
|
shell.setAttribute("data-commentray-pair-browse-href", resolveStaticBrowseHref(pairBrowseRaw, pathname, globalThis.location.origin));
|
|
2535
3634
|
}
|
|
2536
3635
|
}
|
|
2537
|
-
function normalizeDocumentationHomeHrefForCurrentPath() {
|
|
2538
|
-
const home = document.querySelector('a[aria-label="Documentation home"]');
|
|
2539
|
-
if (!(home instanceof HTMLAnchorElement))
|
|
2540
|
-
return;
|
|
2541
|
-
const siteRoot = siteRootPathnameFromPathname(globalThis.location.pathname);
|
|
2542
|
-
const normalized = siteRoot === "/" ? "/" : `${siteRoot}/`;
|
|
2543
|
-
home.setAttribute("href", normalized);
|
|
2544
|
-
}
|
|
2545
3636
|
function activeCommentrayHashTokenFromViewport() {
|
|
2546
3637
|
const docPane = document.getElementById("doc-pane");
|
|
2547
3638
|
if (!(docPane instanceof HTMLElement))
|
|
@@ -2552,42 +3643,33 @@ function activeCommentrayHashTokenFromViewport() {
|
|
|
2552
3643
|
if (anchors.length === 0)
|
|
2553
3644
|
return null;
|
|
2554
3645
|
const mdLine0 = probeCommentrayLine0FromDoc(docScrollEl);
|
|
2555
|
-
if (!Number.isFinite(mdLine0) || mdLine0 < 0)
|
|
3646
|
+
if (mdLine0 === null || !Number.isFinite(mdLine0) || mdLine0 < 0)
|
|
2556
3647
|
return null;
|
|
2557
3648
|
return `commentray-md-line-${String(mdLine0)}`;
|
|
2558
3649
|
}
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
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")
|
|
2562
3656
|
return;
|
|
2563
3657
|
const pathname = globalThis.location.pathname;
|
|
2564
|
-
normalizeDocumentationHomeHrefForCurrentPath();
|
|
2565
|
-
if (!shellEligibleForHumaneBackfill(shell, pathname))
|
|
2566
|
-
return;
|
|
2567
3658
|
const beforeHref = globalThis.location.href;
|
|
2568
3659
|
absolutizeNavJsonUrls(shell, beforeHref);
|
|
2569
3660
|
normalizePairBrowseHrefForCurrentPath(shell, pathname);
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
}
|
|
2583
|
-
catch {
|
|
2584
|
-
return null;
|
|
2585
|
-
}
|
|
2586
|
-
})();
|
|
2587
|
-
const nextPath = canonicalBrowsePathname ?? nextHumaneBrowsePathForShell(shell, pathname);
|
|
2588
|
-
if (nextPath === null)
|
|
2589
|
-
return;
|
|
2590
|
-
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;
|
|
2591
3673
|
}
|
|
2592
3674
|
function permalinkHashSuffixFromUi() {
|
|
2593
3675
|
const tokens = [];
|
|
@@ -2611,16 +3693,31 @@ function permalinkHashSuffixFromUi() {
|
|
|
2611
3693
|
return tokens.length > 0 ? `#${tokens.join("&")}` : "";
|
|
2612
3694
|
}
|
|
2613
3695
|
function sharePermalinkFromShell(shell) {
|
|
2614
|
-
const
|
|
2615
|
-
const canonical = isHubRelativeStaticBrowseHref(raw.trim()) && raw.trim().length > 0
|
|
2616
|
-
? resolveStaticBrowseHref(raw.trim(), globalThis.location.pathname, globalThis.location.origin)
|
|
2617
|
-
: safePermalinkHref(raw);
|
|
2618
|
-
const base = canonical ?? globalThis.location.href;
|
|
2619
|
-
const u = new URL(base, globalThis.location.href);
|
|
3696
|
+
const u = new URL(pairBrowsePermalinkBaseHrefFromShell(shell), globalThis.location.href);
|
|
2620
3697
|
const hash = permalinkHashSuffixFromUi();
|
|
2621
3698
|
u.hash = hash.length > 0 ? hash.slice(1) : "";
|
|
2622
3699
|
return u.toString();
|
|
2623
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
|
+
}
|
|
2624
3721
|
async function writeTextToClipboard(text) {
|
|
2625
3722
|
try {
|
|
2626
3723
|
await navigator.clipboard.writeText(text);
|
|
@@ -2673,6 +3770,7 @@ function wireSharePermalinkButton() {
|
|
|
2673
3770
|
});
|
|
2674
3771
|
}
|
|
2675
3772
|
function main() {
|
|
3773
|
+
announceScrollSyncTraceOnBoot();
|
|
2676
3774
|
wireSharePermalinkButton();
|
|
2677
3775
|
wireColorThemeToolbar();
|
|
2678
3776
|
wireDocumentedFilesTree();
|
|
@@ -2686,11 +3784,22 @@ function main() {
|
|
|
2686
3784
|
wireWideModeIntroTrigger(shell);
|
|
2687
3785
|
const layout = shell.getAttribute("data-layout") || "dual";
|
|
2688
3786
|
if (layout === "stretch") {
|
|
2689
|
-
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
|
+
}
|
|
2690
3799
|
return;
|
|
2691
3800
|
}
|
|
2692
3801
|
wireDualPaneCodeBrowser(shell, codePane);
|
|
2693
|
-
|
|
3802
|
+
resolveEmbeddedStaticNavUrlsForCurrentPage(shell);
|
|
2694
3803
|
}
|
|
2695
3804
|
if (document.readyState === "loading") {
|
|
2696
3805
|
document.addEventListener("DOMContentLoaded", main);
|