Skip to content

Commit be836fb

Browse files
authored
tweak: right toc (#646)
* feat: implement scroll highlighting for active TOC headings in RightNav component - Added state management to track the active heading based on scroll position. - Introduced an Intersection Observer to dynamically update the active heading as the user scrolls. - Enhanced the generateToc function to highlight the active heading in the table of contents, improving navigation experience. * feat: add conditional TOC generation and filtering based on CustomContent conditions - Introduced createConditionalToc function to generate a table of contents that respects CustomContent conditions. - Implemented filterRightToc function to filter TOC items based on current context (pageType, cloudPlan, language). - Updated DocTemplate and RightNav components to utilize the new TOC filtering logic, enhancing navigation experience. - Added condition property to TableOfContent interface for better handling of conditional rendering.
1 parent 6dd2544 commit be836fb

File tree

6 files changed

+382
-16
lines changed

6 files changed

+382
-16
lines changed

gatsby-node.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ const {
99
create404,
1010
} = require("./gatsby/create-pages");
1111
const { createExtraType } = require("./gatsby/create-types");
12+
const {
13+
createConditionalToc,
14+
} = require("./gatsby/plugin/conditional-toc/conditional-toc");
1215

1316
exports.createPages = async ({ graphql, actions }) => {
1417
await createDocHome({ graphql, actions });
@@ -22,4 +25,7 @@ exports.createPages = async ({ graphql, actions }) => {
2225
create404({ actions });
2326
};
2427

25-
exports.createSchemaCustomization = createExtraType;
28+
exports.createSchemaCustomization = (options) => {
29+
createExtraType(options);
30+
createConditionalToc(options);
31+
};
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
const visit = require(`unist-util-visit`);
2+
const genMDX = require("gatsby-plugin-mdx/utils/gen-mdx");
3+
const defaultOptions = require("gatsby-plugin-mdx/utils/default-options");
4+
const generateTOC = require(`mdast-util-toc`);
5+
const GithubSlugger = require(`github-slugger`);
6+
7+
export const createConditionalToc = ({
8+
store,
9+
pathPrefix,
10+
getNode,
11+
getNodes,
12+
cache,
13+
reporter,
14+
actions,
15+
schema,
16+
...helpers
17+
}) => {
18+
const options = defaultOptions({});
19+
const { createTypes } = actions;
20+
const pendingPromises = new WeakMap();
21+
const processMDX = ({ node }) => {
22+
let promise = pendingPromises.get(node);
23+
if (!promise) {
24+
promise = genMDX({
25+
node,
26+
options,
27+
store,
28+
pathPrefix,
29+
getNode,
30+
getNodes,
31+
cache,
32+
reporter,
33+
actions,
34+
schema,
35+
...helpers,
36+
});
37+
pendingPromises.set(node, promise);
38+
promise.then(() => {
39+
pendingPromises.delete(node);
40+
});
41+
}
42+
return promise;
43+
};
44+
45+
const tocType = schema.buildObjectType({
46+
name: `Mdx`,
47+
fields: {
48+
toc: {
49+
type: `JSON`,
50+
args: {
51+
maxDepth: {
52+
type: `Int`,
53+
default: 6,
54+
},
55+
},
56+
async resolve(mdxNode, { maxDepth }) {
57+
const { mdast } = await processMDX({ node: mdxNode });
58+
const toc = generateTOC(mdast, { maxDepth });
59+
60+
// Build a map of heading IDs to their CustomContent conditions
61+
const headingConditions = {};
62+
const slugger = new GithubSlugger();
63+
const conditionStack = [];
64+
65+
// Helper function to extract text from heading node
66+
const getHeadingText = (node) => {
67+
let text = "";
68+
visit(node, (child) => {
69+
if (child.type === "text") {
70+
text += child.value;
71+
} else if (child.type === "inlineCode") {
72+
text += child.value;
73+
}
74+
});
75+
return text;
76+
};
77+
78+
// Helper function to parse CustomContent JSX attributes
79+
const parseCustomContentAttributes = (jsxString) => {
80+
const attributes = {};
81+
82+
// Match platform="value" or platform='value'
83+
const platformMatch = jsxString.match(/platform=["']([^"']+)["']/);
84+
if (platformMatch) {
85+
attributes.platform = platformMatch[1];
86+
}
87+
88+
// Match plan="value" or plan='value'
89+
const planMatch = jsxString.match(/plan=["']([^"']+)["']/);
90+
if (planMatch) {
91+
attributes.plan = planMatch[1];
92+
}
93+
94+
// Match language="value" or language='value'
95+
const languageMatch = jsxString.match(/language=["']([^"']+)["']/);
96+
if (languageMatch) {
97+
attributes.language = languageMatch[1];
98+
}
99+
100+
return attributes;
101+
};
102+
103+
// Traverse the mdast tree to track CustomContent blocks
104+
let insideCustomContent = false;
105+
let currentConditionAttrs = null;
106+
107+
visit(mdast, (node, index, parent) => {
108+
// Check for opening CustomContent tag
109+
if (
110+
node.type === "jsx" &&
111+
node.value &&
112+
node.value.includes("<CustomContent")
113+
) {
114+
const attributes = parseCustomContentAttributes(node.value);
115+
conditionStack.push(attributes);
116+
insideCustomContent = true;
117+
currentConditionAttrs = attributes;
118+
}
119+
// Check for closing CustomContent tag
120+
else if (
121+
node.type === "jsx" &&
122+
node.value &&
123+
node.value.includes("</CustomContent>")
124+
) {
125+
if (conditionStack.length > 0) {
126+
conditionStack.pop();
127+
}
128+
if (conditionStack.length === 0) {
129+
insideCustomContent = false;
130+
currentConditionAttrs = null;
131+
} else {
132+
currentConditionAttrs =
133+
conditionStack[conditionStack.length - 1];
134+
}
135+
}
136+
// Track headings inside CustomContent blocks
137+
else if (node.type === "heading" && conditionStack.length > 0) {
138+
const headingText = getHeadingText(node);
139+
const headingId = slugger.slug(headingText);
140+
141+
headingConditions[headingId] = {
142+
...conditionStack[conditionStack.length - 1],
143+
};
144+
}
145+
});
146+
147+
// Reset slugger for potential reuse
148+
slugger.reset();
149+
150+
const items = getItems(toc.map, {}, headingConditions);
151+
return items;
152+
},
153+
},
154+
},
155+
interfaces: [`Node`],
156+
extensions: {
157+
childOf: {
158+
mimeTypes: options.mediaTypes,
159+
},
160+
},
161+
});
162+
163+
createTypes([tocType]);
164+
};
165+
166+
// parse mdast-utils-toc object to JSON object:
167+
//
168+
// {"items": [{
169+
// "url": "#something-if",
170+
// "title": "Something if",
171+
// "condition": { "platform": "a", "plan": "b", "language": "c" },
172+
// "items": [
173+
// {
174+
// "url": "#something-else",
175+
// "title": "Something else"
176+
// },
177+
// {
178+
// "url": "#something-elsefi",
179+
// "title": "Something elsefi"
180+
// }
181+
// ]},
182+
// {
183+
// "url": "#something-iffi",
184+
// "title": "Something iffi"
185+
// }]}
186+
//
187+
function getItems(node, current, headingConditions = {}) {
188+
if (!node) {
189+
return {};
190+
} else if (node.type === `paragraph`) {
191+
visit(node, (item) => {
192+
if (item.type === `link`) {
193+
current.url = item.url;
194+
// Extract heading ID from URL and attach condition if exists
195+
const headingId = item.url.replace(/^#/, "");
196+
if (headingConditions[headingId]) {
197+
current.condition = headingConditions[headingId];
198+
}
199+
}
200+
if (item.type === `text`) {
201+
current.title = item.value;
202+
}
203+
});
204+
return current;
205+
} else {
206+
if (node.type === `list`) {
207+
current.items = node.children.map((i) =>
208+
getItems(i, {}, headingConditions)
209+
);
210+
return current;
211+
} else if (node.type === `listItem`) {
212+
const heading = getItems(node.children[0], {}, headingConditions);
213+
if (node.children.length > 1) {
214+
getItems(node.children[1], heading, headingConditions);
215+
}
216+
return heading;
217+
}
218+
}
219+
return {};
220+
}

src/components/Layout/Navigation/RightNav.tsx

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
removeHtmlTag,
2525
} from "shared/utils";
2626
import { sliceVersionMark } from "shared/utils/anchor";
27-
import { GTMEvent, gtmTrack } from "shared/utils/gtm";
2827
import { getPageType } from "shared/utils";
2928

3029
interface RightNavProps {
@@ -70,6 +69,65 @@ export default function RightNav(props: RightNavProps) {
7069
pathname = pathname.slice(0, -1); // unify client and ssr
7170
}
7271

72+
// Track active heading for scroll highlighting
73+
const [activeId, setActiveId] = React.useState<string>("");
74+
75+
React.useEffect(() => {
76+
// Collect all heading IDs from the TOC
77+
const headingIds: string[] = [];
78+
const collectIds = (items: TableOfContent[]) => {
79+
items.forEach((item) => {
80+
if (item.url) {
81+
const id = item.url.replace(/^#/, "");
82+
if (id) {
83+
headingIds.push(id);
84+
}
85+
}
86+
if (item.items) {
87+
collectIds(item.items);
88+
}
89+
});
90+
};
91+
collectIds(toc);
92+
93+
if (headingIds.length === 0) return;
94+
95+
// Create an intersection observer
96+
const observer = new IntersectionObserver(
97+
(entries) => {
98+
entries.forEach((entry) => {
99+
if (entry.isIntersecting) {
100+
setActiveId(entry.target.id);
101+
}
102+
});
103+
},
104+
{
105+
rootMargin: "-80px 0px -80% 0px",
106+
threshold: 0,
107+
}
108+
);
109+
110+
setTimeout(() => {
111+
// Observe all heading elements
112+
headingIds.forEach((id) => {
113+
const element = document.getElementById(id);
114+
if (element) {
115+
observer.observe(element);
116+
}
117+
});
118+
}, 1000);
119+
120+
// Cleanup
121+
return () => {
122+
headingIds.forEach((id) => {
123+
const element = document.getElementById(id);
124+
if (element) {
125+
observer.unobserve(element);
126+
}
127+
});
128+
};
129+
}, [toc]);
130+
73131
return (
74132
<>
75133
<Box
@@ -150,14 +208,14 @@ export default function RightNav(props: RightNavProps) {
150208
>
151209
<Trans i18nKey="doc.toc" />
152210
</Typography>
153-
{generateToc(toc)}
211+
{generateToc(toc, 0, activeId)}
154212
</Box>
155213
</Box>
156214
</>
157215
);
158216
}
159217

160-
const generateToc = (items: TableOfContent[], level = 0) => {
218+
const generateToc = (items: TableOfContent[], level = 0, activeId = "") => {
161219
const theme = useTheme();
162220

163221
return (
@@ -174,6 +232,9 @@ const generateToc = (items: TableOfContent[], level = 0) => {
174232
title,
175233
url
176234
);
235+
const itemId = url?.replace(/^#/, "") || "";
236+
const isActive = itemId && itemId === activeId;
237+
177238
return (
178239
<Typography key={`${level}-${item.title}`} component="li">
179240
<Typography
@@ -188,6 +249,8 @@ const generateToc = (items: TableOfContent[], level = 0) => {
188249
paddingLeft: `${0.5 + 1 * level}rem`,
189250
paddingTop: "0.25rem",
190251
paddingBottom: "0.25rem",
252+
fontWeight: isActive ? "700" : "400",
253+
color: isActive ? theme.palette.website.f1 : "inherit",
191254
"&:hover": {
192255
color: theme.palette.website.f3,
193256
borderLeft: `1px solid ${theme.palette.website.f3}`,
@@ -196,7 +259,7 @@ const generateToc = (items: TableOfContent[], level = 0) => {
196259
>
197260
{removeHtmlTag(newLabel)}
198261
</Typography>
199-
{items && generateToc(items, level + 1)}
262+
{items && generateToc(items, level + 1, activeId)}
200263
</Typography>
201264
);
202265
})}

0 commit comments

Comments
 (0)