@celestia-island/hikari 0.55.151 → 0.55.153
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/HkBoard.fitCap.test.tsx +59 -0
- package/src/components/HkBoard.tsx +6 -0
- package/src/components/HkScrollContainer.test.ts +42 -0
- package/src/components/HkScrollContainer.tsx +15 -2
- package/src/components/overlayScrollbarChrome.contract.test.ts +41 -0
- package/src/composables/useBoardCamera.ts +10 -1
- package/src/composables/useOverlayScrollbar.test.ts +262 -3
- package/src/composables/useOverlayScrollbar.ts +185 -2
- package/src/index.ts +3 -0
- package/src/styles/theme/scrollbar.scss +76 -0
package/package.json
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkBoard, { type BoardNodeInput } from "./HkBoard";
|
|
5
|
+
|
|
6
|
+
/** A board whose content is far smaller than the viewport: the default fit
|
|
7
|
+
* parks at the library cap; a fitCap lets the host fill the stage. */
|
|
8
|
+
const nodes: BoardNodeInput[] = [
|
|
9
|
+
{ id: "a", x: 0, y: 0, w: 120, h: 80, label: "A" },
|
|
10
|
+
];
|
|
11
|
+
const VIEW_W = 800;
|
|
12
|
+
const VIEW_H = 600;
|
|
13
|
+
|
|
14
|
+
function mountBoard(props: Record<string, unknown>): { world: () => string; app: ReturnType<typeof createApp> } {
|
|
15
|
+
// happy-dom lays nothing out: give the board a real viewport box so the
|
|
16
|
+
// fit maths are meaningful (the chest board tests use the same stub).
|
|
17
|
+
Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, get: () => VIEW_W });
|
|
18
|
+
Object.defineProperty(HTMLElement.prototype, "clientHeight", { configurable: true, get: () => VIEW_H });
|
|
19
|
+
const host = document.createElement("div");
|
|
20
|
+
document.body.appendChild(host);
|
|
21
|
+
const app = createApp(defineComponent({
|
|
22
|
+
setup: () => () => h(HkBoard, { nodes, ...props }),
|
|
23
|
+
}));
|
|
24
|
+
app.mount(host);
|
|
25
|
+
return {
|
|
26
|
+
world: () => document.body.querySelector<HTMLElement>(".hk-board-world")!.style.transform,
|
|
27
|
+
app,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The world transform's scale factor, parsed out of `translate(..) scale(k)`. */
|
|
32
|
+
function scaleOf(transform: string): number {
|
|
33
|
+
return Number(/scale\(([\d.]+)\)/.exec(transform)?.[1] ?? 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("HkBoard fitCap", () => {
|
|
37
|
+
afterEach(() => { document.body.innerHTML = ""; });
|
|
38
|
+
|
|
39
|
+
it("caps the default fit at the library ceiling when no fitCap is given", async () => {
|
|
40
|
+
const { world, app } = mountBoard({});
|
|
41
|
+
await nextTick();
|
|
42
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
43
|
+
expect(scaleOf(world())).toBeLessThanOrEqual(1.25 + 1e-6);
|
|
44
|
+
app.unmount();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("lets a host raise the fit ceiling to fill the stage", async () => {
|
|
48
|
+
const { world, app } = mountBoard({ fitCap: 3 });
|
|
49
|
+
await nextTick();
|
|
50
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
51
|
+
const k = scaleOf(world());
|
|
52
|
+
// The fit honours the raised cap: with a 120px node in an 800px-ish
|
|
53
|
+
// stage the scale would be ~5 at fill; the cap is the ceiling, so the
|
|
54
|
+
// observed scale must exceed the library default and not exceed 3.
|
|
55
|
+
expect(k).toBeGreaterThan(1.25);
|
|
56
|
+
expect(k).toBeLessThanOrEqual(3 + 1e-6);
|
|
57
|
+
app.unmount();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -117,6 +117,11 @@ export default defineComponent({
|
|
|
117
117
|
minimap: { type: Boolean, default: true },
|
|
118
118
|
minK: { type: Number, default: 0.2 },
|
|
119
119
|
maxK: { type: Number, default: 4 },
|
|
120
|
+
/** Ceiling for the fit zoom (reset button, fitOnMount). The library
|
|
121
|
+
* default caps a fit at 1.25 so a small board never fills a huge
|
|
122
|
+
* viewport; a host that wants fill-the-stage framing passes a higher
|
|
123
|
+
* cap (the camera clamp still honours maxK). */
|
|
124
|
+
fitCap: { type: Number, default: undefined },
|
|
120
125
|
animated: { type: Boolean, default: true },
|
|
121
126
|
fitOnMount: { type: Boolean, default: true },
|
|
122
127
|
},
|
|
@@ -141,6 +146,7 @@ export default defineComponent({
|
|
|
141
146
|
minK: props.minK,
|
|
142
147
|
maxK: props.maxK,
|
|
143
148
|
animated: props.animated,
|
|
149
|
+
fitCap: props.fitCap,
|
|
144
150
|
});
|
|
145
151
|
const camera = cam.camera as Ref<BoardCamera>;
|
|
146
152
|
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, reactive, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkScrollContainer from "./HkScrollContainer";
|
|
5
|
+
import type { OverlayScrollbarMarker } from "../composables/useOverlayScrollbar";
|
|
5
6
|
|
|
6
7
|
interface Mounted {
|
|
7
8
|
app: ReturnType<typeof createApp>;
|
|
@@ -401,3 +402,44 @@ describe("HkScrollContainer back-to-top signal", () => {
|
|
|
401
402
|
app.unmount();
|
|
402
403
|
});
|
|
403
404
|
});
|
|
405
|
+
|
|
406
|
+
describe("HkScrollContainer change-mark rail", () => {
|
|
407
|
+
it("renders the marks prop, and re-applies new marks without rebuilding the chrome", async () => {
|
|
408
|
+
const markers = ref<OverlayScrollbarMarker[]>([{ start: 0.1, end: 0.2, kind: "added" }]);
|
|
409
|
+
const container = document.createElement("div");
|
|
410
|
+
document.body.appendChild(container);
|
|
411
|
+
containers.push(container);
|
|
412
|
+
const Wrapper = defineComponent({
|
|
413
|
+
setup() {
|
|
414
|
+
return () =>
|
|
415
|
+
h(HkScrollContainer, { markers: markers.value }, { default: () => h("span", "content") });
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
const app = createApp(Wrapper);
|
|
419
|
+
mounts.push(app);
|
|
420
|
+
app.mount(container);
|
|
421
|
+
|
|
422
|
+
const rail = () => container.querySelector<HTMLElement>(".hk-scrollbar-markers");
|
|
423
|
+
const firstRail = rail();
|
|
424
|
+
expect(firstRail, "the rail exists once marks arrive").not.toBeNull();
|
|
425
|
+
expect(firstRail!.querySelectorAll(".hk-scrollbar-marker").length).toBe(1);
|
|
426
|
+
|
|
427
|
+
markers.value = [
|
|
428
|
+
{ start: 0.5, end: 0.6, kind: "deleted" },
|
|
429
|
+
{ start: 0.7, end: 0.8, kind: "modified" },
|
|
430
|
+
];
|
|
431
|
+
await nextTick();
|
|
432
|
+
const marks = rail()!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
|
|
433
|
+
expect(marks.length).toBe(2);
|
|
434
|
+
expect(marks[0].getAttribute("data-kind")).toBe("deleted");
|
|
435
|
+
// Same element: a marks update must not detach/reattach the overlay
|
|
436
|
+
// (that would drop the scroll flash and re-run the position guard).
|
|
437
|
+
expect(rail()).toBe(firstRail);
|
|
438
|
+
app.unmount();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("keeps the rail out of the DOM when no marks are passed", () => {
|
|
442
|
+
const m = mountScroller({});
|
|
443
|
+
expect(m.container.querySelector(".hk-scrollbar-markers")).toBeNull();
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -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 } from "../composables/useOverlayScrollbar";
|
|
15
|
+
import { attachOverlayScrollbars, type OverlayScrollbarHandle, 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";
|
|
@@ -46,6 +46,12 @@ export default defineComponent({
|
|
|
46
46
|
axis: { type: String as PropType<ScrollAxis>, default: "vertical" },
|
|
47
47
|
mode: { type: String as PropType<ScrollMode>, default: "traditional" },
|
|
48
48
|
scrollbar: { type: Boolean, default: true },
|
|
49
|
+
/** Change marks for the overlay's right-hand rail (VS Code's overview
|
|
50
|
+
* ruler): fractions of the FULL content extent, so a mark stays put
|
|
51
|
+
* while the user scrolls. Vertical axis only, and additive — leaving
|
|
52
|
+
* it out renders exactly the chrome this container had before the
|
|
53
|
+
* rail existed. */
|
|
54
|
+
markers: { type: Array as PropType<OverlayScrollbarMarker[]>, default: () => [] },
|
|
49
55
|
autoFollow: { type: Boolean, default: false },
|
|
50
56
|
overscanScreens: { type: Number, default: 1 },
|
|
51
57
|
/** Inline-axis alignment of the content when it fits the viewport.
|
|
@@ -265,9 +271,16 @@ export default defineComponent({
|
|
|
265
271
|
const vp = viewportRef.value;
|
|
266
272
|
if (!vp) return;
|
|
267
273
|
overlay?.detach();
|
|
268
|
-
overlay = attachOverlayScrollbars(vp, { axis: props.axis });
|
|
274
|
+
overlay = attachOverlayScrollbars(vp, { axis: props.axis, markers: props.markers });
|
|
269
275
|
}
|
|
270
276
|
|
|
277
|
+
/** Re-apply marks without rebuilding the chrome: a diff re-renders its
|
|
278
|
+
* marks on every tick, and detaching/reattaching would drop the
|
|
279
|
+
* scroll flash and re-run the positioning guard each time. */
|
|
280
|
+
watch(() => props.markers, (next) => {
|
|
281
|
+
overlay?.setMarkers(next ?? []);
|
|
282
|
+
}, { deep: true, flush: "post" });
|
|
283
|
+
|
|
271
284
|
/** Tear down and rebuild the overlay tracks when the `scrollbar`
|
|
272
285
|
* opt-in flips at runtime (HkTabs passes the prop through). */
|
|
273
286
|
watch(() => props.scrollbar, (on) => {
|
|
@@ -70,4 +70,45 @@ describe("overlay scrollbar chrome contract", () => {
|
|
|
70
70
|
expect(chrome).toMatch(/\.hk-scrollbar-track\s*\{[^}]*pointer-events:\s*none/);
|
|
71
71
|
expect(chrome).toMatch(/&?\.is-scrolling,\s*&?\.is-hovering\s*\{[^}]*pointer-events:\s*auto/);
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it("keeps the change-mark rail clear of the track and out of the click path", () => {
|
|
75
|
+
// The rail is a SECOND overlay in the same corner. Two facts have to
|
|
76
|
+
// survive any future restyle:
|
|
77
|
+
//
|
|
78
|
+
// 1. It must not overlap the track (right 2px + width 6px), or marks
|
|
79
|
+
// would draw across the thumb;
|
|
80
|
+
// 2. it must not take pointer input — it spans the WHOLE host, docks
|
|
81
|
+
// included, so a live hot zone there eats presses meant for
|
|
82
|
+
// right-aligned dock controls. Only the marks accept input, and
|
|
83
|
+
// that exemption is exactly why the rail itself must stay `none`.
|
|
84
|
+
// The vertical geometry lives in the track's own nested
|
|
85
|
+
// `&:not([data-axis="horizontal"])` block, not a top-level selector.
|
|
86
|
+
const track = /&:not\(\[data-axis="horizontal"\]\)\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
|
|
87
|
+
expect(track, "positive control: the vertical track rule was found").not.toBe("");
|
|
88
|
+
const trackRight = Number(/right:\s*(\d+)px/.exec(track)?.[1]);
|
|
89
|
+
const trackWidth = Number(/width:\s*(\d+)px/.exec(track)?.[1]);
|
|
90
|
+
|
|
91
|
+
const rail = /\.hk-scrollbar-markers\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
|
|
92
|
+
expect(rail, "positive control: the rail rule was found").not.toBe("");
|
|
93
|
+
const railRight = Number(/right:\s*(\d+)px/.exec(rail)?.[1]);
|
|
94
|
+
const railWidth = Number(/width:\s*(\d+)px/.exec(rail)?.[1]);
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
railRight,
|
|
98
|
+
"the rail starts outboard of the track's inner edge",
|
|
99
|
+
).toBeGreaterThanOrEqual(trackRight + trackWidth);
|
|
100
|
+
expect(railRight + railWidth, "and stays inside the host").toBeLessThanOrEqual(24);
|
|
101
|
+
|
|
102
|
+
expect(rail, "the rail itself takes no pointer input").toMatch(/pointer-events:\s*none/);
|
|
103
|
+
const marker = /\.hk-scrollbar-marker\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
|
|
104
|
+
expect(marker, "positive control: the mark rule was found").not.toBe("");
|
|
105
|
+
expect(marker, "the marks are the hit area").toMatch(/pointer-events:\s*auto/);
|
|
106
|
+
// The hover ring must be drawn INSIDE the mark: the rail clips its
|
|
107
|
+
// children, and a plain outline loses both side strokes to that clip,
|
|
108
|
+
// leaving a two-line "ring" that reads as a stray rule rather than a
|
|
109
|
+
// hover cue. Nothing else would notice that regression.
|
|
110
|
+
expect(marker, "the hover ring is drawn inside the mark").toMatch(
|
|
111
|
+
/outline-offset:\s*-1px/,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
73
114
|
});
|
|
@@ -47,6 +47,11 @@ export interface UseBoardCameraOptions {
|
|
|
47
47
|
maxK?: number;
|
|
48
48
|
/** Master animation switch; reduced-motion forces snapping regardless. */
|
|
49
49
|
animated?: boolean;
|
|
50
|
+
/** Optional ceiling for fit()'s scale: boards whose content is smaller
|
|
51
|
+
* than the viewport may want to fill it instead of parking at the
|
|
52
|
+
* library's default cap (BOARD_FIT_CAP). Unset keeps the default; the
|
|
53
|
+
* camera clamp still honours maxK. */
|
|
54
|
+
fitCap?: number;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
export interface UseBoardCameraReturn {
|
|
@@ -81,6 +86,10 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
81
86
|
minK = BOARD_K_MIN,
|
|
82
87
|
maxK = BOARD_K_MAX,
|
|
83
88
|
animated = true,
|
|
89
|
+
/** Ceiling for fit()'s scale: boards whose content is smaller than the
|
|
90
|
+
* viewport may want to fill it instead of parking at the library's
|
|
91
|
+
* default cap (BOARD_FIT_CAP). Unset keeps the default. */
|
|
92
|
+
fitCap = undefined,
|
|
84
93
|
} = options;
|
|
85
94
|
|
|
86
95
|
const camera = ref<BoardCamera>({ x: 0, y: 0, k: 1 });
|
|
@@ -195,7 +204,7 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
function fit(): void {
|
|
198
|
-
const target = boardFit(contentRect(), viewport());
|
|
207
|
+
const target = boardFit(contentRect(), viewport(), fitCap !== undefined ? { cap: fitCap } : {});
|
|
199
208
|
kFloor = Math.min(minK, target.k);
|
|
200
209
|
animateTo(target);
|
|
201
210
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "./useOverlayScrollbar";
|
|
3
|
+
import { attachOverlayScrollbars, type OverlayScrollbarHandle, type OverlayScrollbarOptions } from "./useOverlayScrollbar";
|
|
4
4
|
|
|
5
5
|
const handles: OverlayScrollbarHandle[] = [];
|
|
6
6
|
const containers: HTMLElement[] = [];
|
|
7
7
|
|
|
8
8
|
/** Mount a viewport (the scrolling element) inside a wrapper inside a
|
|
9
9
|
* fresh container: wrapper ← viewport. The wrapper is the track host. */
|
|
10
|
-
function mountViewport(
|
|
10
|
+
function mountViewport(
|
|
11
|
+
axis: "vertical" | "horizontal" | "both" = "vertical",
|
|
12
|
+
extra: Omit<OverlayScrollbarOptions, "axis"> = {},
|
|
13
|
+
) {
|
|
11
14
|
const container = document.createElement("div");
|
|
12
15
|
document.body.appendChild(container);
|
|
13
16
|
containers.push(container);
|
|
@@ -15,11 +18,37 @@ function mountViewport(axis: "vertical" | "horizontal" | "both" = "vertical") {
|
|
|
15
18
|
const viewport = document.createElement("div");
|
|
16
19
|
wrapper.appendChild(viewport);
|
|
17
20
|
container.appendChild(wrapper);
|
|
18
|
-
const handle = attachOverlayScrollbars(viewport, { axis });
|
|
21
|
+
const handle = attachOverlayScrollbars(viewport, { axis, ...extra });
|
|
19
22
|
handles.push(handle);
|
|
20
23
|
return { container, wrapper, viewport, handle };
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
/** A scrollable vertical viewport with a deterministic 100px rail box:
|
|
27
|
+
* happy-dom performs no layout, so both the geometry and the rail's
|
|
28
|
+
* bounding box are stubbed. `height` of the rail is what maps a mark's
|
|
29
|
+
* fraction to a pixel position. */
|
|
30
|
+
function mountRailed(railHeight = 100) {
|
|
31
|
+
const mounted = mountViewport("vertical");
|
|
32
|
+
stubGeometry(mounted.viewport, {
|
|
33
|
+
scrollHeight: 1000, clientHeight: 100, scrollTop: 0,
|
|
34
|
+
scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
|
|
35
|
+
});
|
|
36
|
+
mounted.handle.update();
|
|
37
|
+
return {
|
|
38
|
+
...mounted,
|
|
39
|
+
rail: () => {
|
|
40
|
+
const el = mounted.wrapper.querySelector<HTMLElement>(".hk-scrollbar-markers");
|
|
41
|
+
if (el) {
|
|
42
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
|
43
|
+
top: 0, left: 0, width: 8, height: railHeight,
|
|
44
|
+
bottom: railHeight, right: 8, x: 0, y: 0, toJSON: () => ({}),
|
|
45
|
+
} as DOMRect);
|
|
46
|
+
}
|
|
47
|
+
return el;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
/** happy-dom performs no layout, so scroll geometry is stubbed on the
|
|
24
53
|
* viewport instance (shadowing the prototype getters) — the same
|
|
25
54
|
* trick HkScrollContainer.test.ts uses, plus writable scroll offsets
|
|
@@ -44,6 +73,16 @@ function stubGeometry(
|
|
|
44
73
|
Object.defineProperties(el, desc);
|
|
45
74
|
}
|
|
46
75
|
|
|
76
|
+
/** Give the rail a measured height before applying the marks under test.
|
|
77
|
+
* The pixel floor is derived from `clientHeight` AT RENDER TIME, so the
|
|
78
|
+
* stub has to exist before the marks are rendered. */
|
|
79
|
+
function railOfHeight(m: ReturnType<typeof mountRailed>, height: number): HTMLElement {
|
|
80
|
+
m.handle.setMarkers([{ start: 0, end: 0 }]);
|
|
81
|
+
const rail = m.rail()!;
|
|
82
|
+
Object.defineProperty(rail, "clientHeight", { configurable: true, get: () => height });
|
|
83
|
+
return rail;
|
|
84
|
+
}
|
|
85
|
+
|
|
47
86
|
afterEach(() => {
|
|
48
87
|
for (const h of handles.splice(0)) h.detach();
|
|
49
88
|
for (const el of containers.splice(0)) el.remove();
|
|
@@ -308,7 +347,227 @@ describe("attachOverlayScrollbars", () => {
|
|
|
308
347
|
handles.push(handle);
|
|
309
348
|
expect(() => {
|
|
310
349
|
handle.update();
|
|
350
|
+
handle.setMarkers([{ start: 0, end: 0.5 }]);
|
|
311
351
|
handle.detach();
|
|
312
352
|
}).not.toThrow();
|
|
313
353
|
});
|
|
314
354
|
});
|
|
355
|
+
|
|
356
|
+
describe("change-mark rail", () => {
|
|
357
|
+
it("renders no rail at all when the consumer passes no markers", () => {
|
|
358
|
+
const { wrapper } = mountViewport("vertical");
|
|
359
|
+
expect(wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("creates the rail and one mark per entry, tagged by kind", () => {
|
|
363
|
+
const m = mountRailed();
|
|
364
|
+
m.handle.setMarkers([
|
|
365
|
+
{ start: 0.1, end: 0.2, kind: "added" },
|
|
366
|
+
{ start: 0.5, end: 0.6, kind: "deleted" },
|
|
367
|
+
]);
|
|
368
|
+
const rail = m.rail();
|
|
369
|
+
expect(rail).not.toBeNull();
|
|
370
|
+
const marks = rail!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
|
|
371
|
+
expect(marks.length).toBe(2);
|
|
372
|
+
expect(marks[0].getAttribute("data-kind")).toBe("added");
|
|
373
|
+
expect(marks[1].getAttribute("data-kind")).toBe("deleted");
|
|
374
|
+
expect(marks[0].style.top).toBe("10%");
|
|
375
|
+
expect(marks[0].style.height).toBe("10%");
|
|
376
|
+
expect(marks[1].style.top).toBe("50%");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("accepts markers through the attach options", () => {
|
|
380
|
+
const m = mountViewport("vertical", { markers: [{ start: 0, end: 0.25, kind: "added" }] });
|
|
381
|
+
stubGeometry(m.viewport, {
|
|
382
|
+
scrollHeight: 1000, clientHeight: 100, scrollTop: 0,
|
|
383
|
+
scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
|
|
384
|
+
});
|
|
385
|
+
m.handle.update();
|
|
386
|
+
expect(m.wrapper.querySelectorAll(".hk-scrollbar-marker").length).toBe(1);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("floors a hairline mark and keeps an end-of-file mark inside the rail", () => {
|
|
390
|
+
const m = mountRailed();
|
|
391
|
+
m.handle.setMarkers([
|
|
392
|
+
// One line of a 1000-line file: 0.1% — under the floor.
|
|
393
|
+
{ start: 0.4, end: 0.401, kind: "modified" },
|
|
394
|
+
// The very last line: top + height must not overflow the rail.
|
|
395
|
+
{ start: 1, end: 1, kind: "added" },
|
|
396
|
+
]);
|
|
397
|
+
const marks = m.rail()!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
|
|
398
|
+
expect(parseFloat(marks[0].style.height), "floored to the minimum").toBeCloseTo(0.5, 5);
|
|
399
|
+
const top = parseFloat(marks[1].style.top);
|
|
400
|
+
const height = parseFloat(marks[1].style.height);
|
|
401
|
+
expect(top + height, "a mark at the end stays inside the rail").toBeLessThanOrEqual(100);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("floors a hairline mark in PIXELS on a short rail, not just as a fraction", () => {
|
|
405
|
+
const m = mountRailed();
|
|
406
|
+
railOfHeight(m, 100);
|
|
407
|
+
m.handle.setMarkers([{ start: 0.4, end: 0.401, kind: "modified" }]);
|
|
408
|
+
// `update()` is the path that re-renders marks laid out before a height
|
|
409
|
+
// existed (a no-layout DOM reports 0, which leaves the "never sized"
|
|
410
|
+
// sentinel behind) — without it this test silently asserted the
|
|
411
|
+
// FRACTION floor and would have stayed green with no pixel floor at all.
|
|
412
|
+
m.handle.update();
|
|
413
|
+
// 2px of a 100px rail = 2%. The fraction floor alone (0.5%) is a
|
|
414
|
+
// half-pixel target — and since the rail is pointer-transparent the
|
|
415
|
+
// marks ARE the hit area, so a half-pixel mark is a half-pixel button.
|
|
416
|
+
expect(
|
|
417
|
+
parseFloat(m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!.style.height),
|
|
418
|
+
).toBeCloseTo(2, 5);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("re-sizes the marks when the rail is resized", () => {
|
|
422
|
+
const m = mountRailed();
|
|
423
|
+
const rail = railOfHeight(m, 100);
|
|
424
|
+
m.handle.setMarkers([{ start: 0.4, end: 0.401, kind: "modified" }]);
|
|
425
|
+
const height = () =>
|
|
426
|
+
parseFloat(m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!.style.height);
|
|
427
|
+
expect(height()).toBeCloseTo(2, 5);
|
|
428
|
+
// A taller rail makes the same 2px a smaller share of it.
|
|
429
|
+
Object.defineProperty(rail, "clientHeight", { configurable: true, get: () => 400 });
|
|
430
|
+
m.handle.update();
|
|
431
|
+
expect(height(), "2px of a 400px rail").toBeCloseTo(0.5, 5);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("clamps a reversed or out-of-range span instead of rendering it outside", () => {
|
|
435
|
+
const m = mountRailed();
|
|
436
|
+
m.handle.setMarkers([{ start: 0.8, end: 0.2, kind: "added" }]);
|
|
437
|
+
const mark = m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
|
|
438
|
+
expect(mark.style.top).toBe("20%");
|
|
439
|
+
expect(mark.style.height).toBe("60%");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("positions marks against the content, so scrolling does not move them", () => {
|
|
443
|
+
const m = mountRailed();
|
|
444
|
+
m.handle.setMarkers([{ start: 0.25, end: 0.3, kind: "added" }]);
|
|
445
|
+
const top = () => m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!.style.top;
|
|
446
|
+
expect(top()).toBe("25%");
|
|
447
|
+
stubGeometry(m.viewport, {
|
|
448
|
+
scrollHeight: 1000, clientHeight: 100, scrollTop: 500,
|
|
449
|
+
scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
|
|
450
|
+
});
|
|
451
|
+
m.viewport.dispatchEvent(new Event("scroll"));
|
|
452
|
+
m.handle.update();
|
|
453
|
+
expect(top(), "the rail maps the whole document, not the window").toBe("25%");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("removes the rail again when the marks are cleared", () => {
|
|
457
|
+
const m = mountRailed();
|
|
458
|
+
m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
|
|
459
|
+
expect(m.rail()).not.toBeNull();
|
|
460
|
+
m.handle.setMarkers([]);
|
|
461
|
+
expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("hides the rail while the content fits, and shows it again when it scrolls", () => {
|
|
465
|
+
const m = mountRailed();
|
|
466
|
+
m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
|
|
467
|
+
stubGeometry(m.viewport, {
|
|
468
|
+
scrollHeight: 100, clientHeight: 100, scrollTop: 0,
|
|
469
|
+
scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
|
|
470
|
+
});
|
|
471
|
+
m.handle.update();
|
|
472
|
+
expect(m.rail()!.style.display).toBe("none");
|
|
473
|
+
stubGeometry(m.viewport, {
|
|
474
|
+
scrollHeight: 1000, clientHeight: 100, scrollTop: 0,
|
|
475
|
+
scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
|
|
476
|
+
});
|
|
477
|
+
m.handle.update();
|
|
478
|
+
expect(m.rail()!.style.display).toBe("");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("never renders a rail on a horizontal-only scroll region", () => {
|
|
482
|
+
const m = mountViewport("horizontal");
|
|
483
|
+
stubGeometry(m.viewport, {
|
|
484
|
+
scrollHeight: 100, clientHeight: 100, scrollTop: 0,
|
|
485
|
+
scrollWidth: 1000, clientWidth: 100, scrollLeft: 0,
|
|
486
|
+
});
|
|
487
|
+
m.handle.update();
|
|
488
|
+
m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
|
|
489
|
+
expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("ignores non-finite mark bounds instead of parking them at NaN%", () => {
|
|
493
|
+
const m = mountRailed();
|
|
494
|
+
m.handle.setMarkers([
|
|
495
|
+
{ start: Number.NaN, end: Number.NaN, kind: "added" },
|
|
496
|
+
{ start: Number.POSITIVE_INFINITY, end: 0.9, kind: "modified" },
|
|
497
|
+
]);
|
|
498
|
+
const marks = m.rail()!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
|
|
499
|
+
// "NaN%" is rejected by CSSOM: the element keeps no inline top/height,
|
|
500
|
+
// collapses to zero height and the mark silently does not exist.
|
|
501
|
+
expect(marks[0].style.top).toBe("0%");
|
|
502
|
+
expect(marks[0].style.height).toBe("0.5%");
|
|
503
|
+
// Infinity is not "the end of the file" — non-finite clamps to 0.
|
|
504
|
+
expect(marks[1].style.top).toBe("0%");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("keeps a mark press from escaping to the page and shows the drag", () => {
|
|
508
|
+
const m = mountRailed();
|
|
509
|
+
m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
|
|
510
|
+
const rail = m.rail()!;
|
|
511
|
+
const mark = rail.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
|
|
512
|
+
// The rail rides inside modals/drawers that dismiss on an outside
|
|
513
|
+
// press; scrubbing it must not double as that press.
|
|
514
|
+
let escaped = 0;
|
|
515
|
+
const spy = () => { escaped += 1; };
|
|
516
|
+
document.body.addEventListener("mousedown", spy);
|
|
517
|
+
const down = new MouseEvent("mousedown", { bubbles: true, cancelable: true, clientY: 0 });
|
|
518
|
+
mark.dispatchEvent(down);
|
|
519
|
+
expect(down.defaultPrevented, "the press is consumed").toBe(true);
|
|
520
|
+
expect(escaped, "the press never reaches the page").toBe(0);
|
|
521
|
+
expect(rail.classList.contains("is-dragging"), "the rail shows the drag").toBe(true);
|
|
522
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
523
|
+
expect(rail.classList.contains("is-dragging"), "and clears it on release").toBe(false);
|
|
524
|
+
document.body.removeEventListener("mousedown", spy);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("jumps to a mark when the mark is pressed", () => {
|
|
528
|
+
const m = mountRailed();
|
|
529
|
+
m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
|
|
530
|
+
const mark = m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
|
|
531
|
+
mark.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 50 }));
|
|
532
|
+
// Halfway down a 100px rail over a 900px scroll range.
|
|
533
|
+
expect(m.viewport.scrollTop).toBe(450);
|
|
534
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("scrubs while the rail is dragged and stops on mouseup", () => {
|
|
538
|
+
const m = mountRailed();
|
|
539
|
+
m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
|
|
540
|
+
const mark = m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
|
|
541
|
+
mark.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 0 }));
|
|
542
|
+
expect(m.viewport.scrollTop).toBe(0);
|
|
543
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientY: 100 }));
|
|
544
|
+
expect(m.viewport.scrollTop, "dragged to the bottom").toBe(900);
|
|
545
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
546
|
+
// The document listeners are gone: a later move must not scroll.
|
|
547
|
+
m.viewport.scrollTop = 0;
|
|
548
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientY: 50 }));
|
|
549
|
+
expect(m.viewport.scrollTop, "no scrub after mouseup").toBe(0);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("detach removes the rail and its document listeners", () => {
|
|
553
|
+
const m = mountRailed();
|
|
554
|
+
m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
|
|
555
|
+
const mark = m.rail()!.querySelector<HTMLElement>(".hk-scrollbar-marker")!;
|
|
556
|
+
mark.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 0 }));
|
|
557
|
+
m.handle.detach();
|
|
558
|
+
handles.splice(handles.indexOf(m.handle), 1);
|
|
559
|
+
expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
|
|
560
|
+
m.viewport.scrollTop = 0;
|
|
561
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientY: 100 }));
|
|
562
|
+
expect(m.viewport.scrollTop, "detach dropped the drag listener").toBe(0);
|
|
563
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("ignores setMarkers after detach", () => {
|
|
567
|
+
const m = mountRailed();
|
|
568
|
+
m.handle.detach();
|
|
569
|
+
handles.splice(handles.indexOf(m.handle), 1);
|
|
570
|
+
expect(() => m.handle.setMarkers([{ start: 0, end: 1, kind: "added" }])).not.toThrow();
|
|
571
|
+
expect(m.wrapper.querySelector(".hk-scrollbar-markers")).toBeNull();
|
|
572
|
+
});
|
|
573
|
+
});
|
|
@@ -30,6 +30,57 @@ import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
|
30
30
|
/** Which axes get an overlay track. Defaults to "vertical". */
|
|
31
31
|
export type OverlayScrollbarAxis = "vertical" | "horizontal" | "both";
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* One change mark on the vertical rail beside the track — the "where in
|
|
35
|
+
* the document is the interesting part" affordance a diff, a search
|
|
36
|
+
* result set or a lint run wants next to its scrollbar (VS Code's
|
|
37
|
+
* overview ruler).
|
|
38
|
+
*
|
|
39
|
+
* Positions are fractions of the FULL content extent, never of the
|
|
40
|
+
* visible window: line 500 of a 1000-line file sits at 0.5 while the
|
|
41
|
+
* user scrolls, and the rail keeps representing the whole document while
|
|
42
|
+
* the thumb represents the window. The engine stays domain-agnostic —
|
|
43
|
+
* the consumer divides its own line numbers, byte offsets or match
|
|
44
|
+
* indices by its own total.
|
|
45
|
+
*/
|
|
46
|
+
export interface OverlayScrollbarMarker {
|
|
47
|
+
/** Mark start in [0, 1] along the content. */
|
|
48
|
+
start: number;
|
|
49
|
+
/** Mark end in [0, 1]; clamped to at least `start`. */
|
|
50
|
+
end: number;
|
|
51
|
+
/** Semantic tag mirrored onto `data-kind`, so the consumer's theme owns
|
|
52
|
+
* the palette (this engine ships no colours of its own). */
|
|
53
|
+
kind?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shortest a mark may render, as a fraction of the rail. A one-line edit
|
|
57
|
+
* in a long file is ~0.1% of the content — without a floor it would be a
|
|
58
|
+
* sub-pixel sliver, i.e. invisible exactly when the reader needs it. */
|
|
59
|
+
const MARKER_MIN_FRACTION = 0.005;
|
|
60
|
+
|
|
61
|
+
/** …and the same floor in CSS pixels, which is what actually decides
|
|
62
|
+
* whether a mark can be SEEN and HIT on a short rail: at 1000px tall the
|
|
63
|
+
* fraction floor is 5px, at 100px it is 0.5px. It lives in the engine —
|
|
64
|
+
* not as a stylesheet `min-height` — for two reasons: the floor has to
|
|
65
|
+
* scale with the rail, and the end-of-rail inset below has to be computed
|
|
66
|
+
* from the SAME number, which CSS cannot express. */
|
|
67
|
+
const MARKER_MIN_PX = 2;
|
|
68
|
+
|
|
69
|
+
/** Clamp to [0, 1]; non-finite input is the caller's bug, but the rail
|
|
70
|
+
* must never render a mark at a meaningless position, so it becomes 0. */
|
|
71
|
+
function clamp01(n: number): number {
|
|
72
|
+
if (!Number.isFinite(n)) return 0;
|
|
73
|
+
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A rail offset as a CSS percentage with the float noise trimmed: the
|
|
77
|
+
* span `0.8 − 0.2` is 0.6000000000000001, and a position is not a
|
|
78
|
+
* measurement — "60.00000000000001%" is noise in the DOM, in snapshots
|
|
79
|
+
* and in any consumer assertion. */
|
|
80
|
+
function pct(fraction: number): string {
|
|
81
|
+
return `${Number((fraction * 100).toFixed(4))}%`;
|
|
82
|
+
}
|
|
83
|
+
|
|
33
84
|
export interface OverlayScrollbarOptions {
|
|
34
85
|
axis?: OverlayScrollbarAxis;
|
|
35
86
|
/** Element the tracks are appended to — their positioning context.
|
|
@@ -42,11 +93,19 @@ export interface OverlayScrollbarOptions {
|
|
|
42
93
|
* (see HkDrawer/HkSidebar/HkTable for the pattern). Never rely on
|
|
43
94
|
* the guard to promote `body`. */
|
|
44
95
|
host?: HTMLElement;
|
|
96
|
+
/** Change marks drawn on a rail immediately inboard of the vertical
|
|
97
|
+
* track. Vertical axis only — a horizontal rail has no meaningful
|
|
98
|
+
* "position in the document". Omitted or empty creates no rail at all,
|
|
99
|
+
* so consumers that never pass markers keep the exact chrome they had. */
|
|
100
|
+
markers?: OverlayScrollbarMarker[];
|
|
45
101
|
}
|
|
46
102
|
|
|
47
103
|
export interface OverlayScrollbarHandle {
|
|
48
104
|
/** Re-read the viewport's scroll geometry and reposition the thumbs. */
|
|
49
105
|
update(): void;
|
|
106
|
+
/** Replace the rail's marks. Passing an empty list removes the rail
|
|
107
|
+
* again. No-op when the handle was attached against no host. */
|
|
108
|
+
setMarkers(markers: OverlayScrollbarMarker[]): void;
|
|
50
109
|
/** Remove the tracks and every listener/observer; restore any
|
|
51
110
|
* inline positioning the guard applied. Safe to call twice. */
|
|
52
111
|
detach(): void;
|
|
@@ -117,7 +176,7 @@ export function attachOverlayScrollbars(
|
|
|
117
176
|
const axis = opts.axis ?? "vertical";
|
|
118
177
|
const parent = opts.host ?? viewport.parentElement;
|
|
119
178
|
if (!parent) {
|
|
120
|
-
return { update() {}, detach() {} };
|
|
179
|
+
return { update() {}, setMarkers() {}, detach() {} };
|
|
121
180
|
}
|
|
122
181
|
|
|
123
182
|
const wantV = axis !== "horizontal";
|
|
@@ -156,14 +215,121 @@ export function attachOverlayScrollbars(
|
|
|
156
215
|
let ro: ResizeObserver | null = null;
|
|
157
216
|
let detached = false;
|
|
158
217
|
|
|
218
|
+
// ── Change-mark rail (vertical only) ─────────────────────────────────
|
|
219
|
+
// Created lazily on the first non-empty marker list: consumers that
|
|
220
|
+
// never pass markers keep byte-identical chrome, and the rail's own
|
|
221
|
+
// listeners exist only while the rail does.
|
|
222
|
+
let rail: HTMLElement | null = null;
|
|
223
|
+
let markers: OverlayScrollbarMarker[] = [];
|
|
224
|
+
let railDragging = false;
|
|
225
|
+
/** Rail height the marks were last sized against — the pixel floor is a
|
|
226
|
+
* function of it, so a resize has to re-render them. */
|
|
227
|
+
let renderedRailHeight = 0;
|
|
228
|
+
|
|
229
|
+
/** Scroll so the rail position under `clientY` lands in the viewport —
|
|
230
|
+
* the same fraction → scrollTop mapping the track's own click uses, so
|
|
231
|
+
* a mark and the track at the same height agree. */
|
|
232
|
+
function railScrollTo(clientY: number) {
|
|
233
|
+
if (!rail) return;
|
|
234
|
+
const rect = rail.getBoundingClientRect();
|
|
235
|
+
if (rect.height <= 0) return;
|
|
236
|
+
const ratio = clamp01((clientY - rect.top) / rect.height);
|
|
237
|
+
const { scrollSize, clientSize } = axisMetrics(viewport, false);
|
|
238
|
+
viewport.scrollTop = ratio * Math.max(scrollSize - clientSize, 0);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function onRailMove(e: MouseEvent) {
|
|
242
|
+
if (railDragging) railScrollTo(e.clientY);
|
|
243
|
+
}
|
|
244
|
+
function onRailUp() {
|
|
245
|
+
railDragging = false;
|
|
246
|
+
rail?.classList.remove("is-dragging");
|
|
247
|
+
document.removeEventListener("mousemove", onRailMove);
|
|
248
|
+
document.removeEventListener("mouseup", onRailUp);
|
|
249
|
+
}
|
|
250
|
+
function onRailDown(e: MouseEvent) {
|
|
251
|
+
e.preventDefault();
|
|
252
|
+
// The rail sits inside a modal/panel that may itself dismiss on an
|
|
253
|
+
// outside press — scrubbing the rail must not double as that press.
|
|
254
|
+
e.stopPropagation();
|
|
255
|
+
railDragging = true;
|
|
256
|
+
rail?.classList.add("is-dragging");
|
|
257
|
+
railScrollTo(e.clientY);
|
|
258
|
+
document.addEventListener("mousemove", onRailMove);
|
|
259
|
+
document.addEventListener("mouseup", onRailUp);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function renderMarkers() {
|
|
263
|
+
if (!rail) return;
|
|
264
|
+
rail.replaceChildren();
|
|
265
|
+
// Measured, not assumed: happy-dom and a `display:none` rail both
|
|
266
|
+
// report 0, and the fraction floor is the honest answer there.
|
|
267
|
+
const railH = rail.clientHeight;
|
|
268
|
+
renderedRailHeight = railH;
|
|
269
|
+
const floor = railH > 0
|
|
270
|
+
? Math.max(MARKER_MIN_FRACTION, MARKER_MIN_PX / railH)
|
|
271
|
+
: MARKER_MIN_FRACTION;
|
|
272
|
+
for (const m of markers) {
|
|
273
|
+
const size = Math.max(
|
|
274
|
+
Math.abs(clamp01(m.end) - clamp01(m.start)),
|
|
275
|
+
floor,
|
|
276
|
+
);
|
|
277
|
+
// Keep the whole mark inside the rail: a mark at the very end would
|
|
278
|
+
// otherwise overflow the bottom, get clipped, and read as absent.
|
|
279
|
+
const top = Math.max(
|
|
280
|
+
Math.min(clamp01(m.start), clamp01(m.end), 1 - size),
|
|
281
|
+
0,
|
|
282
|
+
);
|
|
283
|
+
const el = document.createElement("div");
|
|
284
|
+
el.className = "hk-scrollbar-marker";
|
|
285
|
+
if (m.kind) el.setAttribute("data-kind", m.kind);
|
|
286
|
+
el.style.top = pct(top);
|
|
287
|
+
el.style.height = pct(size);
|
|
288
|
+
rail.appendChild(el);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// An arrow const, not a function declaration: the guard above narrows
|
|
293
|
+
// `parent` to non-null, and only a closure created AFTER that narrowing
|
|
294
|
+
// keeps it (a hoisted declaration may run at any time, so TS widens the
|
|
295
|
+
// capture back to `HTMLElement | null`).
|
|
296
|
+
const setMarkers = (next: OverlayScrollbarMarker[]) => {
|
|
297
|
+
if (detached) return;
|
|
298
|
+
markers = Array.isArray(next) ? next : [];
|
|
299
|
+
if (markers.length === 0) {
|
|
300
|
+
// No marks = no rail, i.e. exactly the pre-marker chrome.
|
|
301
|
+
if (rail) {
|
|
302
|
+
rail.removeEventListener("mousedown", onRailDown);
|
|
303
|
+
rail.remove();
|
|
304
|
+
rail = null;
|
|
305
|
+
}
|
|
306
|
+
onRailUp();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!wantV) return;
|
|
310
|
+
if (!rail) {
|
|
311
|
+
rail = document.createElement("div");
|
|
312
|
+
rail.className = "hk-scrollbar-markers";
|
|
313
|
+
rail.addEventListener("mousedown", onRailDown);
|
|
314
|
+
parent.appendChild(rail);
|
|
315
|
+
}
|
|
316
|
+
renderMarkers();
|
|
317
|
+
// The rail's visibility follows the track's fit test.
|
|
318
|
+
update();
|
|
319
|
+
};
|
|
320
|
+
|
|
159
321
|
function updateAxis(s: AxisState, horizontal: boolean) {
|
|
160
322
|
const { scrollSize, clientSize, scrollPos } = axisMetrics(viewport, horizontal);
|
|
161
323
|
if (scrollSize <= clientSize) {
|
|
162
|
-
// Content fits — no bar at all.
|
|
324
|
+
// Content fits — no bar at all. The rail follows the track: marks on
|
|
325
|
+
// a region that cannot scroll would claim changes the reader can
|
|
326
|
+
// never reach.
|
|
163
327
|
s.track.style.display = "none";
|
|
328
|
+
if (!horizontal && rail) rail.style.display = "none";
|
|
164
329
|
return;
|
|
165
330
|
}
|
|
166
331
|
s.track.style.display = "";
|
|
332
|
+
if (!horizontal && rail) rail.style.display = "";
|
|
167
333
|
const trackSize = horizontal ? s.track.clientWidth : s.track.clientHeight;
|
|
168
334
|
const ratio = clientSize / scrollSize;
|
|
169
335
|
const thumbSize = Math.max(ratio * trackSize, 20);
|
|
@@ -188,6 +354,15 @@ export function attachOverlayScrollbars(
|
|
|
188
354
|
function update() {
|
|
189
355
|
if (detached) return;
|
|
190
356
|
for (const s of states) updateAxis(s, s.horizontal);
|
|
357
|
+
// The marks' pixel floor is a fraction OF the rail, so a height change
|
|
358
|
+
// invalidates the sizes they were rendered with. A hidden rail reports
|
|
359
|
+
// 0 and is skipped here; `renderMarkers` leaves that 0 behind as its
|
|
360
|
+
// "never sized against a real height" sentinel, so the first visible
|
|
361
|
+
// update re-renders even when the height matches the last one used.
|
|
362
|
+
const railH = rail?.clientHeight ?? 0;
|
|
363
|
+
if (rail && markers.length > 0 && railH > 0 && railH !== renderedRailHeight) {
|
|
364
|
+
renderMarkers();
|
|
365
|
+
}
|
|
191
366
|
}
|
|
192
367
|
|
|
193
368
|
function scheduleUpdate() {
|
|
@@ -292,9 +467,11 @@ export function attachOverlayScrollbars(
|
|
|
292
467
|
ro = new ResizeObserver(scheduleUpdate);
|
|
293
468
|
ro.observe(viewport);
|
|
294
469
|
update();
|
|
470
|
+
if (opts.markers?.length) setMarkers(opts.markers);
|
|
295
471
|
|
|
296
472
|
return {
|
|
297
473
|
update,
|
|
474
|
+
setMarkers,
|
|
298
475
|
detach() {
|
|
299
476
|
if (detached) return;
|
|
300
477
|
detached = true;
|
|
@@ -303,6 +480,12 @@ export function attachOverlayScrollbars(
|
|
|
303
480
|
ro = null;
|
|
304
481
|
scheduled?.disconnect();
|
|
305
482
|
scheduled = null;
|
|
483
|
+
if (rail) {
|
|
484
|
+
rail.removeEventListener("mousedown", onRailDown);
|
|
485
|
+
rail.remove();
|
|
486
|
+
rail = null;
|
|
487
|
+
}
|
|
488
|
+
onRailUp();
|
|
306
489
|
for (const { state, handlers } of axisBindings) {
|
|
307
490
|
state.thumb.removeEventListener("mousedown", handlers.onDown);
|
|
308
491
|
state.track.removeEventListener("click", handlers.onTrackClick);
|
package/src/index.ts
CHANGED
|
@@ -420,6 +420,9 @@ export type { PasswordValidationResult, PasswordLevel, PasswordStrengthEvaluator
|
|
|
420
420
|
export { FOCUSABLE_SELECTOR, getFocusableElements, focusFirst, trapFocus, scrollToElement } from "./utils/dom";
|
|
421
421
|
export { useApproachEnd } from "./composables/useApproachEnd";
|
|
422
422
|
export type { ApproachEndOptions, ApproachEndHandle } from "./composables/useApproachEnd";
|
|
423
|
+
// The change-mark rail's public vocabulary: HkScrollContainer's `markers`
|
|
424
|
+
// prop is typed with it, so consumers can name what they pass.
|
|
425
|
+
export type { OverlayScrollbarMarker } from "./composables/useOverlayScrollbar";
|
|
423
426
|
export { useDeferredTransition } from "./composables/useDeferredTransition";
|
|
424
427
|
export { useSurfaceTransition } from "./composables/useSurfaceTransition";
|
|
425
428
|
export { useSizeMorph } from "./composables/useSizeMorph";
|
|
@@ -83,3 +83,79 @@
|
|
|
83
83
|
width: 100%;
|
|
84
84
|
min-height: 20px;
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/* ── Change-mark rail (VS Code's overview ruler) ──────────────────────
|
|
88
|
+
Rendered only when a consumer passes `markers` to
|
|
89
|
+
attachOverlayScrollbars (HkScrollContainer's `markers` prop), so every
|
|
90
|
+
existing consumer's chrome above is unchanged. It sits immediately
|
|
91
|
+
inboard of the vertical track — the two together read as one thick
|
|
92
|
+
marked scrollbar — and carries no chrome of its own beyond a faint
|
|
93
|
+
groove: the marks are the content. */
|
|
94
|
+
.hk-scrollbar-markers {
|
|
95
|
+
position: absolute;
|
|
96
|
+
z-index: 11;
|
|
97
|
+
top: 4px;
|
|
98
|
+
bottom: 4px;
|
|
99
|
+
/* Vertical track: right 2px + width 6px → occupies 2–8px; a 2px gutter
|
|
100
|
+
puts the rail at 10px. The contract test pins this non-overlap. */
|
|
101
|
+
right: 10px;
|
|
102
|
+
width: 8px;
|
|
103
|
+
border-radius: 4px;
|
|
104
|
+
background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 18%, transparent);
|
|
105
|
+
overflow: hidden;
|
|
106
|
+
transition: background var(--hi-duration-fast, 0.15s) ease;
|
|
107
|
+
|
|
108
|
+
/* Pointer-transparent, exactly like the track above and for the same
|
|
109
|
+
reason: an overlay hugging the region's edge must never steal a press
|
|
110
|
+
meant for the content beneath it — and this rail spans the WHOLE host,
|
|
111
|
+
docks included, so it sits over right-aligned dock controls too. Only
|
|
112
|
+
the MARKS take input (see below): they are the targets that mean
|
|
113
|
+
something, and they are the rail's entire content. */
|
|
114
|
+
pointer-events: none;
|
|
115
|
+
|
|
116
|
+
&.is-dragging {
|
|
117
|
+
background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 32%, transparent);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* One mark. `data-kind` is whatever tag the consumer passed; the three
|
|
122
|
+
diff kinds get the diff palette, anything else the neutral muted one.
|
|
123
|
+
The fallbacks mirror the thumb's: a themeless standalone render must
|
|
124
|
+
still show marks rather than an invalid-at-computed-value-time
|
|
125
|
+
transparent.
|
|
126
|
+
|
|
127
|
+
Height comes ENTIRELY from the engine, which floors a hairline mark in
|
|
128
|
+
both fractions and pixels and sizes the end-of-rail inset against that
|
|
129
|
+
same number. A `min-height` here is therefore redundant — the engine
|
|
130
|
+
already guarantees 2px — and, because the rail clips, would win over
|
|
131
|
+
the inset on the occasions it did bite. */
|
|
132
|
+
.hk-scrollbar-marker {
|
|
133
|
+
position: absolute;
|
|
134
|
+
left: 0;
|
|
135
|
+
width: 100%;
|
|
136
|
+
border-radius: 1px;
|
|
137
|
+
background: var(--hi-color-muted, rgb(118 122 128));
|
|
138
|
+
/* The rail is pointer-transparent; the marks are the hit area. */
|
|
139
|
+
pointer-events: auto;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
|
|
142
|
+
&:hover {
|
|
143
|
+
/* Negative offset: the rail clips its children, and a plain outline
|
|
144
|
+
lost both side strokes to that clip (only the top/bottom lines
|
|
145
|
+
survived) — drawn inside, the ring stays whole. */
|
|
146
|
+
outline: 1px solid color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 60%, transparent);
|
|
147
|
+
outline-offset: -1px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
&[data-kind="added"] {
|
|
151
|
+
background: var(--hi-color-success, #0eb840);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
&[data-kind="deleted"] {
|
|
155
|
+
background: var(--hi-color-danger, #ff4c00);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
&[data-kind="modified"] {
|
|
159
|
+
background: var(--hi-color-warning, #eed677);
|
|
160
|
+
}
|
|
161
|
+
}
|