@san-siva/blogkit 1.1.20 → 1.1.22
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/dist/cjs/components/BlogSection.js +2 -2
- package/dist/cjs/components/BlogSection.js.map +1 -1
- package/dist/cjs/dynamicComponents/BlogDynamic.js +31 -181
- package/dist/cjs/dynamicComponents/BlogDynamic.js.map +1 -1
- package/dist/cjs/dynamicComponents/BlogSectionDynamic.js +16 -8
- package/dist/cjs/dynamicComponents/BlogSectionDynamic.js.map +1 -1
- package/dist/cjs/hooks/useCategoryTitles.js +104 -0
- package/dist/cjs/hooks/useCategoryTitles.js.map +1 -0
- package/dist/cjs/hooks/useSectionObserver.js +89 -0
- package/dist/cjs/hooks/useSectionObserver.js.map +1 -0
- package/dist/cjs/index.css +1 -1
- package/dist/cjs/index.css.map +1 -1
- package/dist/cjs/staticComponents/BlogSectionStatic.js +2 -5
- package/dist/cjs/staticComponents/BlogSectionStatic.js.map +1 -1
- package/dist/cjs/staticComponents/TocNodeStatic.js +16 -0
- package/dist/cjs/staticComponents/TocNodeStatic.js.map +1 -0
- package/dist/cjs/styles/Blog.module.scss.js +1 -1
- package/dist/cjs/styles/BlogSection.module.scss.js +1 -1
- package/dist/cjs/styles/Callout.module.scss.js +1 -1
- package/dist/cjs/styles/TocNode.module.scss.js +8 -0
- package/dist/cjs/styles/TocNode.module.scss.js.map +1 -0
- package/dist/esm/components/BlogSection.js +2 -2
- package/dist/esm/components/BlogSection.js.map +1 -1
- package/dist/esm/dynamicComponents/BlogDynamic.js +32 -182
- package/dist/esm/dynamicComponents/BlogDynamic.js.map +1 -1
- package/dist/esm/dynamicComponents/BlogSectionDynamic.js +17 -9
- package/dist/esm/dynamicComponents/BlogSectionDynamic.js.map +1 -1
- package/dist/esm/hooks/useCategoryTitles.js +102 -0
- package/dist/esm/hooks/useCategoryTitles.js.map +1 -0
- package/dist/esm/hooks/useSectionObserver.js +87 -0
- package/dist/esm/hooks/useSectionObserver.js.map +1 -0
- package/dist/esm/index.css +1 -1
- package/dist/esm/index.css.map +1 -1
- package/dist/esm/staticComponents/BlogSectionStatic.js +2 -5
- package/dist/esm/staticComponents/BlogSectionStatic.js.map +1 -1
- package/dist/esm/staticComponents/TocNodeStatic.js +12 -0
- package/dist/esm/staticComponents/TocNodeStatic.js.map +1 -0
- package/dist/esm/styles/Blog.module.scss.js +1 -1
- package/dist/esm/styles/BlogSection.module.scss.js +1 -1
- package/dist/esm/styles/Callout.module.scss.js +1 -1
- package/dist/esm/styles/TocNode.module.scss.js +4 -0
- package/dist/esm/styles/TocNode.module.scss.js.map +1 -0
- package/dist/types/components/BlogSection.d.ts +0 -1
- package/dist/types/components/BlogSection.d.ts.map +1 -1
- package/dist/types/dynamicComponents/BlogDynamic.d.ts +1 -1
- package/dist/types/dynamicComponents/BlogDynamic.d.ts.map +1 -1
- package/dist/types/dynamicComponents/BlogSectionDynamic.d.ts +0 -1
- package/dist/types/dynamicComponents/BlogSectionDynamic.d.ts.map +1 -1
- package/dist/types/hooks/useCategoryTitles.d.ts +22 -0
- package/dist/types/hooks/useCategoryTitles.d.ts.map +1 -0
- package/dist/types/hooks/useSectionObserver.d.ts +11 -0
- package/dist/types/hooks/useSectionObserver.d.ts.map +1 -0
- package/dist/types/staticComponents/BlogSectionStatic.d.ts +1 -2
- package/dist/types/staticComponents/BlogSectionStatic.d.ts.map +1 -1
- package/dist/types/staticComponents/TocNodeStatic.d.ts +16 -0
- package/dist/types/staticComponents/TocNodeStatic.d.ts.map +1 -0
- package/package.json +5 -3
- package/src/components/BlogSection.tsx +0 -4
- package/src/dynamicComponents/BlogDynamic.tsx +42 -253
- package/src/dynamicComponents/BlogSectionDynamic.tsx +38 -21
- package/src/hooks/useCategoryTitles.ts +148 -0
- package/src/hooks/useSectionObserver.ts +102 -0
- package/src/staticComponents/BlogSectionStatic.tsx +2 -13
- package/src/staticComponents/TocNodeStatic.tsx +52 -0
- package/src/styles/Blog.module.scss +0 -30
- package/src/styles/Blog.module.scss.d.ts +0 -4
- package/src/styles/BlogHeader.module.scss +6 -5
- package/src/styles/BlogLink.module.scss +1 -1
- package/src/styles/BlogSection.module.scss +26 -21
- package/src/styles/BlogSection.module.scss.d.ts +1 -2
- package/src/styles/CodeBlock.module.scss +2 -2
- package/src/styles/Table.module.scss +1 -1
- package/src/styles/TocNode.module.scss +49 -0
- package/src/styles/TocNode.module.scss.d.ts +11 -0
|
@@ -4,18 +4,19 @@ import {
|
|
|
4
4
|
Children,
|
|
5
5
|
cloneElement,
|
|
6
6
|
isValidElement,
|
|
7
|
-
useCallback,
|
|
8
|
-
useEffect,
|
|
9
|
-
useRef,
|
|
10
7
|
useState,
|
|
11
8
|
} from 'react';
|
|
12
9
|
import { useSpring, animated, config } from '@react-spring/web';
|
|
13
10
|
|
|
14
|
-
import type {
|
|
11
|
+
import type { ReactNode, RefAttributes } from 'react';
|
|
15
12
|
import type { Thing, WithContext } from 'schema-dts';
|
|
16
13
|
|
|
17
14
|
import styles from '../styles/Blog.module.scss';
|
|
18
|
-
import
|
|
15
|
+
import { useCategoryTitles } from '../hooks/useCategoryTitles';
|
|
16
|
+
import { useSectionObserver } from '../hooks/useSectionObserver';
|
|
17
|
+
import type { CategoryTitleValue } from '../hooks/useCategoryTitles';
|
|
18
|
+
import TocNodeStatic from '../staticComponents/TocNodeStatic';
|
|
19
|
+
import type { TocNode } from '../staticComponents/TocNodeStatic';
|
|
19
20
|
|
|
20
21
|
interface BlogProperties {
|
|
21
22
|
children: ReactNode;
|
|
@@ -25,244 +26,45 @@ interface BlogProperties {
|
|
|
25
26
|
|
|
26
27
|
export interface ForwardedReference {
|
|
27
28
|
parentRef: HTMLDivElement;
|
|
28
|
-
childRefs:
|
|
29
|
+
childRefs: ForwardedReference[];
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
32
|
+
const buildTocTree = (entries: [string, CategoryTitleValue][]): TocNode[] => {
|
|
33
|
+
const roots: TocNode[] = [];
|
|
34
|
+
const stack: TocNode[] = [];
|
|
35
|
+
for (const [id, { title, depth }] of entries) {
|
|
36
|
+
const node: TocNode = { id, title, depth, children: [] };
|
|
37
|
+
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) {
|
|
38
|
+
stack.pop();
|
|
39
|
+
}
|
|
40
|
+
if (stack.length === 0) {
|
|
41
|
+
roots.push(node);
|
|
42
|
+
} else {
|
|
43
|
+
stack[stack.length - 1].children.push(node);
|
|
44
|
+
}
|
|
45
|
+
stack.push(node);
|
|
46
|
+
}
|
|
47
|
+
return roots;
|
|
48
|
+
};
|
|
44
49
|
|
|
45
50
|
const Blog = ({
|
|
46
51
|
children,
|
|
47
52
|
title = 'In this article',
|
|
48
53
|
jsonLd,
|
|
49
54
|
}: BlogProperties) => {
|
|
50
|
-
const sectionReferences = useRef<SectionReference>(new Map());
|
|
51
|
-
const [categoryTitles, setCategoryTitles] = useState<CategoryTitle>(
|
|
52
|
-
new Map()
|
|
53
|
-
);
|
|
54
55
|
const [visibleTitle, setVisibleTitle] = useState<string | null>(null);
|
|
55
56
|
const [showTOC, setShowTOC] = useState(false);
|
|
56
57
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
new Map()
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
const sortByDomPosition = useCallback(
|
|
66
|
-
(
|
|
67
|
-
[, a]: [string, SectionReferenceValue],
|
|
68
|
-
[, b]: [string, SectionReferenceValue]
|
|
69
|
-
) => {
|
|
70
|
-
const position = a.el.compareDocumentPosition(b.el);
|
|
71
|
-
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
72
|
-
return -1; // a comes before b
|
|
73
|
-
} else if (position & Node.DOCUMENT_POSITION_PRECEDING) {
|
|
74
|
-
return 1; // b comes before a
|
|
75
|
-
}
|
|
76
|
-
return 0;
|
|
77
|
-
},
|
|
78
|
-
[]
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
const updateCategoryTitles = useCallback(() => {
|
|
82
|
-
const now = Date.now();
|
|
83
|
-
const newCategoryTitles = new Map<string, CategoryTitleValue>();
|
|
84
|
-
|
|
85
|
-
// Sort sections by their DOM position to maintain correct order
|
|
86
|
-
const sectionsArray = Array.from(sectionReferences.current.entries());
|
|
87
|
-
sectionsArray.sort(sortByDomPosition);
|
|
88
|
-
|
|
89
|
-
let firstSectionId: string | null = null;
|
|
90
|
-
for (const [id, { title, el, isSubSection }] of sectionsArray) {
|
|
91
|
-
if (!firstSectionId) {
|
|
92
|
-
firstSectionId = id;
|
|
93
|
-
}
|
|
94
|
-
newCategoryTitles.set(id, {
|
|
95
|
-
el,
|
|
96
|
-
title,
|
|
97
|
-
lastUpdatedAt: now,
|
|
98
|
-
isSubSection,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (newCategoryTitles.size === 0) return;
|
|
103
|
-
|
|
104
|
-
setCategoryTitles(newCategoryTitles);
|
|
105
|
-
if (!showTOC) setShowTOC(true);
|
|
106
|
-
|
|
107
|
-
if (visibleTitle) return;
|
|
108
|
-
setVisibleTitle(firstSectionId);
|
|
109
|
-
}, [visibleTitle, sortByDomPosition, showTOC, setShowTOC]);
|
|
110
|
-
|
|
111
|
-
const debounceUpdateCategoryTitles = useCallback(() => {
|
|
112
|
-
// Clear existing timer and set a new one to batch updates
|
|
113
|
-
if (updateTimerRef.current) {
|
|
114
|
-
clearTimeout(updateTimerRef.current);
|
|
115
|
-
}
|
|
116
|
-
updateTimerRef.current = setTimeout(() => {
|
|
117
|
-
updateCategoryTitles();
|
|
118
|
-
}, 200);
|
|
119
|
-
}, [updateCategoryTitles]);
|
|
120
|
-
|
|
121
|
-
useEffect(() => {
|
|
122
|
-
for (const [id, { el }] of categoryTitles) {
|
|
123
|
-
const observer = new IntersectionObserver(
|
|
124
|
-
([entry]) => {
|
|
125
|
-
if (!entry.isIntersecting) return;
|
|
126
|
-
if (isClickScrolling.current) return;
|
|
127
|
-
if (document.body.scrollTop === 0) return;
|
|
128
|
-
setVisibleTitle(visibleId => {
|
|
129
|
-
if (visibleId === id && !entry.isIntersecting) return null;
|
|
130
|
-
if (entry.isIntersecting) return id;
|
|
131
|
-
return visibleId;
|
|
132
|
-
});
|
|
133
|
-
const url = new URL(window.location.href);
|
|
134
|
-
url.searchParams.set('section', id);
|
|
135
|
-
window.history.replaceState({}, '', url.toString());
|
|
136
|
-
},
|
|
137
|
-
{ threshold: 0.1 }
|
|
138
|
-
);
|
|
139
|
-
intersectionObserversRef.current.set(id, observer);
|
|
140
|
-
observer.observe(el as HTMLElement);
|
|
141
|
-
}
|
|
142
|
-
}, [categoryTitles.size]);
|
|
143
|
-
|
|
144
|
-
useEffect(() => {
|
|
145
|
-
return () => {
|
|
146
|
-
if (updateTimerRef.current) {
|
|
147
|
-
clearTimeout(updateTimerRef.current);
|
|
148
|
-
}
|
|
149
|
-
if (showTOCTimerRef.current) {
|
|
150
|
-
clearTimeout(showTOCTimerRef.current);
|
|
151
|
-
}
|
|
152
|
-
if (scrollEndHandlerRef.current) {
|
|
153
|
-
document.body.removeEventListener(
|
|
154
|
-
'scrollend',
|
|
155
|
-
scrollEndHandlerRef.current
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
if (intersectionObserversRef.current) {
|
|
159
|
-
for (const observer of intersectionObserversRef.current.values()) {
|
|
160
|
-
observer.disconnect();
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
}, []);
|
|
165
|
-
|
|
166
|
-
const getSectionFromUrl = () => {
|
|
167
|
-
const url = new URL(window.location.href);
|
|
168
|
-
const section = url.searchParams.get('section');
|
|
169
|
-
if (!section) return null;
|
|
170
|
-
return section;
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const updateUrl = (id: string) => {
|
|
174
|
-
const url = new URL(window.location.href);
|
|
175
|
-
url.searchParams.set('section', id);
|
|
176
|
-
window.history.replaceState({}, '', url.toString());
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
const scrollIntoView = (element: HTMLElement) => {
|
|
180
|
-
if (!element) return;
|
|
181
|
-
const top =
|
|
182
|
-
element.getBoundingClientRect().top + document.body.scrollTop - 100;
|
|
183
|
-
document.body.scrollTo({ top, behavior: 'smooth' });
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
// On initial load, scroll to section specified in URL
|
|
187
|
-
useEffect(() => {
|
|
188
|
-
if (categoryTitles.size === 0) return;
|
|
189
|
-
const section = getSectionFromUrl();
|
|
190
|
-
if (!section) {
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
const entry = categoryTitles.get(section);
|
|
194
|
-
if (!entry) {
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
scrollIntoView(entry.el);
|
|
198
|
-
lockScrollUpdates(
|
|
199
|
-
section,
|
|
200
|
-
isClickScrolling,
|
|
201
|
-
scrollEndHandlerRef,
|
|
202
|
-
setVisibleTitle
|
|
203
|
-
);
|
|
204
|
-
}, [categoryTitles.size]);
|
|
205
|
-
|
|
206
|
-
const handleSectionReference = useCallback(
|
|
207
|
-
(element: ForwardedReference) => {
|
|
208
|
-
if (!element) return;
|
|
209
|
-
const { parentRef, childRefs } = element;
|
|
210
|
-
|
|
211
|
-
// Add parent section reference
|
|
212
|
-
if (parentRef) {
|
|
213
|
-
const id = parentRef.dataset.id;
|
|
214
|
-
const title = parentRef.dataset.title;
|
|
215
|
-
if (id && title) {
|
|
216
|
-
sectionReferences.current.set(id, {
|
|
217
|
-
el: parentRef,
|
|
218
|
-
title,
|
|
219
|
-
isSubSection: false,
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Add child section references
|
|
225
|
-
if (Array.isArray(childRefs)) {
|
|
226
|
-
for (const childRef of childRefs) {
|
|
227
|
-
if (!childRef) continue;
|
|
228
|
-
const id = childRef.dataset.id;
|
|
229
|
-
const title = childRef.dataset.title;
|
|
230
|
-
if (id && title) {
|
|
231
|
-
sectionReferences.current.set(id, {
|
|
232
|
-
el: childRef,
|
|
233
|
-
title,
|
|
234
|
-
isSubSection: true,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
debounceUpdateCategoryTitles();
|
|
241
|
-
},
|
|
242
|
-
[debounceUpdateCategoryTitles]
|
|
243
|
-
);
|
|
244
|
-
|
|
245
|
-
const handleClickCategoryTitle = (
|
|
246
|
-
error: MouseEvent<HTMLParagraphElement>
|
|
247
|
-
) => {
|
|
248
|
-
const id = error.currentTarget.dataset.id;
|
|
249
|
-
const index = error.currentTarget.dataset.idx;
|
|
250
|
-
if (!id || !index) return;
|
|
251
|
-
|
|
252
|
-
const { el } = categoryTitles.get(id) || {};
|
|
253
|
-
if (!el) return;
|
|
254
|
-
|
|
255
|
-
updateUrl(id);
|
|
256
|
-
|
|
257
|
-
scrollIntoView(el);
|
|
58
|
+
const { categoryTitles, handleSectionReference } = useCategoryTitles({
|
|
59
|
+
visibleTitle,
|
|
60
|
+
setVisibleTitle,
|
|
61
|
+
setShowTOC,
|
|
62
|
+
});
|
|
258
63
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
setVisibleTitle
|
|
264
|
-
);
|
|
265
|
-
};
|
|
64
|
+
const { handleClickCategoryTitle } = useSectionObserver({
|
|
65
|
+
categoryTitles,
|
|
66
|
+
setVisibleTitle,
|
|
67
|
+
});
|
|
266
68
|
|
|
267
69
|
const sidebarStyle = useSpring({
|
|
268
70
|
opacity: showTOC ? 1 : 0,
|
|
@@ -292,28 +94,15 @@ const Blog = ({
|
|
|
292
94
|
>
|
|
293
95
|
{title}
|
|
294
96
|
</p>
|
|
295
|
-
{[...categoryTitles].map(
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
id === visibleTitle ? styles['category__title--active'] : ''
|
|
305
|
-
} ${isSubSection ? styles['category__title--sub'] : ''} ${
|
|
306
|
-
isSubSection && !isNextSectionSubSection
|
|
307
|
-
? styles['margin-bottom-imp--2']
|
|
308
|
-
: ''
|
|
309
|
-
}`}
|
|
310
|
-
onClick={handleClickCategoryTitle}
|
|
311
|
-
>
|
|
312
|
-
{title}
|
|
313
|
-
</p>
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
)}
|
|
97
|
+
{buildTocTree([...categoryTitles]).map((node, i) => (
|
|
98
|
+
<TocNodeStatic
|
|
99
|
+
key={node.id}
|
|
100
|
+
node={node}
|
|
101
|
+
index={i}
|
|
102
|
+
visibleTitle={visibleTitle}
|
|
103
|
+
onClick={handleClickCategoryTitle}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
317
106
|
</animated.div>
|
|
318
107
|
</div>
|
|
319
108
|
);
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
cloneElement,
|
|
6
6
|
forwardRef,
|
|
7
7
|
isValidElement,
|
|
8
|
+
useEffect,
|
|
8
9
|
useImperativeHandle,
|
|
9
10
|
useRef,
|
|
10
11
|
} from 'react';
|
|
@@ -20,17 +21,11 @@ interface BlogProperties {
|
|
|
20
21
|
title?: string;
|
|
21
22
|
category?: string;
|
|
22
23
|
children?: ReactNode;
|
|
23
|
-
increaseMarginBottom?: boolean;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const BlogSection = forwardRef<ForwardedReference, BlogProperties>(
|
|
27
27
|
(
|
|
28
|
-
{
|
|
29
|
-
title = '',
|
|
30
|
-
category = '',
|
|
31
|
-
children = null,
|
|
32
|
-
increaseMarginBottom = false,
|
|
33
|
-
}: BlogProperties,
|
|
28
|
+
{ title = '', category = '', children = null }: BlogProperties,
|
|
34
29
|
forwardedReference
|
|
35
30
|
) => {
|
|
36
31
|
const titleWithCategory = category ? `${category} - ${title}` : title;
|
|
@@ -49,37 +44,59 @@ const BlogSection = forwardRef<ForwardedReference, BlogProperties>(
|
|
|
49
44
|
return handle;
|
|
50
45
|
});
|
|
51
46
|
|
|
47
|
+
// Re-register when title or category changes so the TOC reflects the updated heading
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (
|
|
50
|
+
typeof forwardedReference === 'function' &&
|
|
51
|
+
imperativeHandleRef.current
|
|
52
|
+
) {
|
|
53
|
+
forwardedReference(imperativeHandleRef.current);
|
|
54
|
+
}
|
|
55
|
+
}, [title, category]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
56
|
+
|
|
52
57
|
const handleChildReferences = (element: ForwardedReference | null) => {
|
|
53
58
|
if (!element) return;
|
|
54
59
|
const { parentRef: subParentReference } = element;
|
|
55
60
|
if (!subParentReference) return;
|
|
56
|
-
childReferences.current.push(subParentReference);
|
|
57
61
|
|
|
58
|
-
//
|
|
59
|
-
|
|
62
|
+
// Avoid registering the same child section twice
|
|
63
|
+
const alreadyRegistered = childReferences.current.some(
|
|
64
|
+
ref => ref.parentRef === subParentReference
|
|
65
|
+
);
|
|
66
|
+
if (!alreadyRegistered) {
|
|
67
|
+
childReferences.current.push(element);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
typeof forwardedReference === 'function' &&
|
|
72
|
+
imperativeHandleRef.current
|
|
73
|
+
) {
|
|
60
74
|
forwardedReference(imperativeHandleRef.current);
|
|
61
75
|
}
|
|
62
76
|
};
|
|
63
77
|
|
|
64
78
|
return (
|
|
65
79
|
<div
|
|
66
|
-
className={
|
|
67
|
-
${
|
|
68
|
-
increaseMarginBottom
|
|
69
|
-
? styles['margin-bottom--9']
|
|
70
|
-
: styles['margin-bottom--6']
|
|
71
|
-
}`}
|
|
80
|
+
className={styles['blog-section']}
|
|
72
81
|
data-title={title}
|
|
73
82
|
data-id={id}
|
|
74
83
|
ref={parentReference}
|
|
75
84
|
>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
<h3
|
|
86
|
+
className={`${styles['blog-section__title']} ${title ? '' : styles['blog-section__title--empty']}`}
|
|
87
|
+
>
|
|
88
|
+
{title ? (
|
|
89
|
+
<a
|
|
90
|
+
href={generateSectionHref(id)}
|
|
91
|
+
className={styles['blog-section__title-link']}
|
|
92
|
+
onClick={e => e.preventDefault()}
|
|
93
|
+
>
|
|
79
94
|
{title}
|
|
80
95
|
</a>
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
) : (
|
|
97
|
+
<p>No title</p>
|
|
98
|
+
)}
|
|
99
|
+
</h3>
|
|
83
100
|
{Children.map(children, child => {
|
|
84
101
|
if (!isValidElement(child)) return child;
|
|
85
102
|
return cloneElement(child, {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
6
|
+
|
|
7
|
+
import type { ForwardedReference } from '../dynamicComponents/BlogDynamic';
|
|
8
|
+
|
|
9
|
+
export interface SectionReferenceValue {
|
|
10
|
+
el: HTMLElement;
|
|
11
|
+
title: string;
|
|
12
|
+
depth: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CategoryTitleValue extends SectionReferenceValue {
|
|
16
|
+
lastUpdatedAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CategoryTitle = Map<string, CategoryTitleValue>;
|
|
20
|
+
|
|
21
|
+
type SectionReference = Map<string, SectionReferenceValue>;
|
|
22
|
+
|
|
23
|
+
interface Options {
|
|
24
|
+
visibleTitle: string | null;
|
|
25
|
+
setVisibleTitle: Dispatch<SetStateAction<string | null>>;
|
|
26
|
+
setShowTOC: Dispatch<SetStateAction<boolean>>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useCategoryTitles({
|
|
30
|
+
visibleTitle,
|
|
31
|
+
setVisibleTitle,
|
|
32
|
+
setShowTOC,
|
|
33
|
+
}: Options) {
|
|
34
|
+
const sectionReferences = useRef<SectionReference>(new Map());
|
|
35
|
+
const [categoryTitles, setCategoryTitles] = useState<CategoryTitle>(
|
|
36
|
+
new Map()
|
|
37
|
+
);
|
|
38
|
+
const updateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
39
|
+
|
|
40
|
+
const sortByDomPosition = useCallback(
|
|
41
|
+
(
|
|
42
|
+
[, a]: [string, SectionReferenceValue],
|
|
43
|
+
[, b]: [string, SectionReferenceValue]
|
|
44
|
+
) => {
|
|
45
|
+
const position = a.el.compareDocumentPosition(b.el);
|
|
46
|
+
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
47
|
+
return -1;
|
|
48
|
+
} else if (position & Node.DOCUMENT_POSITION_PRECEDING) {
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
},
|
|
53
|
+
[]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const updateCategoryTitles = useCallback(() => {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const newCategoryTitles = new Map<string, CategoryTitleValue>();
|
|
59
|
+
|
|
60
|
+
const sectionsArray = Array.from(sectionReferences.current.entries());
|
|
61
|
+
sectionsArray.sort(sortByDomPosition);
|
|
62
|
+
|
|
63
|
+
let firstSectionId: string | null = null;
|
|
64
|
+
for (const [id, { title, el, depth }] of sectionsArray) {
|
|
65
|
+
if (!firstSectionId) {
|
|
66
|
+
firstSectionId = id;
|
|
67
|
+
}
|
|
68
|
+
newCategoryTitles.set(id, {
|
|
69
|
+
el,
|
|
70
|
+
title,
|
|
71
|
+
lastUpdatedAt: now,
|
|
72
|
+
depth,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (newCategoryTitles.size === 0) return;
|
|
77
|
+
|
|
78
|
+
setCategoryTitles(newCategoryTitles);
|
|
79
|
+
setShowTOC(true);
|
|
80
|
+
|
|
81
|
+
if (visibleTitle) return;
|
|
82
|
+
setVisibleTitle(firstSectionId);
|
|
83
|
+
}, [visibleTitle, sortByDomPosition, setShowTOC, setVisibleTitle]);
|
|
84
|
+
|
|
85
|
+
const debounceUpdateCategoryTitles = useCallback(() => {
|
|
86
|
+
if (updateTimerRef.current) {
|
|
87
|
+
clearTimeout(updateTimerRef.current);
|
|
88
|
+
}
|
|
89
|
+
updateTimerRef.current = setTimeout(() => {
|
|
90
|
+
updateCategoryTitles();
|
|
91
|
+
}, 200);
|
|
92
|
+
}, [updateCategoryTitles]);
|
|
93
|
+
|
|
94
|
+
const removeStaleRefs = (ref: HTMLDivElement) => {
|
|
95
|
+
for (const [existingId, { el }] of sectionReferences.current) {
|
|
96
|
+
if (el !== ref) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
sectionReferences.current.delete(existingId);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleCategoryTitle = (ref: HTMLDivElement) => {
|
|
105
|
+
const id = ref.dataset.id;
|
|
106
|
+
const title = ref.dataset.title;
|
|
107
|
+
if (!id || !title) return;
|
|
108
|
+
|
|
109
|
+
let depth = 0;
|
|
110
|
+
let parent = ref.parentElement;
|
|
111
|
+
while (parent) {
|
|
112
|
+
if (parent.hasAttribute('data-id')) depth++;
|
|
113
|
+
parent = parent.parentElement;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
removeStaleRefs(ref);
|
|
117
|
+
sectionReferences.current.set(id, { el: ref, title, depth });
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const processSection = (element: ForwardedReference) => {
|
|
121
|
+
const { parentRef, childRefs } = element;
|
|
122
|
+
if (parentRef) handleCategoryTitle(parentRef);
|
|
123
|
+
if (Array.isArray(childRefs)) {
|
|
124
|
+
for (const childRef of childRefs) {
|
|
125
|
+
processSection(childRef);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const handleSectionReference = useCallback(
|
|
131
|
+
(element: ForwardedReference) => {
|
|
132
|
+
if (!element) return;
|
|
133
|
+
processSection(element);
|
|
134
|
+
debounceUpdateCategoryTitles();
|
|
135
|
+
},
|
|
136
|
+
[debounceUpdateCategoryTitles]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
return () => {
|
|
141
|
+
if (updateTimerRef.current) {
|
|
142
|
+
clearTimeout(updateTimerRef.current);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
return { categoryTitles, handleSectionReference };
|
|
148
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
|
6
|
+
|
|
7
|
+
import lockScrollUpdates from '../utils/lockScrollUpdates';
|
|
8
|
+
import type { CategoryTitle } from './useCategoryTitles';
|
|
9
|
+
|
|
10
|
+
const getSectionFromUrl = () => {
|
|
11
|
+
const url = new URL(window.location.href);
|
|
12
|
+
return url.searchParams.get('section');
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const updateUrl = (id: string) => {
|
|
16
|
+
const url = new URL(window.location.href);
|
|
17
|
+
url.searchParams.set('section', id);
|
|
18
|
+
window.history.replaceState({}, '', url.toString());
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const scrollIntoView = (element: HTMLElement) => {
|
|
22
|
+
if (!element) return;
|
|
23
|
+
const top =
|
|
24
|
+
element.getBoundingClientRect().top + document.body.scrollTop - 100;
|
|
25
|
+
document.body.scrollTo({ top, behavior: 'smooth' });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
interface Options {
|
|
29
|
+
categoryTitles: CategoryTitle;
|
|
30
|
+
setVisibleTitle: Dispatch<SetStateAction<string | null>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useSectionObserver({ categoryTitles, setVisibleTitle }: Options) {
|
|
34
|
+
const isClickScrolling = useRef(false);
|
|
35
|
+
const scrollEndHandlerRef = useRef<(() => void) | null>(null);
|
|
36
|
+
const intersectionObserversRef = useRef<Map<string, IntersectionObserver>>(
|
|
37
|
+
new Map()
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Set up IntersectionObservers whenever the set of sections changes
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
for (const [id, { el }] of categoryTitles) {
|
|
43
|
+
const observer = new IntersectionObserver(
|
|
44
|
+
([entry]) => {
|
|
45
|
+
if (!entry.isIntersecting) return;
|
|
46
|
+
if (isClickScrolling.current) return;
|
|
47
|
+
if (document.body.scrollTop === 0) return;
|
|
48
|
+
setVisibleTitle(visibleId => {
|
|
49
|
+
if (visibleId === id && !entry.isIntersecting) return null;
|
|
50
|
+
if (entry.isIntersecting) return id;
|
|
51
|
+
return visibleId;
|
|
52
|
+
});
|
|
53
|
+
updateUrl(id);
|
|
54
|
+
},
|
|
55
|
+
{ threshold: 0.1 }
|
|
56
|
+
);
|
|
57
|
+
intersectionObserversRef.current.set(id, observer);
|
|
58
|
+
observer.observe(el as HTMLElement);
|
|
59
|
+
}
|
|
60
|
+
}, [categoryTitles.size, setVisibleTitle]);
|
|
61
|
+
|
|
62
|
+
// On initial load, scroll to section specified in URL
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (categoryTitles.size === 0) return;
|
|
65
|
+
const section = getSectionFromUrl();
|
|
66
|
+
if (!section) return;
|
|
67
|
+
const entry = categoryTitles.get(section);
|
|
68
|
+
if (!entry) return;
|
|
69
|
+
scrollIntoView(entry.el);
|
|
70
|
+
lockScrollUpdates(section, isClickScrolling, scrollEndHandlerRef, setVisibleTitle);
|
|
71
|
+
}, [categoryTitles.size, setVisibleTitle]);
|
|
72
|
+
|
|
73
|
+
// Cleanup observers on unmount
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
return () => {
|
|
76
|
+
if (scrollEndHandlerRef.current) {
|
|
77
|
+
document.body.removeEventListener('scrollend', scrollEndHandlerRef.current);
|
|
78
|
+
}
|
|
79
|
+
for (const observer of intersectionObserversRef.current.values()) {
|
|
80
|
+
observer.disconnect();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const handleClickCategoryTitle = useCallback(
|
|
86
|
+
(event: MouseEvent<HTMLParagraphElement>) => {
|
|
87
|
+
const id = event.currentTarget.dataset.id;
|
|
88
|
+
const index = event.currentTarget.dataset.idx;
|
|
89
|
+
if (!id || !index) return;
|
|
90
|
+
|
|
91
|
+
const { el } = categoryTitles.get(id) || {};
|
|
92
|
+
if (!el) return;
|
|
93
|
+
|
|
94
|
+
updateUrl(id);
|
|
95
|
+
scrollIntoView(el);
|
|
96
|
+
lockScrollUpdates(id, isClickScrolling, scrollEndHandlerRef, setVisibleTitle);
|
|
97
|
+
},
|
|
98
|
+
[categoryTitles, setVisibleTitle]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return { handleClickCategoryTitle };
|
|
102
|
+
}
|