@celestia-island/hikari 0.55.160 → 0.55.161
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/package.json +1 -1
- package/src/components/HkDiffViewer.test.tsx +112 -0
- package/src/components/HkDiffViewer.tsx +122 -3
- package/src/components/HkScrollContainer.tsx +16 -2
- package/src/composables/useOverlayScrollbar.scrollMap.test.ts +187 -0
- package/src/composables/useOverlayScrollbar.ts +162 -8
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -238,3 +238,115 @@ describe("HkDiffViewer", () => {
|
|
|
238
238
|
expect(root.querySelector(".hk-diff-viewer__hunk")).toBeNull();
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
|
+
|
|
242
|
+
// ── The fold-aware scroll map (2026-10-12 user direction) ──────────────
|
|
243
|
+
// The scrollbar must represent the WHOLE document while the viewer folds
|
|
244
|
+
// unchanged regions away: the map total is fold-invariant, deleted rows
|
|
245
|
+
// carry no document extent (they are not in the current file), and the
|
|
246
|
+
// hunk heading is chrome. Mounting and folding drives the emission.
|
|
247
|
+
describe("HkDiffViewer scroll map", () => {
|
|
248
|
+
const MAPPED_DIFF = [
|
|
249
|
+
// Counts MUST match the listed lines (3 old-side, 4 new-side; then
|
|
250
|
+
// 3 and 3) or the map total drifts off totalLines.
|
|
251
|
+
"@@ -1,3 +1,4 @@",
|
|
252
|
+
" ctx1",
|
|
253
|
+
" ctx2",
|
|
254
|
+
" ctx3",
|
|
255
|
+
"+add",
|
|
256
|
+
"@@ -50,3 +51,3 @@",
|
|
257
|
+
" ctxA",
|
|
258
|
+
"-old",
|
|
259
|
+
"+new",
|
|
260
|
+
" ctxB",
|
|
261
|
+
].join("\n");
|
|
262
|
+
|
|
263
|
+
function mountViewer(extra: Record<string, unknown> = {}) {
|
|
264
|
+
const emitted: Array<Array<{ dom: number; map: number }>> = [];
|
|
265
|
+
const root = mount({
|
|
266
|
+
diff: MAPPED_DIFF,
|
|
267
|
+
totalLines: 100,
|
|
268
|
+
onScrollMap: (segs: Array<{ dom: number; map: number }>) => emitted.push(segs),
|
|
269
|
+
...extra,
|
|
270
|
+
});
|
|
271
|
+
return { root, emitted };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
it("emits a map whose total is the document, fold-invariant", async () => {
|
|
275
|
+
const { root, emitted } = mountViewer();
|
|
276
|
+
await nextTick();
|
|
277
|
+
expect(emitted.length).toBeGreaterThanOrEqual(1);
|
|
278
|
+
const total = (segs: Array<{ map: number }>) => segs.reduce((a, s) => a + s.map, 0);
|
|
279
|
+
expect(total(emitted[emitted.length - 1])).toBe(100);
|
|
280
|
+
// Collapse a hunk: the document total must not change (the scrollbar
|
|
281
|
+
// is a map of the WHOLE file whatever is folded).
|
|
282
|
+
const head = root.querySelector<HTMLButtonElement>(".hk-diff-viewer__hunk-head")!;
|
|
283
|
+
head.click();
|
|
284
|
+
await nextTick();
|
|
285
|
+
expect(total(emitted[emitted.length - 1])).toBe(100);
|
|
286
|
+
head.click(); // back
|
|
287
|
+
await nextTick();
|
|
288
|
+
expect(total(emitted[emitted.length - 1])).toBe(100);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("keeps an empty-batched gap's rung at zero dom while the document keeps its lines", async () => {
|
|
292
|
+
// A host answering [] marks the gap done with NO rows fetched: the
|
|
293
|
+
// rung must not claim dom extent for lines that never rendered (R2's
|
|
294
|
+
// N2 caught +1957px of phantom dom) while the document side keeps
|
|
295
|
+
// the gap's full extent.
|
|
296
|
+
const { root, emitted } = mountViewer({
|
|
297
|
+
loadContext: async () => [],
|
|
298
|
+
});
|
|
299
|
+
await nextTick();
|
|
300
|
+
// MAPPED_DIFF has no leading gap (hunk 1 starts at line 1); the first
|
|
301
|
+
// divider is the 46-line middle gap (lines 5..50, anchor=start).
|
|
302
|
+
const divider = root.querySelector<HTMLButtonElement>(".hk-diff-viewer__gap")!;
|
|
303
|
+
divider.click();
|
|
304
|
+
await tick();
|
|
305
|
+
await nextTick();
|
|
306
|
+
const segs = emitted[emitted.length - 1];
|
|
307
|
+
// The gap is DONE via the empty batch: its document extent survives
|
|
308
|
+
// as a zero-dom rung — no phantom rows for lines that never rendered.
|
|
309
|
+
expect(segs).toContainEqual({ dom: 0, map: 46 });
|
|
310
|
+
expect(segs).not.toContainEqual({ dom: 46, map: 46 });
|
|
311
|
+
// …and the document total is still the whole file.
|
|
312
|
+
expect(segs.reduce((a, x) => a + x.map, 0)).toBe(100);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("orders an expanded anchor=start gap's revealed rung BEFORE its divider rung", async () => {
|
|
316
|
+
// R2's N3: swapping the rung order moved the remaining-divider's
|
|
317
|
+
// document start by ~727px. The render puts revealed rows above the
|
|
318
|
+
// remaining divider for anchor=start, and the map must mirror it.
|
|
319
|
+
const lines: string[] = [];
|
|
320
|
+
for (let i = 0; i < 40; i += 1) lines.push(` fetched-${i}`);
|
|
321
|
+
const { root, emitted } = mountViewer({
|
|
322
|
+
loadContext: async () => lines,
|
|
323
|
+
});
|
|
324
|
+
await nextTick();
|
|
325
|
+
// The trailing gap (anchor=start, after the last hunk).
|
|
326
|
+
const dividers = [...root.querySelectorAll<HTMLButtonElement>(".hk-diff-viewer__gap")];
|
|
327
|
+
const trailing = dividers[dividers.length - 1];
|
|
328
|
+
trailing.click();
|
|
329
|
+
await tick();
|
|
330
|
+
await nextTick();
|
|
331
|
+
const segs = emitted[emitted.length - 1];
|
|
332
|
+
// Find the trailing gap's rungs: revealed (dom>0, map=40) directly
|
|
333
|
+
// before the remaining divider (map = 100 - docBefore - 40).
|
|
334
|
+
const idx = segs.findIndex((x, i) => x.map === 40 && segs[i + 1] && segs[i + 1].map > 0 && segs[i + 1].dom >= 0 && x.dom > 0 && i > segs.length - 6);
|
|
335
|
+
expect(idx).toBeGreaterThan(-1);
|
|
336
|
+
expect(segs[idx].dom).toBeGreaterThan(0);
|
|
337
|
+
expect(segs[idx + 1].map).toBeGreaterThan(0);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("counts deleted rows as rendered-but-not-in-the-document", async () => {
|
|
341
|
+
const { emitted } = mountViewer();
|
|
342
|
+
await nextTick();
|
|
343
|
+
const segs = emitted[emitted.length - 1];
|
|
344
|
+
const total = segs.reduce((a, s) => a + s.map, 0);
|
|
345
|
+
const dom = segs.reduce((a, s) => a + s.dom, 0);
|
|
346
|
+
// 4 context/add rows in hunk 1 + 6 in hunk 2 (one of them a DELETED
|
|
347
|
+
// row: rendered, map 0) → dom exceeds the hunks' map contribution.
|
|
348
|
+
expect(dom).toBeGreaterThan(total - 100 + 0); // sanity: dom side exists
|
|
349
|
+
const hunkRows = segs.filter((s) => s.dom === 1 && s.map <= 1);
|
|
350
|
+
expect(hunkRows.length).toBeGreaterThan(4);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { computed, defineComponent, ref, watch } from "vue";
|
|
1
|
+
import { computed, defineComponent, onMounted, ref, watch } from "vue";
|
|
2
2
|
import { AlertTriangle, ChevronDown, ChevronRight, UnfoldVertical } from "lucide-vue-next";
|
|
3
3
|
import HkSpinner from "./HkSpinner";
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
5
|
import { highlight } from "../composables/useHighlight";
|
|
6
6
|
import { computeGaps, parseUnifiedDiff, type DiffLine, type UnchangedGap } from "./diffParse";
|
|
7
|
+
import type { OverlayScrollMapSegment } from "../composables/useOverlayScrollbar";
|
|
7
8
|
import "./HkDiffViewer.scss";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -57,15 +58,133 @@ export default defineComponent({
|
|
|
57
58
|
*/
|
|
58
59
|
loadContext: { type: Function, default: null },
|
|
59
60
|
},
|
|
60
|
-
|
|
61
|
+
emits: {
|
|
62
|
+
/**
|
|
63
|
+
* The fold-aware scroll map (see `OverlayScrollMapSegment`): document
|
|
64
|
+
* units are NEW-SIDE file lines (deleted rows carry none — they do
|
|
65
|
+
* not exist in the current file), dom units are MEASURED PIXELS of
|
|
66
|
+
* the rows that render (row-count fallback where nothing lays out; a
|
|
67
|
+
* hunk heading is chrome and carries no document extent). The total
|
|
68
|
+
* is FOLD-INVARIANT — expanding a region moves its extent from the
|
|
69
|
+
* divider's rung onto real rows without changing the document — so
|
|
70
|
+
* the scrollbar stays a map of the WHOLE file whatever is folded.
|
|
71
|
+
* Bind it to a HkScrollContainer's `mapSegments`.
|
|
72
|
+
*/
|
|
73
|
+
"scroll-map": null as OverlayScrollMapSegment[] | null,
|
|
74
|
+
},
|
|
75
|
+
setup(props, { emit }) {
|
|
76
|
+
const host = ref<HTMLElement | null>(null);
|
|
61
77
|
const { t } = useI18n();
|
|
62
78
|
|
|
63
79
|
const parsed = computed(() => parseUnifiedDiff(props.diff));
|
|
64
80
|
const gaps = computed(() => computeGaps(parsed.value, props.totalLines ?? null));
|
|
65
81
|
const collapsedHunks = ref<Set<number>>(new Set());
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Measured pixel heights of the three row kinds, so the map's dom
|
|
85
|
+
* side can be honest in px instead of row counts — a hunk heading is
|
|
86
|
+
* ~1.7 lines tall and a divider ~1.35, and count-units drift the
|
|
87
|
+
* rung boundaries one to two rows off the real geometry (R1's pin
|
|
88
|
+
* plateau sat 17px past the divider). Zero = not measured yet
|
|
89
|
+
* (no-layout environments), and the map falls back to counts.
|
|
90
|
+
*/
|
|
91
|
+
const rowKinds = ref({ line: 0, head: 0, gap: 0 });
|
|
92
|
+
|
|
93
|
+
function measureRowKinds() {
|
|
94
|
+
const root = host.value;
|
|
95
|
+
if (!root) return;
|
|
96
|
+
const line = root.querySelector<HTMLElement>(".hk-diff-viewer__line");
|
|
97
|
+
const head = root.querySelector<HTMLElement>(".hk-diff-viewer__hunk-head");
|
|
98
|
+
const gap = root.querySelector<HTMLElement>(".hk-diff-viewer__gap");
|
|
99
|
+
const next = {
|
|
100
|
+
line: line?.offsetHeight ?? 0,
|
|
101
|
+
head: head?.offsetHeight ?? 0,
|
|
102
|
+
gap: gap?.offsetHeight ?? 0,
|
|
103
|
+
};
|
|
104
|
+
// Write only on a real change: `rowKinds` feeds the scrollMap
|
|
105
|
+
// computed, and this runs from a watcher on that computed — an
|
|
106
|
+
// unconditional assign (a fresh object with equal values) would
|
|
107
|
+
// re-trigger it and recurse forever.
|
|
108
|
+
const prev = rowKinds.value;
|
|
109
|
+
if (
|
|
110
|
+
next.line !== prev.line || next.head !== prev.head || next.gap !== prev.gap
|
|
111
|
+
) {
|
|
112
|
+
rowKinds.value = next;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The fold-aware scroll map, in RENDER order (mirrors the tree the
|
|
117
|
+
* render function interleaves below, rung for rung). */
|
|
118
|
+
const scrollMap = computed<OverlayScrollMapSegment[]>(() => {
|
|
119
|
+
const segs: OverlayScrollMapSegment[] = [];
|
|
120
|
+
const mapOfGap = (gap: UnchangedGap) => {
|
|
121
|
+
const st = gapStates.value.get(gap.start);
|
|
122
|
+
const revealed = st?.revealed ?? 0;
|
|
123
|
+
const remaining = Math.max(gap.count - revealed, 0);
|
|
124
|
+
// Dom side counts the rows that actually RENDER (the same window
|
|
125
|
+
// walk the render does): a host answering an empty batch marks
|
|
126
|
+
// the gap done with zero rows shown, and the map must not claim
|
|
127
|
+
// dom extent for them — the DOCUMENT side keeps the lines.
|
|
128
|
+
const first =
|
|
129
|
+
gap.anchor === "end" ? gap.start + gap.count - revealed : gap.start;
|
|
130
|
+
let rendered = 0;
|
|
131
|
+
for (let no = first; no < first + revealed; no += 1) {
|
|
132
|
+
if (st?.lines.has(no)) rendered += 1;
|
|
133
|
+
}
|
|
134
|
+
const px = rowKinds.value.line > 0 ? rowKinds.value.line : 1;
|
|
135
|
+
const dividerPx =
|
|
136
|
+
rowKinds.value.gap > 0 ? rowKinds.value.gap : rowKinds.value.line > 0 ? rowKinds.value.line : 1;
|
|
137
|
+
const revealedRung = { dom: rendered * px, map: revealed };
|
|
138
|
+
const dividerRung = { dom: remaining > 0 ? dividerPx : 0, map: remaining };
|
|
139
|
+
// Mirror renderGap's order: anchor=end puts the divider ABOVE the
|
|
140
|
+
// revealed rows, anchor=start below them.
|
|
141
|
+
if (gap.anchor === "end") segs.push(dividerRung, revealedRung);
|
|
142
|
+
else segs.push(revealedRung, dividerRung);
|
|
143
|
+
};
|
|
144
|
+
const mapOfHunk = (idx: number) => {
|
|
145
|
+
const hunk = parsed.value.hunks[idx];
|
|
146
|
+
const linePx = rowKinds.value.line > 0 ? rowKinds.value.line : 1;
|
|
147
|
+
const headPx = rowKinds.value.head > 0 ? rowKinds.value.head : 1;
|
|
148
|
+
// The heading is chrome: it occupies a row and no document line.
|
|
149
|
+
segs.push({ dom: headPx, map: 0 });
|
|
150
|
+
const live = hunk.lines.filter((l) => l.kind !== "del").length;
|
|
151
|
+
if (collapsedHunks.value.has(idx)) {
|
|
152
|
+
// Body hidden: the whole new-side extent rides the heading rung.
|
|
153
|
+
segs.push({ dom: 0, map: live });
|
|
154
|
+
} else {
|
|
155
|
+
for (const line of hunk.lines) {
|
|
156
|
+
// Deleted rows render but exist only in the old file.
|
|
157
|
+
segs.push({ dom: linePx, map: line.kind === "del" ? 0 : 1 });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const gapsByHunk = new Map<number, UnchangedGap>();
|
|
162
|
+
for (const gap of gaps.value) gapsByHunk.set(gap.beforeHunk, gap);
|
|
163
|
+
for (let i = 0; i < parsed.value.hunks.length; i += 1) {
|
|
164
|
+
const gap = gapsByHunk.get(i);
|
|
165
|
+
if (gap) mapOfGap(gap);
|
|
166
|
+
mapOfHunk(i);
|
|
167
|
+
}
|
|
168
|
+
const trailing = gapsByHunk.get(parsed.value.hunks.length);
|
|
169
|
+
if (trailing) mapOfGap(trailing);
|
|
170
|
+
return segs;
|
|
171
|
+
});
|
|
172
|
+
|
|
66
173
|
/** Gap states keyed by the gap's new-side start line (stable identity). */
|
|
67
174
|
const gapStates = ref<Map<number, GapState>>(new Map());
|
|
68
175
|
|
|
176
|
+
// Emit on every fold change (and once up front) — the consumer feeds
|
|
177
|
+
// it straight into its HkScrollContainer's `mapSegments`. Declared
|
|
178
|
+
// AFTER gapStates: the immediate evaluation reads it, and a watch
|
|
179
|
+
// above the declaration would hit the temporal dead zone.
|
|
180
|
+
watch(scrollMap, (segs) => emit("scroll-map", segs), { immediate: true, flush: "post" });
|
|
181
|
+
// Re-measure after the DOM catches up (mount + every fold change:
|
|
182
|
+
// a first-ever divider/heading may have appeared).
|
|
183
|
+
onMounted(() => measureRowKinds());
|
|
184
|
+
watch([() => props.diff, () => props.totalLines, scrollMap], () => {
|
|
185
|
+
measureRowKinds();
|
|
186
|
+
}, { flush: "post" });
|
|
187
|
+
|
|
69
188
|
// A fresh diff invalidates every cached context line and fold state.
|
|
70
189
|
watch(
|
|
71
190
|
() => [props.diff, props.totalLines],
|
|
@@ -267,7 +386,7 @@ export default defineComponent({
|
|
|
267
386
|
const trailing = gapsByHunk.get(hunks.length);
|
|
268
387
|
if (trailing) blocks.push(renderGap(trailing));
|
|
269
388
|
return (
|
|
270
|
-
<div class="hk-diff-viewer">
|
|
389
|
+
<div class="hk-diff-viewer" ref={host}>
|
|
271
390
|
<div class="hk-diff-viewer__body">{blocks}</div>
|
|
272
391
|
</div>
|
|
273
392
|
);
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { useI18n } from "../i18n/context";
|
|
13
13
|
import { laidOffsetWithin } from "../composables/layoutGeometry";
|
|
14
14
|
import "./HkScrollContainer.scss";
|
|
15
|
-
import { attachOverlayScrollbars, type OverlayScrollbarHandle, type OverlayScrollbarMarker } from "../composables/useOverlayScrollbar";
|
|
15
|
+
import { attachOverlayScrollbars, type OverlayScrollbarHandle, type OverlayScrollMapSegment, type OverlayScrollbarMarker } from "../composables/useOverlayScrollbar";
|
|
16
16
|
import { useApproachEnd, type ApproachEndHandle } from "../composables/useApproachEnd";
|
|
17
17
|
import { provideScrollWindow } from "../composables/useScrollWindow";
|
|
18
18
|
import { SCROLL_HOST_CLASS } from "./HkScrollPin";
|
|
@@ -55,6 +55,13 @@ export default defineComponent({
|
|
|
55
55
|
/** Width the vertical track widens to while marks show, in px. Omitted
|
|
56
56
|
* = the library default (14). Only read when `markers` is non-empty. */
|
|
57
57
|
markersWidth: { type: Number, default: undefined },
|
|
58
|
+
/**
|
|
59
|
+
* Asymmetric scroll map (vertical): the scrollbar represents the
|
|
60
|
+
* DOCUMENT while the viewport holds only rendered rows — collapsed
|
|
61
|
+
* regions sweep the thumb quickly and pin the content while dragging
|
|
62
|
+
* (see `OverlayScrollMapSegment`). Omitted = the dom-native bar.
|
|
63
|
+
*/
|
|
64
|
+
mapSegments: { type: Array as PropType<OverlayScrollMapSegment[]>, default: undefined },
|
|
58
65
|
autoFollow: { type: Boolean, default: false },
|
|
59
66
|
overscanScreens: { type: Number, default: 1 },
|
|
60
67
|
/** Inline-axis alignment of the content when it fits the viewport.
|
|
@@ -274,9 +281,16 @@ export default defineComponent({
|
|
|
274
281
|
const vp = viewportRef.value;
|
|
275
282
|
if (!vp) return;
|
|
276
283
|
overlay?.detach();
|
|
277
|
-
overlay = attachOverlayScrollbars(vp, { axis: props.axis, markers: props.markers, markersWidth: props.markersWidth });
|
|
284
|
+
overlay = attachOverlayScrollbars(vp, { axis: props.axis, markers: props.markers, markersWidth: props.markersWidth, mapSegments: props.mapSegments });
|
|
278
285
|
}
|
|
279
286
|
|
|
287
|
+
/** Swap the fold state's map without rebuilding the chrome: expanding
|
|
288
|
+
* or collapsing a region only re-derives the thumb from the new
|
|
289
|
+
* rungs. */
|
|
290
|
+
watch(() => props.mapSegments, (next) => {
|
|
291
|
+
overlay?.setMapSegments(next ?? []);
|
|
292
|
+
}, { deep: true, flush: "post" });
|
|
293
|
+
|
|
280
294
|
/** Re-apply marks without rebuilding the chrome: a diff re-renders its
|
|
281
295
|
* marks on every tick, and detaching/reattaching would drop the
|
|
282
296
|
* scroll flash and re-run the positioning guard each time. */
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "./useOverlayScrollbar";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The asymmetric scroll map (2026-10-12 user direction): a scrollbar that
|
|
7
|
+
* represents the DOCUMENT while the scroller holds only rendered rows —
|
|
8
|
+
* the diff viewer's collapsed unchanged regions. The three behaviours the
|
|
9
|
+
* user named, plus the guarantee that nobody who opts out notices:
|
|
10
|
+
*
|
|
11
|
+
* 1. scrolling the CONTENT sweeps the thumb quickly across a collapsed
|
|
12
|
+
* region's map range;
|
|
13
|
+
* 2. DRAGGING the thumb through that range leaves the content pinned
|
|
14
|
+
* until the range ends;
|
|
15
|
+
* 3. swapping the map (a region unfolded) re-derives the thumb from the
|
|
16
|
+
* same document total — folding never moves the document;
|
|
17
|
+
* 4. without `mapSegments` the bar is dom-native, byte-for-byte the
|
|
18
|
+
* math every consumer had before.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Scroll geometry is stubbed (happy-dom lays nothing out): 900px of
|
|
22
|
+
* rendered content in a 300px viewport. The map below models a document
|
|
23
|
+
* of 2000 lines whose middle 1100 lines are collapsed to one divider:
|
|
24
|
+
*
|
|
25
|
+
* rung 1: dom 300, map 600 (rendered rows, 1:1)
|
|
26
|
+
* rung 2: dom 1, map 1100 (the collapsed divider)
|
|
27
|
+
* rung 3: dom 599, map 300
|
|
28
|
+
*/
|
|
29
|
+
const GEOM = { scrollHeight: 900, clientHeight: 300, scrollTop: 0 };
|
|
30
|
+
|
|
31
|
+
function stubGeometry(el: HTMLElement, geom: { scrollHeight?: number; clientHeight?: number } = {}) {
|
|
32
|
+
const desc: PropertyDescriptorMap = {
|
|
33
|
+
scrollHeight: { configurable: true, get: () => geom.scrollHeight ?? GEOM.scrollHeight },
|
|
34
|
+
clientHeight: { configurable: true, get: () => geom.clientHeight ?? GEOM.clientHeight },
|
|
35
|
+
};
|
|
36
|
+
let top = GEOM.scrollTop;
|
|
37
|
+
desc.scrollTop = { configurable: true, get: () => top, set: (v: number) => { top = v; } };
|
|
38
|
+
Object.defineProperties(el, desc);
|
|
39
|
+
return () => { GEOM.scrollTop = top; };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** dom 300|map 600, dom 1|map 1100, dom 599|map 300: Σdom=900 (the real
|
|
43
|
+
* scrollHeight), Σmap=2000 (the whole document). */
|
|
44
|
+
const FOLDED: Array<{ dom: number; map: number }> = [
|
|
45
|
+
{ dom: 300, map: 600 },
|
|
46
|
+
{ dom: 1, map: 1100 },
|
|
47
|
+
{ dom: 599, map: 300 },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const handles: OverlayScrollbarHandle[] = [];
|
|
51
|
+
const cleanups: Array<() => void> = [];
|
|
52
|
+
|
|
53
|
+
function mount(segments?: Array<{ dom: number; map: number }>) {
|
|
54
|
+
const host = document.createElement("div");
|
|
55
|
+
const viewport = document.createElement("div");
|
|
56
|
+
host.appendChild(viewport);
|
|
57
|
+
document.body.appendChild(host);
|
|
58
|
+
cleanups.push(() => host.remove());
|
|
59
|
+
const sync = stubGeometry(viewport);
|
|
60
|
+
const track = () => host.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])")!;
|
|
61
|
+
const thumb = () => host.querySelector<HTMLElement>(".hk-scrollbar-thumb")!;
|
|
62
|
+
const pct = (el: HTMLElement) => Number(/([\d.]+)px/.exec(el.style.transform ?? "")?.[1] ?? "0");
|
|
63
|
+
const height = (el: HTMLElement) => Number.parseFloat(el.style.height || "0");
|
|
64
|
+
const handle = attachOverlayScrollbars(viewport, {
|
|
65
|
+
axis: "vertical",
|
|
66
|
+
...(segments ? { mapSegments: segments } : {}),
|
|
67
|
+
});
|
|
68
|
+
handles.push(handle);
|
|
69
|
+
handle.update();
|
|
70
|
+
return { viewport, handle, sync, track, thumb, pct, height, host };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
for (const h of handles.splice(0)) h.detach();
|
|
75
|
+
for (const c of cleanups.splice(0)) c();
|
|
76
|
+
document.body.innerHTML = "";
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("overlay scrollbar scroll map", () => {
|
|
80
|
+
it("sizes and places the thumb in DOCUMENT space", () => {
|
|
81
|
+
const t = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(200);
|
|
82
|
+
// track height 200; viewport covers map 300→600? — with the viewport
|
|
83
|
+
// at dom 0, map 0..~333 of 2000 (300px viewport over 900px content
|
|
84
|
+
// spans rung 1 entirely plus a sliver of the divider).
|
|
85
|
+
const m = mount(FOLDED);
|
|
86
|
+
// Σmap=2000, viewport map extent = fracToMap(300/900) = 600 + (0/1)*1100 = 600.
|
|
87
|
+
// thumb = 600/2000 * 200 = 60px; max progress 0 → offset 0.
|
|
88
|
+
expect(m.height(m.thumb())).toBeCloseTo(60, 1);
|
|
89
|
+
expect(m.pct(m.thumb())).toBeCloseTo(0, 1);
|
|
90
|
+
t.mockRestore();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("sweeps the thumb FAST across a collapsed region while the content scrolls it (1)", () => {
|
|
94
|
+
const t = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(200);
|
|
95
|
+
const m = mount(FOLDED);
|
|
96
|
+
// 1px of dom travel at the divider = 1100 map units; the whole
|
|
97
|
+
// divider is crossed by 1px. Content at dom 300 (divider top):
|
|
98
|
+
m.viewport.scrollTop = 299;
|
|
99
|
+
m.handle.update();
|
|
100
|
+
const before = m.pct(m.thumb());
|
|
101
|
+
m.viewport.scrollTop = 301; // past the divider entirely
|
|
102
|
+
m.handle.update();
|
|
103
|
+
const after = m.pct(m.thumb());
|
|
104
|
+
// The two pixels of content moved the thumb across the collapsed
|
|
105
|
+
// range: from rung 1's end (map 600/2000) to rung 3's start
|
|
106
|
+
// (map 1700/2000) — over half the track for 2px of scroll.
|
|
107
|
+
expect(before).toBeLessThan(60);
|
|
108
|
+
expect(after).toBeGreaterThan(120);
|
|
109
|
+
t.mockRestore();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("PINS the content while the thumb drags through a collapsed range (2)", () => {
|
|
113
|
+
const t = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(200);
|
|
114
|
+
const m = mount(FOLDED);
|
|
115
|
+
m.viewport.scrollTop = 300; // the divider
|
|
116
|
+
m.handle.update();
|
|
117
|
+
const thumbEl = m.thumb();
|
|
118
|
+
thumbEl.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 0, clientY: 100 }));
|
|
119
|
+
// Drag well into the collapsed range (map 600..1700). The track is
|
|
120
|
+
// 200px, the thumb ~60px, so ~140px of travel ≈ the whole document.
|
|
121
|
+
const dragTo = (y: number) => {
|
|
122
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 0, clientY: y }));
|
|
123
|
+
};
|
|
124
|
+
dragTo(120); // a little into the range — still inside the divider's dom extent
|
|
125
|
+
expect(m.viewport.scrollTop).toBeGreaterThanOrEqual(299);
|
|
126
|
+
expect(m.viewport.scrollTop).toBeLessThanOrEqual(301);
|
|
127
|
+
dragTo(160); // deep into the range — the DOM cannot present it
|
|
128
|
+
expect(m.viewport.scrollTop).toBeGreaterThanOrEqual(299);
|
|
129
|
+
expect(m.viewport.scrollTop).toBeLessThanOrEqual(301);
|
|
130
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
131
|
+
t.mockRestore();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("re-derives the thumb from the SAME document total when a region unfolds (3)", () => {
|
|
135
|
+
const t = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(200);
|
|
136
|
+
const m = mount(FOLDED);
|
|
137
|
+
// Unfold: the divider's 1100 map lines become real dom rows (the
|
|
138
|
+
// consumer re-renders and reports taller content too).
|
|
139
|
+
const UNFOLDED = [
|
|
140
|
+
{ dom: 300, map: 600 },
|
|
141
|
+
{ dom: 1100, map: 1100 },
|
|
142
|
+
{ dom: 599, map: 300 },
|
|
143
|
+
];
|
|
144
|
+
vi.spyOn(m.viewport, "scrollHeight", "get").mockReturnValue(1999);
|
|
145
|
+
m.handle.setMapSegments(UNFOLDED);
|
|
146
|
+
m.handle.update();
|
|
147
|
+
// The thumb still describes the same document: its height is
|
|
148
|
+
// unchanged (viewport map extent / total), even though the dom grew.
|
|
149
|
+
expect(m.height(m.thumb())).toBeCloseTo(60, 1);
|
|
150
|
+
// And clearing the map returns the dom-native bar.
|
|
151
|
+
m.handle.setMapSegments([]);
|
|
152
|
+
m.handle.update();
|
|
153
|
+
expect(m.height(m.thumb())).toBeCloseTo((300 / 1999) * 200, 1);
|
|
154
|
+
t.mockRestore();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("falls back to the dom-native bar when the map carries no document", () => {
|
|
158
|
+
const t = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(200);
|
|
159
|
+
// A pure-deletion diff folds to zero document extent: Σmap = 0 must
|
|
160
|
+
// read as "no usable map", not as a zero-total map (whose thumb would
|
|
161
|
+
// be Infinity-tall and the dom-native fallback dead).
|
|
162
|
+
const m = mount([
|
|
163
|
+
{ dom: 300, map: 0 },
|
|
164
|
+
{ dom: 1, map: 0 },
|
|
165
|
+
{ dom: 599, map: 0 },
|
|
166
|
+
]);
|
|
167
|
+
expect(m.height(m.thumb())).toBeCloseTo((300 / 900) * 200, 1);
|
|
168
|
+
t.mockRestore();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("without mapSegments the bar stays dom-native (4)", () => {
|
|
172
|
+
const t = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(200);
|
|
173
|
+
const m = mount();
|
|
174
|
+
expect(m.height(m.thumb())).toBeCloseTo((300 / 900) * 200, 1);
|
|
175
|
+
// Drag math unchanged: full track travel = full scroll range.
|
|
176
|
+
m.viewport.scrollTop = 0;
|
|
177
|
+
m.handle.update();
|
|
178
|
+
m.thumb().dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 0, clientY: 0 }));
|
|
179
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 0, clientY: 70 }));
|
|
180
|
+
// Dom-native math, unchanged from before the map existed: thumb
|
|
181
|
+
// (300/900)*200 ≈ 66.7px, range 133.3px, so 70px of pointer =
|
|
182
|
+
// (70/133.3)·600 ≈ 315 — the exact value the pre-map engine produced.
|
|
183
|
+
expect(m.viewport.scrollTop).toBeCloseTo(315, 0);
|
|
184
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
185
|
+
t.mockRestore();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -78,6 +78,86 @@ const MARKER_MIN_PX = 2;
|
|
|
78
78
|
* default keeps the marks' two lanes readable at a glance. */
|
|
79
79
|
const DEFAULT_MARKERS_WIDTH = 14;
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* One rung of an ASYMMETRIC scroll map: `dom` units of rendered scroll
|
|
83
|
+
* space that carry `map` units of document space. The scrollbar then
|
|
84
|
+
* represents the DOCUMENT (the fully-expanded whole) while the scroller
|
|
85
|
+
* holds only what is rendered — collapsed regions ("40 unchanged
|
|
86
|
+
* lines…") occupy real map extent and (near-)zero dom extent, so:
|
|
87
|
+
*
|
|
88
|
+
* - scrolling the CONTENT sweeps the thumb quickly across a collapsed
|
|
89
|
+
* region's map range (a one-row divider stands for N document lines);
|
|
90
|
+
* - DRAGGING the thumb through that range leaves the content pinned
|
|
91
|
+
* (the divider is all the DOM there is to show) until the range ends;
|
|
92
|
+
* - markers, which consumers express as fractions of the FULL document,
|
|
93
|
+
* align with the thumb's map position — with a dom-native bar they
|
|
94
|
+
* clump against a mostly-collapsed scroller instead.
|
|
95
|
+
*
|
|
96
|
+
* Both sides may use any consistent unit (rows, px, lines); the engine
|
|
97
|
+
* normalises Σdom onto the scroller's real extent, so approximations are
|
|
98
|
+
* fine as long as proportions are honest. `map: 0` rungs are chrome
|
|
99
|
+
* (a hunk heading) and `dom: 0` rungs are fully-collapsed content — both
|
|
100
|
+
* are honoured. Vertical axis only.
|
|
101
|
+
*/
|
|
102
|
+
export interface OverlayScrollMapSegment {
|
|
103
|
+
/** Rendered scroll-space extent of this rung (>0 unless collapsed). */
|
|
104
|
+
dom: number;
|
|
105
|
+
/** Document-space extent it stands for (0 = pure chrome). */
|
|
106
|
+
map: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Nothing has been mapped: run the dom-native math unchanged. */
|
|
110
|
+
interface ScrollMap {
|
|
111
|
+
total: number;
|
|
112
|
+
fracToMap(frac: number): number;
|
|
113
|
+
mapToFrac(map: number): number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Build the piecewise-linear tables; null unless the map is usable. */
|
|
117
|
+
function buildScrollMap(
|
|
118
|
+
segments: OverlayScrollMapSegment[] | null | undefined,
|
|
119
|
+
): ScrollMap | null {
|
|
120
|
+
if (!segments || segments.length === 0) return null;
|
|
121
|
+
let domTotal = 0;
|
|
122
|
+
let mapTotal = 0;
|
|
123
|
+
const domCum: number[] = [0];
|
|
124
|
+
const mapCum: number[] = [0];
|
|
125
|
+
for (const seg of segments) {
|
|
126
|
+
const dom = Number.isFinite(seg.dom) ? Math.max(seg.dom, 0) : 0;
|
|
127
|
+
const map = Number.isFinite(seg.map) ? Math.max(seg.map, 0) : 0;
|
|
128
|
+
domTotal += dom;
|
|
129
|
+
mapTotal += map;
|
|
130
|
+
domCum.push(domTotal);
|
|
131
|
+
mapCum.push(mapTotal);
|
|
132
|
+
}
|
|
133
|
+
if (domTotal <= 0 || mapTotal <= 0) return null;
|
|
134
|
+
return {
|
|
135
|
+
total: mapTotal,
|
|
136
|
+
fracToMap(frac: number): number {
|
|
137
|
+
const f = Math.min(Math.max(frac, 0), 1) * domTotal;
|
|
138
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
139
|
+
if (f <= domCum[i + 1] || i === segments.length - 1) {
|
|
140
|
+
const span = domCum[i + 1] - domCum[i];
|
|
141
|
+
const local = span > 0 ? (f - domCum[i]) / span : 1;
|
|
142
|
+
return mapCum[i] + local * (mapCum[i + 1] - mapCum[i]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return mapTotal;
|
|
146
|
+
},
|
|
147
|
+
mapToFrac(map: number): number {
|
|
148
|
+
const m = Math.min(Math.max(map, 0), mapTotal);
|
|
149
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
150
|
+
if (m <= mapCum[i + 1] || i === segments.length - 1) {
|
|
151
|
+
const span = mapCum[i + 1] - mapCum[i];
|
|
152
|
+
const local = span > 0 ? (m - mapCum[i]) / span : 1;
|
|
153
|
+
return (domCum[i] + local * (domCum[i + 1] - domCum[i])) / domTotal;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return 1;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
81
161
|
/** Clamp to [0, 1]; non-finite input is the caller's bug, but the rail
|
|
82
162
|
* must never render a mark at a meaningless position, so it becomes 0. */
|
|
83
163
|
function clamp01(n: number): number {
|
|
@@ -114,11 +194,23 @@ export interface OverlayScrollbarOptions {
|
|
|
114
194
|
* pixels. Selective: consumers without marks keep the narrow track.
|
|
115
195
|
* Clamped to a sane range (the thumb and marks share the band). */
|
|
116
196
|
markersWidth?: number;
|
|
197
|
+
/**
|
|
198
|
+
* Asymmetric scroll map for the vertical axis (see
|
|
199
|
+
* `OverlayScrollMapSegment`): the scrollbar represents the DOCUMENT
|
|
200
|
+
* while the scroller holds only the rendered rows. Omitted or empty =
|
|
201
|
+
* the dom-native bar every consumer had before. Update it any time the
|
|
202
|
+
* layout folds or unfolds — the thumb, drag, click and marker-scrub
|
|
203
|
+
* paths all re-derive from it.
|
|
204
|
+
*/
|
|
205
|
+
mapSegments?: OverlayScrollMapSegment[];
|
|
117
206
|
}
|
|
118
207
|
|
|
119
208
|
export interface OverlayScrollbarHandle {
|
|
120
209
|
/** Re-read the viewport's scroll geometry and reposition the thumbs. */
|
|
121
210
|
update(): void;
|
|
211
|
+
/** Swap the asymmetric scroll map (empty clears it back to dom-native).
|
|
212
|
+
* Call whenever the layout folds or unfolds. */
|
|
213
|
+
setMapSegments(segments: OverlayScrollMapSegment[]): void;
|
|
122
214
|
/** Replace the rail's marks. Passing an empty list removes the rail
|
|
123
215
|
* again. No-op when the handle was attached against no host. */
|
|
124
216
|
setMarkers(markers: OverlayScrollbarMarker[]): void;
|
|
@@ -192,7 +284,7 @@ export function attachOverlayScrollbars(
|
|
|
192
284
|
const axis = opts.axis ?? "vertical";
|
|
193
285
|
const parent = opts.host ?? viewport.parentElement;
|
|
194
286
|
if (!parent) {
|
|
195
|
-
return { update() {}, setMarkers() {}, detach() {} };
|
|
287
|
+
return { update() {}, setMapSegments() {}, setMarkers() {}, detach() {} };
|
|
196
288
|
}
|
|
197
289
|
|
|
198
290
|
const wantV = axis !== "horizontal";
|
|
@@ -238,6 +330,8 @@ export function attachOverlayScrollbars(
|
|
|
238
330
|
// that never pass markers keep byte-identical chrome.
|
|
239
331
|
let track: HTMLElement | null = null;
|
|
240
332
|
let markers: OverlayScrollbarMarker[] = [];
|
|
333
|
+
/** The asymmetric document map, when the consumer provides one. */
|
|
334
|
+
let scrollMap: ScrollMap | null = buildScrollMap(opts.mapSegments);
|
|
241
335
|
let railDragging = false;
|
|
242
336
|
/** Rail height the marks were last sized against — the pixel floor is a
|
|
243
337
|
* function of it, so a resize has to re-render them. */
|
|
@@ -256,6 +350,16 @@ export function attachOverlayScrollbars(
|
|
|
256
350
|
if (rect.height <= 0) return;
|
|
257
351
|
const ratio = clamp01((clientY - rect.top) / rect.height);
|
|
258
352
|
const { scrollSize, clientSize } = axisMetrics(viewport, false);
|
|
353
|
+
if (scrollMap) {
|
|
354
|
+
// The band is the DOCUMENT: a scrub walks map space and collapses
|
|
355
|
+
// onto whatever dom position can present it.
|
|
356
|
+
const mapStart = scrollMap.fracToMap(viewport.scrollTop / scrollSize);
|
|
357
|
+
const mapEnd = scrollMap.fracToMap((viewport.scrollTop + clientSize) / scrollSize);
|
|
358
|
+
const viewportMap = Math.max(mapEnd - mapStart, 1e-6);
|
|
359
|
+
const mapPos = ratio * Math.max(scrollMap.total - viewportMap, 0);
|
|
360
|
+
viewport.scrollTop = scrollMap.mapToFrac(mapPos) * scrollSize;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
259
363
|
viewport.scrollTop = ratio * Math.max(scrollSize - clientSize, 0);
|
|
260
364
|
}
|
|
261
365
|
|
|
@@ -336,6 +440,13 @@ export function attachOverlayScrollbars(
|
|
|
336
440
|
// `parent` to non-null, and only a closure created AFTER that narrowing
|
|
337
441
|
// keeps it (a hoisted declaration may run at any time, so TS widens the
|
|
338
442
|
// capture back to `HTMLElement | null`).
|
|
443
|
+
/** Swap in a new fold state; the thumb re-derives on the next update. */
|
|
444
|
+
const setMapSegments = (next: OverlayScrollMapSegment[]) => {
|
|
445
|
+
if (detached) return;
|
|
446
|
+
scrollMap = buildScrollMap(next);
|
|
447
|
+
update();
|
|
448
|
+
};
|
|
449
|
+
|
|
339
450
|
const setMarkers = (next: OverlayScrollbarMarker[]) => {
|
|
340
451
|
if (detached) return;
|
|
341
452
|
markers = Array.isArray(next) ? next : [];
|
|
@@ -369,16 +480,30 @@ export function attachOverlayScrollbars(
|
|
|
369
480
|
}
|
|
370
481
|
s.track.style.display = "";
|
|
371
482
|
const trackSize = horizontal ? s.track.clientWidth : s.track.clientHeight;
|
|
372
|
-
|
|
483
|
+
// Asymmetric map (vertical): the thumb's SIZE and POSITION are
|
|
484
|
+
// document-space quantities — how much of the FILE the viewport shows
|
|
485
|
+
// and where in the FILE it sits — while every input stays the real
|
|
486
|
+
// DOM scroll the browser reports. A collapsed rung therefore shows as
|
|
487
|
+
// the thumb sweeping its map range with (almost) no dom travel.
|
|
488
|
+
let viewportMap = clientSize;
|
|
489
|
+
let maxScroll = scrollSize - clientSize;
|
|
490
|
+
let progressBase = scrollPos;
|
|
491
|
+
if (!horizontal && scrollMap) {
|
|
492
|
+
const mapStart = scrollMap.fracToMap(scrollPos / scrollSize);
|
|
493
|
+
const mapEnd = scrollMap.fracToMap((scrollPos + clientSize) / scrollSize);
|
|
494
|
+
viewportMap = Math.max(mapEnd - mapStart, 1e-6);
|
|
495
|
+
maxScroll = Math.max(scrollMap.total - viewportMap, 0);
|
|
496
|
+
progressBase = mapStart;
|
|
497
|
+
}
|
|
498
|
+
const ratio = viewportMap / (scrollMap && !horizontal ? scrollMap.total : scrollSize);
|
|
373
499
|
const thumbSize = Math.max(ratio * trackSize, 20);
|
|
374
|
-
const maxScroll = scrollSize - clientSize;
|
|
375
500
|
const maxTrack = trackSize - thumbSize;
|
|
376
501
|
// A right-to-left scroller starts with `scrollLeft` at 0 (showing the
|
|
377
502
|
// content's right end) and counts DOWN to -maxScroll, so its progress is
|
|
378
503
|
// the magnitude — and the thumb rests at the track's right end and
|
|
379
504
|
// travels left, mirrored against the left-to-right bar.
|
|
380
505
|
const rtl = isRtlAxis(viewport, horizontal);
|
|
381
|
-
const progress = maxScroll > 0 ? Math.abs(
|
|
506
|
+
const progress = maxScroll > 0 ? Math.abs(progressBase) / maxScroll : 0;
|
|
382
507
|
const offset = (rtl ? 1 - progress : progress) * maxTrack;
|
|
383
508
|
if (horizontal) {
|
|
384
509
|
s.thumb.style.width = `${thumbSize}px`;
|
|
@@ -443,12 +568,30 @@ export function attachOverlayScrollbars(
|
|
|
443
568
|
// and falls back to the viewport box.
|
|
444
569
|
const trackSize = horizontal ? s.track.clientWidth : s.track.clientHeight;
|
|
445
570
|
const measured = trackSize > 0 ? trackSize : clientSize;
|
|
446
|
-
const
|
|
571
|
+
const { scrollSize: sizeNow, clientSize: clientNow } = axisMetrics(viewport, horizontal);
|
|
572
|
+
if (horizontal || !scrollMap) {
|
|
573
|
+
// Dom-native drag (and every horizontal axis).
|
|
574
|
+
const thumbSize = Math.max((clientNow / sizeNow) * measured, 20);
|
|
575
|
+
const trackRange = measured - thumbSize;
|
|
576
|
+
if (trackRange <= 0) return;
|
|
577
|
+
const target = s.dragStartScroll + (delta / trackRange) * (sizeNow - clientNow);
|
|
578
|
+
if (horizontal) viewport.scrollLeft = target;
|
|
579
|
+
else viewport.scrollTop = target;
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
// Mapped drag: the pointer walks the thumb through DOCUMENT space;
|
|
583
|
+
// `mapToFrac` collapses a rung the DOM cannot render into (almost)
|
|
584
|
+
// one scrollTop — dragging across a collapsed region leaves the
|
|
585
|
+
// content pinned at its divider until the range ends.
|
|
586
|
+
const f0 = s.dragStartScroll / sizeNow;
|
|
587
|
+
const mapStart = scrollMap.fracToMap(f0);
|
|
588
|
+
const mapEnd = scrollMap.fracToMap((s.dragStartScroll + clientNow) / sizeNow);
|
|
589
|
+
const viewportMap = Math.max(mapEnd - mapStart, 1e-6);
|
|
590
|
+
const thumbSize = Math.max((viewportMap / scrollMap.total) * measured, 20);
|
|
447
591
|
const trackRange = measured - thumbSize;
|
|
448
592
|
if (trackRange <= 0) return;
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
else viewport.scrollTop = target;
|
|
593
|
+
const mapPos = mapStart + (delta / trackRange) * (scrollMap.total - viewportMap);
|
|
594
|
+
viewport.scrollTop = scrollMap.mapToFrac(mapPos) * sizeNow;
|
|
452
595
|
};
|
|
453
596
|
const onUp = () => {
|
|
454
597
|
s.dragging = false;
|
|
@@ -480,6 +623,16 @@ export function attachOverlayScrollbars(
|
|
|
480
623
|
: (e.clientY - rect.top) / rect.height;
|
|
481
624
|
const { scrollSize, clientSize } = axisMetrics(viewport, horizontal);
|
|
482
625
|
const max = scrollSize - clientSize;
|
|
626
|
+
if (!horizontal && scrollMap) {
|
|
627
|
+
// Clicking the band jumps the DOCUMENT to the clicked rung — the
|
|
628
|
+
// same space the thumb lives in.
|
|
629
|
+
const mapStart = scrollMap.fracToMap(viewport.scrollTop / scrollSize);
|
|
630
|
+
const mapEnd = scrollMap.fracToMap((viewport.scrollTop + clientSize) / scrollSize);
|
|
631
|
+
const viewportMap = Math.max(mapEnd - mapStart, 1e-6);
|
|
632
|
+
const mapPos = ratio * Math.max(scrollMap.total - viewportMap, 0);
|
|
633
|
+
viewport.scrollTop = scrollMap.mapToFrac(mapPos) * scrollSize;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
483
636
|
if (horizontal) {
|
|
484
637
|
// RTL: the track's left end is the content's END, and `scrollLeft`
|
|
485
638
|
// runs 0 → −max (see the thumb mirroring in `update`), so a click at
|
|
@@ -510,6 +663,7 @@ export function attachOverlayScrollbars(
|
|
|
510
663
|
|
|
511
664
|
return {
|
|
512
665
|
update,
|
|
666
|
+
setMapSegments,
|
|
513
667
|
setMarkers,
|
|
514
668
|
detach() {
|
|
515
669
|
if (detached) return;
|
package/src/index.ts
CHANGED
|
@@ -422,7 +422,7 @@ export { useApproachEnd } from "./composables/useApproachEnd";
|
|
|
422
422
|
export type { ApproachEndOptions, ApproachEndHandle } from "./composables/useApproachEnd";
|
|
423
423
|
// The change-mark rail's public vocabulary: HkScrollContainer's `markers`
|
|
424
424
|
// prop is typed with it, so consumers can name what they pass.
|
|
425
|
-
export type { OverlayScrollbarMarker } from "./composables/useOverlayScrollbar";
|
|
425
|
+
export type { OverlayScrollMapSegment, OverlayScrollbarMarker } from "./composables/useOverlayScrollbar";
|
|
426
426
|
export { useDeferredTransition } from "./composables/useDeferredTransition";
|
|
427
427
|
export { useSurfaceTransition } from "./composables/useSurfaceTransition";
|
|
428
428
|
export { useSizeMorph } from "./composables/useSizeMorph";
|