Skip to content

Commit 0ac3794

Browse files
Highlight the last canvas TOC section when scrolled to the bottom
1 parent 52255cd commit 0ac3794

2 files changed

Lines changed: 65 additions & 2 deletions

File tree

web-common/src/features/canvas/toc/scrollspy.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,34 @@ describe("createScrollSpy", () => {
9191
expect(get(spy.activeId)).toBe("b");
9292
});
9393

94+
it("activates the last entry when the container is scrolled to the bottom", () => {
95+
Object.defineProperty(root, "clientHeight", {
96+
value: 500,
97+
configurable: true,
98+
});
99+
Object.defineProperty(root, "scrollHeight", {
100+
value: 1000,
101+
configurable: true,
102+
});
103+
104+
const spy = createScrollSpy(root);
105+
// A middle heading is in the band, but the last one can never reach it.
106+
const entries = [entry("a", -100), entry("b", 20), entry("c", 480)];
107+
spy.setEntries(entries);
108+
fireIntersections({ b: true });
109+
expect(get(spy.activeId)).toBe("b");
110+
111+
// Scroll to the bottom: the last entry wins.
112+
root.scrollTop = 500;
113+
root.dispatchEvent(new Event("scroll"));
114+
expect(get(spy.activeId)).toBe("c");
115+
116+
// Scroll back up: normal tracking resumes.
117+
root.scrollTop = 0;
118+
root.dispatchEvent(new Event("scroll"));
119+
expect(get(spy.activeId)).toBe("b");
120+
});
121+
94122
it("setActive overrides the computed section immediately", () => {
95123
const spy = createScrollSpy(root);
96124
spy.setEntries([entry("a", -10), entry("b", 50)]);

web-common/src/features/canvas/toc/scrollspy.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export type ScrollSpy = {
2525
* The `rootMargin` shrinks the observed viewport to a band near the top of the container, so a
2626
* heading counts as "in view" once it reaches that band. The active entry is the topmost heading
2727
* currently in the band; when the band is empty (scrolled between two headings), the last heading
28-
* above the band stays active, so there is always exactly one active item.
28+
* above the band stays active, so there is always exactly one active item. When the container is
29+
* scrolled to the bottom, the last entry is activated, since trailing sections can't reach the band.
2930
*/
3031
export function createScrollSpy(root: HTMLElement): ScrollSpy {
3132
const activeId = writable<string | null>(null);
@@ -35,12 +36,29 @@ export function createScrollSpy(root: HTMLElement): ScrollSpy {
3536
// While locked (during a click-to-scroll), observer updates are ignored so the active item stays
3637
// pinned to the target instead of walking through every section the scroll passes.
3738
let locked = false;
39+
// Whether the container is scrolled to its bottom. A trailing section can never reach the top
40+
// activation band (the container bottoms out first), so at the bottom we activate the last entry.
41+
let atBottom = false;
3842
// Tracks per-id intersection state so we can pick the topmost visible heading on each change.
3943
const intersecting = new Map<string, boolean>();
4044

45+
function isAtBottom(): boolean {
46+
// Only when the container is actually scrollable and scrolled to (near) its end.
47+
return (
48+
root.scrollHeight > root.clientHeight &&
49+
root.scrollTop + root.clientHeight >= root.scrollHeight - 2
50+
);
51+
}
52+
4153
function recompute() {
4254
if (locked) return;
4355

56+
// At the bottom, highlight the last section (it can't scroll up into the activation band).
57+
if (atBottom && entries.length > 0) {
58+
activeId.set(entries[entries.length - 1].id);
59+
return;
60+
}
61+
4462
// Prefer the topmost heading currently in the activation band.
4563
const visible = entries.filter((e) => intersecting.get(e.id));
4664
if (visible.length > 0) {
@@ -66,6 +84,8 @@ export function createScrollSpy(root: HTMLElement): ScrollSpy {
6684
entries = next;
6785
intersecting.clear();
6886
observer?.disconnect();
87+
// Content height changed, so re-check bottom state before recomputing.
88+
atBottom = isAtBottom();
6989

7090
if (typeof IntersectionObserver === "undefined" || next.length === 0) {
7191
activeId.set(next[0]?.id ?? null);
@@ -88,6 +108,18 @@ export function createScrollSpy(root: HTMLElement): ScrollSpy {
88108
recompute();
89109
}
90110

111+
// A lightweight passive listener that only tracks entering/leaving the bottom (an O(1) check) and
112+
// recomputes on that transition. The IntersectionObserver still drives normal tracking; this does
113+
// not recompute on every scroll frame.
114+
function onScroll() {
115+
const next = isAtBottom();
116+
if (next !== atBottom) {
117+
atBottom = next;
118+
recompute();
119+
}
120+
}
121+
root.addEventListener("scroll", onScroll, { passive: true });
122+
91123
return {
92124
activeId,
93125
setEntries,
@@ -100,6 +132,9 @@ export function createScrollSpy(root: HTMLElement): ScrollSpy {
100132
locked = false;
101133
recompute();
102134
},
103-
destroy: () => observer?.disconnect(),
135+
destroy: () => {
136+
root.removeEventListener("scroll", onScroll);
137+
observer?.disconnect();
138+
},
104139
};
105140
}

0 commit comments

Comments
 (0)