mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat: website (#45)
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import type { MarkdownHeading } from 'astro';
|
||||
import clsx from 'clsx';
|
||||
import { useRef } from 'react';
|
||||
import { useAutoScroll } from './utils';
|
||||
|
||||
interface TableOfContentsDesktopProps {
|
||||
headings: MarkdownHeading[];
|
||||
activeId: string;
|
||||
onNavigate: (slug: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TableOfContentsDesktop({ headings, activeId, onNavigate, className }: TableOfContentsDesktopProps) {
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
|
||||
useAutoScroll({ activeId, containerRef: navRef });
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
|
||||
e.preventDefault();
|
||||
onNavigate(slug);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
ref={navRef}
|
||||
className={clsx('', className)}
|
||||
>
|
||||
<div className="py-8 pr-6">
|
||||
<h2 className="text-sm mb-3 font-semibold">On this page</h2>
|
||||
<ul className="space-y-3">
|
||||
{headings.map(heading => (
|
||||
<li key={heading.slug}>
|
||||
<a
|
||||
href={`#${heading.slug}`}
|
||||
onClick={e => handleClick(e, heading.slug)}
|
||||
className={clsx('text-sm block', activeId === heading.slug ? 'text-dark-100' : 'text-dark-40 intent:text-dark-100',
|
||||
)}
|
||||
style={{ paddingLeft: `calc(${(heading.depth - 2)} * var(--spacing) * 4)` }}
|
||||
>
|
||||
{heading.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { MarkdownHeading } from 'astro';
|
||||
import clsx from 'clsx';
|
||||
import { Select } from '@/components/Select';
|
||||
|
||||
interface TableOfContentsMobileProps {
|
||||
headings: MarkdownHeading[];
|
||||
activeId: string;
|
||||
onNavigate: (slug: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TableOfContentsMobile({ headings, activeId, onNavigate, className }: TableOfContentsMobileProps) {
|
||||
const handleChange = (slug: string | null) => {
|
||||
if (slug) onNavigate(slug);
|
||||
};
|
||||
|
||||
const options = [
|
||||
{ value: null, label: 'On this page…' },
|
||||
...headings.map(heading => ({
|
||||
value: heading.slug,
|
||||
label: `${'\u00A0'.repeat((heading.depth - 2) * 2)}${heading.text}`,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('border-b border-light-40 bg-light-80 px-6 lg:px-12 h-(--h) flex items-center', className)}
|
||||
style={{ '--h': 'var(--mobile-toc-h)' } as React.CSSProperties}
|
||||
>
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
<Select
|
||||
value={activeId || null}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
aria-label="Table of contents"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { MarkdownHeading } from 'astro';
|
||||
import { TableOfContentsDesktop } from './TableOfContents.desktop';
|
||||
import { TableOfContentsMobile } from './TableOfContents.mobile';
|
||||
import { filterHeadingsByDepth, navigateToHeading, useActiveHeading } from './utils';
|
||||
|
||||
interface TableOfContentsProps {
|
||||
headings: MarkdownHeading[];
|
||||
}
|
||||
|
||||
export function TableOfContents({ headings }: TableOfContentsProps) {
|
||||
const filteredHeadings = filterHeadingsByDepth(headings, 2, 3);
|
||||
const activeId = useActiveHeading(filteredHeadings);
|
||||
|
||||
if (filteredHeadings.length === 0) return <></>;
|
||||
return (
|
||||
<>
|
||||
<TableOfContentsMobile
|
||||
headings={filteredHeadings}
|
||||
activeId={activeId}
|
||||
onNavigate={navigateToHeading}
|
||||
className="xl:hidden"
|
||||
/>
|
||||
<TableOfContentsDesktop
|
||||
headings={filteredHeadings}
|
||||
activeId={activeId}
|
||||
onNavigate={navigateToHeading}
|
||||
className="hidden xl:block"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { MarkdownHeading } from 'astro';
|
||||
import type { RefObject } from 'react';
|
||||
import debounce from 'just-debounce-it';
|
||||
import throttle from 'just-throttle';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Find the first scrollable ancestor of an element
|
||||
*/
|
||||
export function getScrollParent(element: HTMLElement): HTMLElement {
|
||||
let current: HTMLElement | null = element;
|
||||
|
||||
while (current && current !== document.body) {
|
||||
const style = window.getComputedStyle(current);
|
||||
const overflowY = style.overflowY;
|
||||
const overflow = style.overflow;
|
||||
|
||||
if (/auto|scroll/.test(overflow + overflowY)) {
|
||||
if (current.scrollHeight > current.clientHeight) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return document.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element is outside the visible bounds of its scroll container
|
||||
*/
|
||||
export function isElementOffscreen(
|
||||
element: HTMLElement,
|
||||
container: HTMLElement,
|
||||
): boolean {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
|
||||
const isAboveView = elementRect.top < containerRect.top;
|
||||
const isBelowView = elementRect.bottom > containerRect.bottom;
|
||||
|
||||
return isAboveView || isBelowView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter headings by depth range
|
||||
*/
|
||||
export function filterHeadingsByDepth(
|
||||
headings: MarkdownHeading[],
|
||||
minDepth: number,
|
||||
maxDepth: number,
|
||||
): MarkdownHeading[] {
|
||||
return headings.filter(h => h.depth >= minDepth && h.depth <= maxDepth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a heading by scrolling it into view and updating the URL
|
||||
*/
|
||||
export function navigateToHeading(slug: string): void {
|
||||
const element = document.getElementById(slug);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
window.history.pushState({}, '', `#${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface UseAutoScrollOptions {
|
||||
activeId: string;
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-scrolls the active link into view when it becomes active and is offscreen
|
||||
*/
|
||||
export function useAutoScroll({ activeId, containerRef }: UseAutoScrollOptions) {
|
||||
useEffect(() => {
|
||||
if (!activeId || !containerRef.current) return;
|
||||
|
||||
const activeLink = containerRef.current.querySelector<HTMLAnchorElement>(
|
||||
`a[href="#${activeId}"]`,
|
||||
);
|
||||
if (!activeLink) return;
|
||||
|
||||
const scrollParent = getScrollParent(containerRef.current);
|
||||
if (!scrollParent) return;
|
||||
|
||||
if (isElementOffscreen(activeLink, scrollParent)) {
|
||||
activeLink.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}, [activeId, containerRef]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which heading is currently active based on scroll position
|
||||
*/
|
||||
export function useActiveHeading(headings: MarkdownHeading[]): string {
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
// globals.css SHOULD define a scroll-margin-top for headings
|
||||
// let's get the value of that, here
|
||||
let scrollOffset = 125; // idk, a sensible default
|
||||
const idElement = document.querySelector('main [id]');
|
||||
if (idElement) {
|
||||
const computedStyle = getComputedStyle(idElement);
|
||||
const scrollMarginTop = computedStyle.scrollMarginTop;
|
||||
if (scrollMarginTop) {
|
||||
const parsed = Number.parseFloat(scrollMarginTop);
|
||||
if (!Number.isNaN(parsed)) scrollOffset = parsed;
|
||||
}
|
||||
}
|
||||
scrollOffset = scrollOffset + 1;
|
||||
const scrollPosition = window.scrollY + scrollOffset;
|
||||
|
||||
// Find the last heading that's above the scroll position
|
||||
let currentActiveId = '';
|
||||
for (const heading of headings) {
|
||||
const element = document.getElementById(heading.slug);
|
||||
if (element) {
|
||||
const elementTop = element.offsetTop;
|
||||
if (elementTop <= scrollPosition) {
|
||||
currentActiveId = heading.slug;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// since this function is mostly only called in events...
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setActiveId(currentActiveId);
|
||||
};
|
||||
|
||||
// Throttle to limit how often it runs during scrolling
|
||||
const throttledHandleScroll = throttle(handleScroll, 100);
|
||||
// Debounce to ensure it runs after scrolling stops
|
||||
const debouncedHandleScroll = debounce(throttledHandleScroll, 50);
|
||||
|
||||
// Set initial active heading
|
||||
handleScroll();
|
||||
|
||||
// Add scroll listeners
|
||||
window.addEventListener('scroll', throttledHandleScroll);
|
||||
window.addEventListener('scroll', debouncedHandleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', throttledHandleScroll);
|
||||
window.removeEventListener('scroll', debouncedHandleScroll);
|
||||
};
|
||||
}, [headings]);
|
||||
|
||||
return activeId;
|
||||
}
|
||||
Reference in New Issue
Block a user