@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.160",
3
+ "version": "0.55.161",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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
- setup(props) {
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
- const ratio = clientSize / scrollSize;
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(scrollPos) / maxScroll : 0;
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 thumbSize = Math.max((clientSize / scrollSize) * measured, 20);
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 target = s.dragStartScroll + (delta / trackRange) * (scrollSize - clientSize);
450
- if (horizontal) viewport.scrollLeft = target;
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";