Перейти к содержимому

Документация

TOC индикатор

Оглавление страницы с активным индикатором, SVG-линиями связи и pulse-анимацией.


TOC индикатор

Компонент TableOfContents создаёт оглавление страницы с визуальным индикатором текущего раздела. Автоматически собирает заголовки с DOM, подсвечивает активные разделы и рисует SVG-линии иерархии.

Превью

<script lang="ts">
	import { fromAction } from 'svelte/attachments';
	import { SvelteMap, SvelteSet } from 'svelte/reactivity';
	import { cn } from '$lib/utils/cn';
	import { page } from '$app/state';
	import TableOfContents from 'carbon-icons-svelte/lib/TableOfContents.svelte';
	import type { ContentTocHeading } from '$lib/content/sections';

	type TocItem = ContentTocHeading & {
		element: HTMLElement;
	};

	type IndicatorRange = {
		startId: string;
		endId: string;
	};

	type PathPoint = {
		x: number;
		y: number;
	};

	type Props = {
		selector?: string;
		title?: string;
		emptyLabel?: string;
		minViewportWidth?: number;
		scrollContainerId?: string;
		headings?: ContentTocHeading[];
		maxHeight?: string;
	};

	const props = $props();
	const selector = $derived(
		(props as Props).selector ?? '[data-doc-content] h2, [data-doc-content] h3'
	);
	const title = $derived((props as Props).title ?? 'On this page');
	const emptyLabel = $derived((props as Props).emptyLabel ?? 'No headings');
	const minViewportWidth = $derived((props as Props).minViewportWidth ?? 1280);
	const scrollContainerId = $derived((props as Props).scrollContainerId ?? null);
	const initialHeadings = $derived(normalizeHeadings((props as Props).headings));
	const maxHeight = $derived((props as Props).maxHeight ?? '70vh');

	let headings = $state<ContentTocHeading[]>([]);
	const renderedHeadings = $derived(headings.length > 0 ? headings : initialHeadings);
	let activeId = $state('');
	let indicatorTop = $state(0);
	let indicatorHeight = $state(0);
	let indicatorBottom = $state(0);
	let lineHeight = $state(0);
	let svgPath = $state('');
	let svgWidth = $state(40);
	let indicatorRange = $state<IndicatorRange | null>(null);
	let pendingIndicatorFrame: number | null = null;
	let lastPulsedRangeKey = '';
	let viewportWidth = $state(0);
	const tocViewportActive = $derived(viewportWidth >= minViewportWidth);
	let collectFrame: number | null = null;
	let collectCleanup: (() => void) | undefined;

	// Fade overlay state — whether the list overflows its scroll box,
	// so we know when to show the top/bottom blur-into-background masks.
	let tocScrollEl = $state<HTMLDivElement | null>(null);
	let canScrollUp = $state(false);
	let canScrollDown = $state(false);
	let fadeResizeObserver: ResizeObserver | null = null;

	const ACTIVE_OFFSET = 140;
	const VISIBLE_BUFFER = 24;
	const CORNER_RADIUS = 2;
	const linkRefs = new SvelteMap<string, HTMLAnchorElement>();
	const linkPositions = new SvelteMap<string, { top: number; height: number }>();
	const headingOrder = new SvelteMap<string, number>();
	const pulseTimers = new SvelteMap<string, number>();
	const lastIndicatorIds = new SvelteSet<string>();
	let pulsingDotIds = $state<string[]>([]);
	const linksWrapperId = 'toc-links-wrapper';

	const currentPath = $derived(page.url.pathname);

	function getScrollContainer() {
		if (typeof document === 'undefined') return window;
		if (!scrollContainerId) return window;
		const element = document.getElementById(scrollContainerId);
		return element ?? window;
	}

	const slugify = (value: string) =>
		value
			.normalize('NFD')
			.replace(/[\u0300-\u036f]/g, '')
			.toLowerCase()
			.trim()
			.replace(/[^a-z0-9]+/g, '-')
			.replace(/^-+|-+$/g, '');

	function normalizeHeadings(value?: ContentTocHeading[]) {
		return Array.isArray(value)
			? value.filter(
					(heading): heading is ContentTocHeading =>
						typeof heading.id === 'string' &&
						heading.id.length > 0 &&
						typeof heading.text === 'string' &&
						heading.text.length > 0 &&
						typeof heading.level === 'number'
				)
			: [];
	}

	function syncHeadingOrder(nextHeadings: ContentTocHeading[]) {
		headingOrder.clear();
		nextHeadings.forEach(({ id }, index) => {
			headingOrder.set(id, index);
		});
	}

	function applyHeadings(nextHeadings: ContentTocHeading[]) {
		headings = nextHeadings;
		syncHeadingOrder(nextHeadings);
		activeId = nextHeadings[0]?.id ?? '';
		lineHeight = 0;
		indicatorTop = 0;
		indicatorHeight = 0;
		indicatorBottom = 0;
		indicatorRange = null;
		lastPulsedRangeKey = '';
		lastIndicatorIds.clear();
	}

	function resetTocState(options: { clearHeadings?: boolean } = {}) {
		if (options.clearHeadings ?? true) {
			headings = [];
			headingOrder.clear();
		}
		activeId = '';
		lineHeight = 0;
		indicatorTop = 0;
		indicatorHeight = 0;
		indicatorBottom = 0;
		indicatorRange = null;
		lastPulsedRangeKey = '';
		lastIndicatorIds.clear();
		pulsingDotIds = [];
		if (typeof window !== 'undefined') {
			pulseTimers.forEach((timer) => {
				window.clearTimeout(timer);
			});
		}
		pulseTimers.clear();
		if (typeof window !== 'undefined' && pendingIndicatorFrame !== null) {
			window.cancelAnimationFrame(pendingIndicatorFrame);
			pendingIndicatorFrame = null;
		}
	}

	function syncInitialHeadings() {
		applyHeadings(initialHeadings);
	}

	function pulseDot(id: string) {
		if (typeof window === 'undefined' || !id) return;

		const existingTimer = pulseTimers.get(id);
		if (existingTimer) {
			window.clearTimeout(existingTimer);
		}

		pulsingDotIds = pulsingDotIds.filter((item) => item !== id);
		window.requestAnimationFrame(() => {
			pulsingDotIds = [...pulsingDotIds, id];
			const timer = window.setTimeout(() => {
				pulsingDotIds = pulsingDotIds.filter((item) => item !== id);
				pulseTimers.delete(id);
			}, 560);
			pulseTimers.set(id, timer);
		});
	}

	function pulseRange(range: IndicatorRange | null) {
		if (!range) return;
		const rangeKey = `${range.startId}:${range.endId}`;
		if (rangeKey === lastPulsedRangeKey) return;
		lastPulsedRangeKey = rangeKey;

		const startIndex = headingOrder.get(range.startId);
		const endIndex = headingOrder.get(range.endId);
		if (startIndex === undefined || endIndex === undefined) return;

		const min = Math.min(startIndex, endIndex);
		const max = Math.max(startIndex, endIndex);

		const nextIndicatorIds = headings
			.filter((_heading, index) => index >= min && index <= max)
			.map((heading) => heading.id);

		nextIndicatorIds.forEach((id) => {
			if (!lastIndicatorIds.has(id)) {
				pulseDot(id);
			}
		});

		lastIndicatorIds.clear();
		nextIndicatorIds.forEach((id) => {
			lastIndicatorIds.add(id);
		});
	}

	function clearCollectionWork() {
		if (typeof window !== 'undefined' && collectFrame !== null) {
			window.cancelAnimationFrame(collectFrame);
		}
		collectFrame = null;

		collectCleanup?.();
		collectCleanup = undefined;
	}

	function registerLink(node: HTMLElement, id?: string) {
		let currentId = id ?? '';

		const assign = () => {
			if (!currentId) return;
			linkRefs.set(currentId, node as HTMLAnchorElement);
		};

		assign();

		return {
			update(newId?: string) {
				if (newId === currentId) return;
				if (currentId) {
					linkRefs.delete(currentId);
					linkPositions.delete(currentId);
				}
				currentId = newId ?? '';
				assign();
			},
			destroy() {
				if (currentId) {
					linkRefs.delete(currentId);
					linkPositions.delete(currentId);
				}
			}
		};
	}

	function buildRoundedPath(points: PathPoint[], radius: number) {
		if (points.length === 0) return '';
		if (points.length === 1) {
			const [point] = points;
			return `M ${point.x.toString()} ${point.y.toString()}`;
		}

		const commands: string[] = [`M ${points[0].x.toString()} ${points[0].y.toString()}`];

		for (let i = 1; i < points.length; i++) {
			const point = points[i];
			const prev = points[i - 1];

			if (i === points.length - 1) {
				commands.push(` L ${point.x.toString()} ${point.y.toString()}`);
				continue;
			}

			const next = points[i + 1];
			const prevVecX = point.x - prev.x;
			const prevVecY = point.y - prev.y;
			const nextVecX = next.x - point.x;
			const nextVecY = next.y - point.y;
			const prevLen = Math.hypot(prevVecX, prevVecY);
			const nextLen = Math.hypot(nextVecX, nextVecY);

			if (prevLen === 0 || nextLen === 0) {
				commands.push(` L ${point.x.toString()} ${point.y.toString()}`);
				continue;
			}

			const prevDirX = prevVecX / prevLen;
			const prevDirY = prevVecY / prevLen;
			const nextDirX = nextVecX / nextLen;
			const nextDirY = nextVecY / nextLen;
			const dot = prevDirX * nextDirX + prevDirY * nextDirY;

			if (Math.abs(dot) > 0.999) {
				commands.push(` L ${point.x.toString()} ${point.y.toString()}`);
				continue;
			}

			const cornerRadius = Math.min(radius, prevLen / 2, nextLen / 2);
			const entryX = point.x - prevDirX * cornerRadius;
			const entryY = point.y - prevDirY * cornerRadius;
			const exitX = point.x + nextDirX * cornerRadius;
			const exitY = point.y + nextDirY * cornerRadius;

			commands.push(` L ${entryX.toString()} ${entryY.toString()}`);
			commands.push(
				` Q ${point.x.toString()} ${point.y.toString()} ${exitX.toString()} ${exitY.toString()}`
			);
		}

		return commands.join('');
	}

	function getLinksWrapperElement() {
		if (typeof document === 'undefined') return null;
		const node = document.getElementById(linksWrapperId);
		return node instanceof HTMLOListElement ? node : null;
	}

	function updateLayout() {
		const linksWrapper = getLinksWrapperElement();
		if (!linksWrapper || headings.length === 0) {
			lineHeight = 0;
			return;
		}

		linkPositions.clear();
		const polyline: PathPoint[] = [];
		let maxW = 0;
		const indentStep = 12;
		const strokeWidth = 1;
		const halfStroke = strokeWidth / 2;

		headings.forEach((heading) => {
			const node = linkRefs.get(heading.id);
			if (!node) return;

			const style = window.getComputedStyle(node);
			const paddingTop = parseFloat(style.paddingTop) || 0;
			const paddingBottom = parseFloat(style.paddingBottom) || 0;
			const positionTop = node.offsetTop + paddingTop;
			const positionBottom = node.offsetTop + node.offsetHeight - paddingBottom;
			const positionHeight = Math.max(0, positionBottom - positionTop);

			linkPositions.set(heading.id, {
				top: positionTop,
				height: positionHeight
			});

			const x = (heading.level - 2) * indentStep + halfStroke;
			const top = positionTop;
			const bottom = Math.max(positionTop, positionBottom);

			polyline.push({ x, y: top });
			polyline.push({ x, y: bottom });

			maxW = Math.max(maxW, x + halfStroke);
		});

		svgPath = buildRoundedPath(polyline, CORNER_RADIUS);
		svgWidth = Math.max(40, maxW + 10);
		lineHeight = linksWrapper.scrollHeight;
	}

	function updateIndicator(range?: IndicatorRange) {
		const appliedRange =
			range ?? indicatorRange ?? (activeId ? { startId: activeId, endId: activeId } : null);

		if (!appliedRange) {
			indicatorRange = null;
			indicatorTop = 0;
			indicatorHeight = 0;
			indicatorBottom = 0;
			return;
		}

		if (range) {
			indicatorRange = range;
		} else {
			indicatorRange ??= appliedRange;
		}

		const startPos = linkPositions.get(appliedRange.startId);
		const endPos = linkPositions.get(appliedRange.endId);

		if (!startPos || !endPos) {
			indicatorTop = 0;
			indicatorHeight = 0;
			indicatorBottom = 0;
			return;
		}

		const top = Math.min(startPos.top, endPos.top);
		const bottom = Math.max(startPos.top + startPos.height, endPos.top + endPos.height);

		indicatorTop = top;
		indicatorHeight = Math.max(0, bottom - top);
		indicatorBottom = bottom;
	}

	function scheduleIndicatorUpdate(range?: IndicatorRange | null) {
		if (typeof window === 'undefined') {
			if (range) {
				pulseRange(range);
				updateIndicator(range);
			} else {
				updateIndicator();
			}
			return;
		}

		if (pendingIndicatorFrame !== null) {
			window.cancelAnimationFrame(pendingIndicatorFrame);
		}

		pendingIndicatorFrame = window.requestAnimationFrame(() => {
			pendingIndicatorFrame = null;
			if (range) {
				pulseRange(range);
				updateIndicator(range);
			} else {
				updateIndicator();
			}
		});
	}

	// Recompute whether the scroll box has hidden content above/below,
	// so the fade overlays only show up when there's actually something to hide.
	function updateFadeState() {
		const el = tocScrollEl;
		if (!el) {
			canScrollUp = false;
			canScrollDown = false;
			return;
		}
		const epsilon = 1;
		canScrollUp = el.scrollTop > epsilon;
		canScrollDown = el.scrollTop + el.clientHeight < el.scrollHeight - epsilon;
	}

	function collectDomHeadings(): TocItem[] {
		const slugCounts = new SvelteMap<string, number>();
		const usedIds = new SvelteSet<string>();
		const nodeList = Array.from(document.querySelectorAll(selector)).filter(
			(node): node is HTMLElement => node instanceof HTMLElement
		);

		const parsed: TocItem[] = [];

		for (const node of nodeList) {
			const rawText = node.textContent;
			const text = rawText ? rawText.trim() : '';
			if (!text) continue;

			let id = node.id;
			if (!id) {
				let baseSlug = slugify(text);
				if (!baseSlug) {
					baseSlug = `section-${(parsed.length + 1).toString()}`;
				}
				const count = slugCounts.get(baseSlug);
				if (typeof count === 'number') {
					const nextCount = count + 1;
					slugCounts.set(baseSlug, nextCount);
					baseSlug = `${baseSlug}-${nextCount.toString()}`;
				} else {
					slugCounts.set(baseSlug, 0);
				}
				id = baseSlug;
			}

			if (usedIds.has(id)) {
				const baseId = id;
				let nextCount = slugCounts.get(baseId) ?? 0;

				do {
					nextCount += 1;
					id = `${baseId}-${nextCount.toString()}`;
				} while (usedIds.has(id));

				slugCounts.set(baseId, nextCount);
			}

			if (node.id !== id) {
				node.id = id;
			}
			usedIds.add(id);

			const level = Number(node.tagName.replace('H', '')) || 2;
			parsed.push({
				id,
				text,
				level,
				element: node
			});
		}

		return parsed;
	}

	function resolveInitialHeadingElements(): TocItem[] {
		if (initialHeadings.length === 0) return [];

		const parsed = initialHeadings.flatMap((heading): TocItem[] => {
			const element = document.getElementById(heading.id);
			return element instanceof HTMLElement ? [{ ...heading, element }] : [];
		});

		return parsed.length === initialHeadings.length ? parsed : [];
	}

	function collectHeadings() {
		if (typeof document === 'undefined' || !tocViewportActive) {
			resetTocState({ clearHeadings: false });
			return undefined;
		}

		const initialParsed = resolveInitialHeadingElements();
		const parsed = initialParsed.length > 0 ? initialParsed : collectDomHeadings();

		applyHeadings(parsed.map(({ element: _element, ...rest }) => rest));

		lineHeight = 0;
		indicatorTop = 0;
		indicatorHeight = 0;
		indicatorBottom = 0;
		indicatorRange = null;

		requestAnimationFrame(() => {
			updateLayout();
			updateFadeState();
		});

		if (!parsed.length) {
			return undefined;
		}

		const updateActive = () => {
			let current = parsed[0]?.id ?? '';
			const scrollEl = getScrollContainer();
			const isWindow = scrollEl === window;
			const scrollY = isWindow ? window.scrollY : (scrollEl as HTMLElement).scrollTop;
			const viewportHeight = isWindow ? window.innerHeight : (scrollEl as HTMLElement).clientHeight;
			const scrollHeight = isWindow
				? document.documentElement.scrollHeight
				: (scrollEl as HTMLElement).scrollHeight;
			const containerBounds = isWindow
				? { top: 0, bottom: viewportHeight }
				: (scrollEl as HTMLElement).getBoundingClientRect();
			const viewportTop = containerBounds.top - VISIBLE_BUFFER;
			const viewportBottom = containerBounds.bottom + VISIBLE_BUFFER;
			const visibleIds: string[] = [];

			for (const item of parsed) {
				const rect = item.element.getBoundingClientRect();
				if (rect.bottom >= viewportTop && rect.top <= viewportBottom) {
					visibleIds.push(item.id);
				}
				if (rect.top - ACTIVE_OFFSET <= viewportTop + VISIBLE_BUFFER) {
					current = item.id;
				}
			}

			const last = parsed[parsed.length - 1];
			const scrolledBottom = scrollY + viewportHeight;
			if (scrolledBottom >= scrollHeight - 20) {
				current = last.id;
			}

			activeId = current;
			const range: IndicatorRange | null =
				visibleIds.length > 0
					? {
							startId: visibleIds[0],
							endId: visibleIds[visibleIds.length - 1]
						}
					: current
						? { startId: current, endId: current }
						: null;

			scheduleIndicatorUpdate(range);

			// Keep the active link within view inside the (unmodified) TOC list,
			// and keep the fade overlays in sync with the new scroll position.
			const activeNode = linkRefs.get(current);
			const scrollBox = tocScrollEl;
			if (activeNode && scrollBox) {
				const nodeTop = activeNode.offsetTop;
				const nodeBottom = nodeTop + activeNode.offsetHeight;
				const viewTop = scrollBox.scrollTop;
				const viewBottom = viewTop + scrollBox.clientHeight;

				if (nodeTop < viewTop) {
					scrollBox.scrollTop = nodeTop;
				} else if (nodeBottom > viewBottom) {
					scrollBox.scrollTop = nodeBottom - scrollBox.clientHeight;
				}
			}
			updateFadeState();
		};

		const container = getScrollContainer();

		if (parsed.length > 0) {
			updateActive();
		}

		const handleResize = () => {
			updateActive();
			updateLayout();
			updateFadeState();
		};

		container.addEventListener('scroll', updateActive, { passive: true });
		window.addEventListener('resize', handleResize);

		return () => {
			container.removeEventListener('scroll', updateActive);
			window.removeEventListener('resize', handleResize);
			pulseTimers.forEach((timer) => {
				window.clearTimeout(timer);
			});
			pulseTimers.clear();
			pulsingDotIds = [];
			lastIndicatorIds.clear();
			if (pendingIndicatorFrame !== null) {
				window.cancelAnimationFrame(pendingIndicatorFrame);
				pendingIndicatorFrame = null;
			}
		};
	}

	function isLinkHighlighted(id: string) {
		if (!indicatorRange) {
			return activeId === id;
		}

		const startIndex = headingOrder.get(indicatorRange.startId);
		const endIndex = headingOrder.get(indicatorRange.endId);
		const currentIndex = headingOrder.get(id);

		if (startIndex === undefined || endIndex === undefined || currentIndex === undefined) {
			return activeId === id;
		}

		const min = Math.min(startIndex, endIndex);
		const max = Math.max(startIndex, endIndex);

		return currentIndex >= min && currentIndex <= max;
	}

	function manageToc(
		_node: HTMLElement,
		deps: { path: string; active: boolean; selector: string }
	) {
		let currentDeps = deps;

		const run = () => {
			clearCollectionWork();
			syncInitialHeadings();

			if (!currentDeps.active) {
				resetTocState({ clearHeadings: false });
				return;
			}

			collectFrame = window.requestAnimationFrame(() => {
				collectFrame = null;
				collectCleanup = collectHeadings();
			});
		};

		run();

		return {
			update(nextDeps: { path: string; active: boolean; selector: string }) {
				currentDeps = nextDeps;
				run();
			},
			destroy() {
				clearCollectionWork();
				resetTocState();
			}
		};
	}

	function observeLinksWrapper(node: HTMLOListElement, active: boolean) {
		let observer: ResizeObserver | null = null;

		const sync = (enabled: boolean) => {
			observer?.disconnect();
			observer = null;

			if (typeof window === 'undefined' || !enabled) return;

			observer = new ResizeObserver(() => {
				updateLayout();
				updateFadeState();
				updateIndicator();
			});

			observer.observe(node);
		};

		sync(active);

		return {
			update(nextActive: boolean) {
				sync(nextActive);
			},
			destroy() {
				observer?.disconnect();
			}
		};
	}

	// Attaches the scroll-box element reference and listens for scroll/resize
	// so the top/bottom fade overlays know when to appear, without altering
	// anything about the TOC's own internal layout/line-drawing logic.
	function observeTocScrollBox(node: HTMLDivElement) {
		tocScrollEl = node;
		updateFadeState();

		const onScroll = () => { updateFadeState(); };
		node.addEventListener('scroll', onScroll, { passive: true });

		fadeResizeObserver?.disconnect();
		fadeResizeObserver = null;
		if (typeof window !== 'undefined') {
			fadeResizeObserver = new ResizeObserver(() => { updateFadeState(); });
			fadeResizeObserver.observe(node);
		}

		return {
			destroy() {
				node.removeEventListener('scroll', onScroll);
				fadeResizeObserver?.disconnect();
				fadeResizeObserver = null;
				if (tocScrollEl === node) {
					tocScrollEl = null;
				}
			}
		};
	}
</script>

<svelte:window bind:innerWidth={viewportWidth} />

<div
	class="contents"
	{@attach fromAction(manageToc, () => ({
		path: currentPath,
		active: tocViewportActive,
		selector
	}))}
>
	{#if renderedHeadings.length > 0}
		<nav aria-label={title}>
			<div
				class="mb-2 flex items-center gap-2 text-xs font-medium tracking-wide text-foreground-muted/70 uppercase"
			>
				<TableOfContents size={16} />
				{title}
			</div>

			<!--
				Outer wrapper is `relative` so the fade overlays below can be
				positioned exactly over the scrollable box, without touching
				the internal structure (lines, dots, indicator) at all.
			-->
			<div class="relative">
				<div
					class="scrollbar-none overflow-y-auto"
					style={`max-height: ${maxHeight}; -webkit-mask-image: none;`}
					{@attach fromAction(observeTocScrollBox, () => undefined)}
				>
					<div class="relative mx-1 flex">
						<div
							class="pointer-events-none absolute top-0 left-[2.5px] h-full w-10"
							style={`
	                    mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${svgWidth.toString()} ${lineHeight.toString()}' width='${svgWidth.toString()}' height='${lineHeight.toString()}' preserveAspectRatio='none'%3E%3Cpath d='${svgPath}' stroke='black' stroke-width='1' fill='none'/%3E%3C/svg%3E");
                    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${svgWidth.toString()} ${lineHeight.toString()}' width='${svgWidth.toString()}' height='${lineHeight.toString()}' preserveAspectRatio='none'%3E%3Cpath d='${svgPath}' stroke='black' stroke-width='1' fill='none'/%3E%3C/svg%3E");
                    mask-repeat: no-repeat;
                    -webkit-mask-repeat: no-repeat;
                    mask-position: left top;
                    -webkit-mask-position: left top;
                    mask-size: 100% 100%;
                    -webkit-mask-size: 100% 100%;
                `}
						>
							<div class="absolute inset-0 h-full w-full bg-border"></div>

							{#if indicatorHeight > 0}
								<div
									class="toc-active-line absolute left-0 w-full transition-[top,bottom] duration-450 ease-out"
									style={`
	                            top: ${indicatorTop.toString()}px;
	                            bottom: ${Math.max(0, lineHeight - indicatorBottom).toString()}px;
                        `}
								></div>
							{/if}
						</div>

						<ol
							id={linksWrapperId}
							class="relative flex flex-col text-sm"
							{@attach fromAction(observeLinksWrapper, () => tocViewportActive)}
						>
							{#each renderedHeadings as heading (heading.id)}
								<li
									class="transition-colors duration-150 ease-out"
									style={`padding-left: ${((heading.level - 2) * 12).toString()}px`}
								>
									<a
										href={`#${heading.id}`}
										onclick={() => {
											pulseDot(heading.id);
										}}
										class={cn(
											'flex items-center gap-2 py-1 font-medium tracking-normal transition-[color] duration-150 ease-out',
											isLinkHighlighted(heading.id)
												? 'text-accent'
												: 'text-foreground-muted hover:text-foreground'
										)}
										{@attach fromAction(registerLink, () => heading.id)}
									>
										<span
											aria-hidden="true"
											class={cn(
												'toc-dot relative size-1.5 flex-none rounded-full transition-[background-color,box-shadow,scale] duration-150 ease-out',
												isLinkHighlighted(heading.id) && 'toc-dot-active',
												pulsingDotIds.includes(heading.id) && 'toc-dot-pulse'
											)}
										></span>
										<span
											class={cn(
												'min-w-0 pl-1',
												isLinkHighlighted(heading.id) && 'text-accent'
											)}
										>
											{heading.text}
										</span>
									</a>
								</li>
							{/each}
						</ol>
					</div>
				</div>

				<!--
					Fade overlays: purely visual, positioned on top of the scroll box.
					They don't affect layout, line drawing, or indicator math at all —
					they just blur/hide overflow content into the page background color
					(#17181a) when the list is taller than its max-height.
				-->
				{#if canScrollUp}
					<div
						class="toc-fade toc-fade-top pointer-events-none absolute inset-x-0 top-0 h-10"
						aria-hidden="true"
					></div>
				{/if}
				{#if canScrollDown}
					<div
						class="toc-fade toc-fade-bottom pointer-events-none absolute inset-x-0 bottom-0 h-10"
						aria-hidden="true"
					></div>
				{/if}
			</div>
		</nav>
	{:else}
		<div class="hidden text-sm tracking-normal text-foreground-muted/70 lg:block">{emptyLabel}</div>
	{/if}
</div>

<style>
	.toc-active-line {
		background-image: linear-gradient(
			to bottom,
			transparent,
			oklch(from var(--color-accent) l c h / 0.68) 22%,
			var(--color-accent) 50%,
			oklch(from var(--color-accent) l c h / 0.68) 78%,
			transparent
		);
		filter: drop-shadow(0 0 6px oklch(from var(--color-accent) l c h / 0.38));
	}

	.toc-dot {
		background-color: var(--color-foreground-muted);
		box-shadow: 0 0 0 2px var(--color-background);
		opacity: 0.72;
	}

	.toc-dot-active {
		background-color: var(--color-accent);
		box-shadow:
			inset 0 1px oklch(from var(--color-white-fixed) l c h / 0.35),
			0 0 0 2px var(--color-background),
			0 0 10px oklch(from var(--color-accent) l c h / 0.38);
		opacity: 1;
	}

	.toc-dot-active::after {
		content: '';
		position: absolute;
		inset: 0;
		border-radius: 9999px;
		box-shadow: 0 0 9px oklch(from var(--color-accent) l c h / 0.5);
	}

	.toc-dot-pulse {
		animation: toc-dot-pulse 0.52s ease-out both;
	}

	@keyframes toc-dot-pulse {
		0% {
			transform: scale(1);
			box-shadow: 0 0 0 2px var(--color-background);
		}

		12% {
			transform: scale(1.15);
			background-color: var(--color-accent);
			box-shadow:
				0 0 0 2px var(--color-background),
				0 0 0 3px oklch(from var(--color-accent) l c h / 0.18),
				0 0 18px oklch(from var(--color-accent) l c h / 0.52);
		}

		100% {
			transform: scale(1);
		}
	}

	/* Hide the native scrollbar on the TOC scroll box so the fade
	   overlays are the only visible cue that there's more content. */
	.scrollbar-none {
		scrollbar-width: none;
		-ms-overflow-style: none;
	}

	.scrollbar-none::-webkit-scrollbar {
		display: none;
	}

	/* Fade-to-background overlays. Uses the same background variable as
	   whatever surface the TOC sits on (desktop sidebar, mobile panel, etc.)
	   so the fade color always matches exactly instead of a fixed hex —
	   content doesn't get clipped abruptly, it softly blurs into the
	   background and disappears. */
	.toc-fade {
		z-index: 1;
		background: var(--toc-fade-color, var(--color-background-inset, var(--color-background)));
	}

	.toc-fade-top {
		mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 100%);
		-webkit-mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 100%);
	}

	.toc-fade-bottom {
		mask-image: linear-gradient(to top, black 0%, black 15%, transparent 100%);
		-webkit-mask-image: linear-gradient(to top, black 0%, black 15%, transparent 100%);
	}
</style>
<script lang="ts">
	import { fromAction } from 'svelte/attachments';
	import { SvelteMap, SvelteSet } from 'svelte/reactivity';
	import { cn } from '$lib/utils/cn';
	import { page } from '$app/state';
	import TableOfContents from 'carbon-icons-svelte/lib/TableOfContents.svelte';
	import type { ContentTocHeading } from '$lib/content/sections';

	type TocItem = ContentTocHeading & {
		element: HTMLElement;
	};

	type IndicatorRange = {
		startId: string;
		endId: string;
	};

	type PathPoint = {
		x: number;
		y: number;
	};

	type Props = {
		selector?: string;
		title?: string;
		emptyLabel?: string;
		minViewportWidth?: number;
		scrollContainerId?: string;
		headings?: ContentTocHeading[];
		maxHeight?: string;
	};

	const props = $props();
	const selector = $derived(
		(props as Props).selector ?? '[data-doc-content] h2, [data-doc-content] h3'
	);
	const title = $derived((props as Props).title ?? 'On this page');
	const emptyLabel = $derived((props as Props).emptyLabel ?? 'No headings');
	const minViewportWidth = $derived((props as Props).minViewportWidth ?? 1280);
	const scrollContainerId = $derived((props as Props).scrollContainerId ?? null);
	const initialHeadings = $derived(normalizeHeadings((props as Props).headings));
	const maxHeight = $derived((props as Props).maxHeight ?? '70vh');

	let headings = $state<ContentTocHeading[]>([]);
	const renderedHeadings = $derived(headings.length > 0 ? headings : initialHeadings);
	let activeId = $state('');
	let indicatorTop = $state(0);
	let indicatorHeight = $state(0);
	let indicatorBottom = $state(0);
	let lineHeight = $state(0);
	let svgPath = $state('');
	let svgWidth = $state(40);
	let indicatorRange = $state<IndicatorRange | null>(null);
	let pendingIndicatorFrame: number | null = null;
	let lastPulsedRangeKey = '';
	let viewportWidth = $state(0);
	const tocViewportActive = $derived(viewportWidth >= minViewportWidth);
	let collectFrame: number | null = null;
	let collectCleanup: (() => void) | undefined;

	// Fade overlay state — whether the list overflows its scroll box,
	// so we know when to show the top/bottom blur-into-background masks.
	let tocScrollEl = $state<HTMLDivElement | null>(null);
	let canScrollUp = $state(false);
	let canScrollDown = $state(false);
	let fadeResizeObserver: ResizeObserver | null = null;

	const ACTIVE_OFFSET = 140;
	const VISIBLE_BUFFER = 24;
	const CORNER_RADIUS = 2;
	const linkRefs = new SvelteMap<string, HTMLAnchorElement>();
	const linkPositions = new SvelteMap<string, { top: number; height: number }>();
	const headingOrder = new SvelteMap<string, number>();
	const pulseTimers = new SvelteMap<string, number>();
	const lastIndicatorIds = new SvelteSet<string>();
	let pulsingDotIds = $state<string[]>([]);
	const linksWrapperId = 'toc-links-wrapper';

	const currentPath = $derived(page.url.pathname);

	function getScrollContainer() {
		if (typeof document === 'undefined') return window;
		if (!scrollContainerId) return window;
		const element = document.getElementById(scrollContainerId);
		return element ?? window;
	}

	const slugify = (value: string) =>
		value
			.normalize('NFD')
			.replace(/[\u0300-\u036f]/g, '')
			.toLowerCase()
			.trim()
			.replace(/[^a-z0-9]+/g, '-')
			.replace(/^-+|-+$/g, '');

	function normalizeHeadings(value?: ContentTocHeading[]) {
		return Array.isArray(value)
			? value.filter(
					(heading): heading is ContentTocHeading =>
						typeof heading.id === 'string' &&
						heading.id.length > 0 &&
						typeof heading.text === 'string' &&
						heading.text.length > 0 &&
						typeof heading.level === 'number'
				)
			: [];
	}

	function syncHeadingOrder(nextHeadings: ContentTocHeading[]) {
		headingOrder.clear();
		nextHeadings.forEach(({ id }, index) => {
			headingOrder.set(id, index);
		});
	}

	function applyHeadings(nextHeadings: ContentTocHeading[]) {
		headings = nextHeadings;
		syncHeadingOrder(nextHeadings);
		activeId = nextHeadings[0]?.id ?? '';
		lineHeight = 0;
		indicatorTop = 0;
		indicatorHeight = 0;
		indicatorBottom = 0;
		indicatorRange = null;
		lastPulsedRangeKey = '';
		lastIndicatorIds.clear();
	}

	function resetTocState(options: { clearHeadings?: boolean } = {}) {
		if (options.clearHeadings ?? true) {
			headings = [];
			headingOrder.clear();
		}
		activeId = '';
		lineHeight = 0;
		indicatorTop = 0;
		indicatorHeight = 0;
		indicatorBottom = 0;
		indicatorRange = null;
		lastPulsedRangeKey = '';
		lastIndicatorIds.clear();
		pulsingDotIds = [];
		if (typeof window !== 'undefined') {
			pulseTimers.forEach((timer) => {
				window.clearTimeout(timer);
			});
		}
		pulseTimers.clear();
		if (typeof window !== 'undefined' && pendingIndicatorFrame !== null) {
			window.cancelAnimationFrame(pendingIndicatorFrame);
			pendingIndicatorFrame = null;
		}
	}

	function syncInitialHeadings() {
		applyHeadings(initialHeadings);
	}

	function pulseDot(id: string) {
		if (typeof window === 'undefined' || !id) return;

		const existingTimer = pulseTimers.get(id);
		if (existingTimer) {
			window.clearTimeout(existingTimer);
		}

		pulsingDotIds = pulsingDotIds.filter((item) => item !== id);
		window.requestAnimationFrame(() => {
			pulsingDotIds = [...pulsingDotIds, id];
			const timer = window.setTimeout(() => {
				pulsingDotIds = pulsingDotIds.filter((item) => item !== id);
				pulseTimers.delete(id);
			}, 560);
			pulseTimers.set(id, timer);
		});
	}

	function pulseRange(range: IndicatorRange | null) {
		if (!range) return;
		const rangeKey = `${range.startId}:${range.endId}`;
		if (rangeKey === lastPulsedRangeKey) return;
		lastPulsedRangeKey = rangeKey;

		const startIndex = headingOrder.get(range.startId);
		const endIndex = headingOrder.get(range.endId);
		if (startIndex === undefined || endIndex === undefined) return;

		const min = Math.min(startIndex, endIndex);
		const max = Math.max(startIndex, endIndex);

		const nextIndicatorIds = headings
			.filter((_heading, index) => index >= min && index <= max)
			.map((heading) => heading.id);

		nextIndicatorIds.forEach((id) => {
			if (!lastIndicatorIds.has(id)) {
				pulseDot(id);
			}
		});

		lastIndicatorIds.clear();
		nextIndicatorIds.forEach((id) => {
			lastIndicatorIds.add(id);
		});
	}

	function clearCollectionWork() {
		if (typeof window !== 'undefined' && collectFrame !== null) {
			window.cancelAnimationFrame(collectFrame);
		}
		collectFrame = null;

		collectCleanup?.();
		collectCleanup = undefined;
	}

	function registerLink(node: HTMLElement, id?: string) {
		let currentId = id ?? '';

		const assign = () => {
			if (!currentId) return;
			linkRefs.set(currentId, node as HTMLAnchorElement);
		};

		assign();

		return {
			update(newId?: string) {
				if (newId === currentId) return;
				if (currentId) {
					linkRefs.delete(currentId);
					linkPositions.delete(currentId);
				}
				currentId = newId ?? '';
				assign();
			},
			destroy() {
				if (currentId) {
					linkRefs.delete(currentId);
					linkPositions.delete(currentId);
				}
			}
		};
	}

	function buildRoundedPath(points: PathPoint[], radius: number) {
		if (points.length === 0) return '';
		if (points.length === 1) {
			const [point] = points;
			return `M ${point.x.toString()} ${point.y.toString()}`;
		}

		const commands: string[] = [`M ${points[0].x.toString()} ${points[0].y.toString()}`];

		for (let i = 1; i < points.length; i++) {
			const point = points[i];
			const prev = points[i - 1];

			if (i === points.length - 1) {
				commands.push(` L ${point.x.toString()} ${point.y.toString()}`);
				continue;
			}

			const next = points[i + 1];
			const prevVecX = point.x - prev.x;
			const prevVecY = point.y - prev.y;
			const nextVecX = next.x - point.x;
			const nextVecY = next.y - point.y;
			const prevLen = Math.hypot(prevVecX, prevVecY);
			const nextLen = Math.hypot(nextVecX, nextVecY);

			if (prevLen === 0 || nextLen === 0) {
				commands.push(` L ${point.x.toString()} ${point.y.toString()}`);
				continue;
			}

			const prevDirX = prevVecX / prevLen;
			const prevDirY = prevVecY / prevLen;
			const nextDirX = nextVecX / nextLen;
			const nextDirY = nextVecY / nextLen;
			const dot = prevDirX * nextDirX + prevDirY * nextDirY;

			if (Math.abs(dot) > 0.999) {
				commands.push(` L ${point.x.toString()} ${point.y.toString()}`);
				continue;
			}

			const cornerRadius = Math.min(radius, prevLen / 2, nextLen / 2);
			const entryX = point.x - prevDirX * cornerRadius;
			const entryY = point.y - prevDirY * cornerRadius;
			const exitX = point.x + nextDirX * cornerRadius;
			const exitY = point.y + nextDirY * cornerRadius;

			commands.push(` L ${entryX.toString()} ${entryY.toString()}`);
			commands.push(
				` Q ${point.x.toString()} ${point.y.toString()} ${exitX.toString()} ${exitY.toString()}`
			);
		}

		return commands.join('');
	}

	function getLinksWrapperElement() {
		if (typeof document === 'undefined') return null;
		const node = document.getElementById(linksWrapperId);
		return node instanceof HTMLOListElement ? node : null;
	}

	function updateLayout() {
		const linksWrapper = getLinksWrapperElement();
		if (!linksWrapper || headings.length === 0) {
			lineHeight = 0;
			return;
		}

		linkPositions.clear();
		const polyline: PathPoint[] = [];
		let maxW = 0;
		const indentStep = 12;
		const strokeWidth = 1;
		const halfStroke = strokeWidth / 2;

		headings.forEach((heading) => {
			const node = linkRefs.get(heading.id);
			if (!node) return;

			const style = window.getComputedStyle(node);
			const paddingTop = parseFloat(style.paddingTop) || 0;
			const paddingBottom = parseFloat(style.paddingBottom) || 0;
			const positionTop = node.offsetTop + paddingTop;
			const positionBottom = node.offsetTop + node.offsetHeight - paddingBottom;
			const positionHeight = Math.max(0, positionBottom - positionTop);

			linkPositions.set(heading.id, {
				top: positionTop,
				height: positionHeight
			});

			const x = (heading.level - 2) * indentStep + halfStroke;
			const top = positionTop;
			const bottom = Math.max(positionTop, positionBottom);

			polyline.push({ x, y: top });
			polyline.push({ x, y: bottom });

			maxW = Math.max(maxW, x + halfStroke);
		});

		svgPath = buildRoundedPath(polyline, CORNER_RADIUS);
		svgWidth = Math.max(40, maxW + 10);
		lineHeight = linksWrapper.scrollHeight;
	}

	function updateIndicator(range?: IndicatorRange) {
		const appliedRange =
			range ?? indicatorRange ?? (activeId ? { startId: activeId, endId: activeId } : null);

		if (!appliedRange) {
			indicatorRange = null;
			indicatorTop = 0;
			indicatorHeight = 0;
			indicatorBottom = 0;
			return;
		}

		if (range) {
			indicatorRange = range;
		} else {
			indicatorRange ??= appliedRange;
		}

		const startPos = linkPositions.get(appliedRange.startId);
		const endPos = linkPositions.get(appliedRange.endId);

		if (!startPos || !endPos) {
			indicatorTop = 0;
			indicatorHeight = 0;
			indicatorBottom = 0;
			return;
		}

		const top = Math.min(startPos.top, endPos.top);
		const bottom = Math.max(startPos.top + startPos.height, endPos.top + endPos.height);

		indicatorTop = top;
		indicatorHeight = Math.max(0, bottom - top);
		indicatorBottom = bottom;
	}

	function scheduleIndicatorUpdate(range?: IndicatorRange | null) {
		if (typeof window === 'undefined') {
			if (range) {
				pulseRange(range);
				updateIndicator(range);
			} else {
				updateIndicator();
			}
			return;
		}

		if (pendingIndicatorFrame !== null) {
			window.cancelAnimationFrame(pendingIndicatorFrame);
		}

		pendingIndicatorFrame = window.requestAnimationFrame(() => {
			pendingIndicatorFrame = null;
			if (range) {
				pulseRange(range);
				updateIndicator(range);
			} else {
				updateIndicator();
			}
		});
	}

	// Recompute whether the scroll box has hidden content above/below,
	// so the fade overlays only show up when there's actually something to hide.
	function updateFadeState() {
		const el = tocScrollEl;
		if (!el) {
			canScrollUp = false;
			canScrollDown = false;
			return;
		}
		const epsilon = 1;
		canScrollUp = el.scrollTop > epsilon;
		canScrollDown = el.scrollTop + el.clientHeight < el.scrollHeight - epsilon;
	}

	function collectDomHeadings(): TocItem[] {
		const slugCounts = new SvelteMap<string, number>();
		const usedIds = new SvelteSet<string>();
		const nodeList = Array.from(document.querySelectorAll(selector)).filter(
			(node): node is HTMLElement => node instanceof HTMLElement
		);

		const parsed: TocItem[] = [];

		for (const node of nodeList) {
			const rawText = node.textContent;
			const text = rawText ? rawText.trim() : '';
			if (!text) continue;

			let id = node.id;
			if (!id) {
				let baseSlug = slugify(text);
				if (!baseSlug) {
					baseSlug = `section-${(parsed.length + 1).toString()}`;
				}
				const count = slugCounts.get(baseSlug);
				if (typeof count === 'number') {
					const nextCount = count + 1;
					slugCounts.set(baseSlug, nextCount);
					baseSlug = `${baseSlug}-${nextCount.toString()}`;
				} else {
					slugCounts.set(baseSlug, 0);
				}
				id = baseSlug;
			}

			if (usedIds.has(id)) {
				const baseId = id;
				let nextCount = slugCounts.get(baseId) ?? 0;

				do {
					nextCount += 1;
					id = `${baseId}-${nextCount.toString()}`;
				} while (usedIds.has(id));

				slugCounts.set(baseId, nextCount);
			}

			if (node.id !== id) {
				node.id = id;
			}
			usedIds.add(id);

			const level = Number(node.tagName.replace('H', '')) || 2;
			parsed.push({
				id,
				text,
				level,
				element: node
			});
		}

		return parsed;
	}

	function resolveInitialHeadingElements(): TocItem[] {
		if (initialHeadings.length === 0) return [];

		const parsed = initialHeadings.flatMap((heading): TocItem[] => {
			const element = document.getElementById(heading.id);
			return element instanceof HTMLElement ? [{ ...heading, element }] : [];
		});

		return parsed.length === initialHeadings.length ? parsed : [];
	}

	function collectHeadings() {
		if (typeof document === 'undefined' || !tocViewportActive) {
			resetTocState({ clearHeadings: false });
			return undefined;
		}

		const initialParsed = resolveInitialHeadingElements();
		const parsed = initialParsed.length > 0 ? initialParsed : collectDomHeadings();

		applyHeadings(parsed.map(({ element: _element, ...rest }) => rest));

		lineHeight = 0;
		indicatorTop = 0;
		indicatorHeight = 0;
		indicatorBottom = 0;
		indicatorRange = null;

		requestAnimationFrame(() => {
			updateLayout();
			updateFadeState();
		});

		if (!parsed.length) {
			return undefined;
		}

		const updateActive = () => {
			let current = parsed[0]?.id ?? '';
			const scrollEl = getScrollContainer();
			const isWindow = scrollEl === window;
			const scrollY = isWindow ? window.scrollY : (scrollEl as HTMLElement).scrollTop;
			const viewportHeight = isWindow ? window.innerHeight : (scrollEl as HTMLElement).clientHeight;
			const scrollHeight = isWindow
				? document.documentElement.scrollHeight
				: (scrollEl as HTMLElement).scrollHeight;
			const containerBounds = isWindow
				? { top: 0, bottom: viewportHeight }
				: (scrollEl as HTMLElement).getBoundingClientRect();
			const viewportTop = containerBounds.top - VISIBLE_BUFFER;
			const viewportBottom = containerBounds.bottom + VISIBLE_BUFFER;
			const visibleIds: string[] = [];

			for (const item of parsed) {
				const rect = item.element.getBoundingClientRect();
				if (rect.bottom >= viewportTop && rect.top <= viewportBottom) {
					visibleIds.push(item.id);
				}
				if (rect.top - ACTIVE_OFFSET <= viewportTop + VISIBLE_BUFFER) {
					current = item.id;
				}
			}

			const last = parsed[parsed.length - 1];
			const scrolledBottom = scrollY + viewportHeight;
			if (scrolledBottom >= scrollHeight - 20) {
				current = last.id;
			}

			activeId = current;
			const range: IndicatorRange | null =
				visibleIds.length > 0
					? {
							startId: visibleIds[0],
							endId: visibleIds[visibleIds.length - 1]
						}
					: current
						? { startId: current, endId: current }
						: null;

			scheduleIndicatorUpdate(range);

			// Keep the active link within view inside the (unmodified) TOC list,
			// and keep the fade overlays in sync with the new scroll position.
			const activeNode = linkRefs.get(current);
			const scrollBox = tocScrollEl;
			if (activeNode && scrollBox) {
				const nodeTop = activeNode.offsetTop;
				const nodeBottom = nodeTop + activeNode.offsetHeight;
				const viewTop = scrollBox.scrollTop;
				const viewBottom = viewTop + scrollBox.clientHeight;

				if (nodeTop < viewTop) {
					scrollBox.scrollTop = nodeTop;
				} else if (nodeBottom > viewBottom) {
					scrollBox.scrollTop = nodeBottom - scrollBox.clientHeight;
				}
			}
			updateFadeState();
		};

		const container = getScrollContainer();

		if (parsed.length > 0) {
			updateActive();
		}

		const handleResize = () => {
			updateActive();
			updateLayout();
			updateFadeState();
		};

		container.addEventListener('scroll', updateActive, { passive: true });
		window.addEventListener('resize', handleResize);

		return () => {
			container.removeEventListener('scroll', updateActive);
			window.removeEventListener('resize', handleResize);
			pulseTimers.forEach((timer) => {
				window.clearTimeout(timer);
			});
			pulseTimers.clear();
			pulsingDotIds = [];
			lastIndicatorIds.clear();
			if (pendingIndicatorFrame !== null) {
				window.cancelAnimationFrame(pendingIndicatorFrame);
				pendingIndicatorFrame = null;
			}
		};
	}

	function isLinkHighlighted(id: string) {
		if (!indicatorRange) {
			return activeId === id;
		}

		const startIndex = headingOrder.get(indicatorRange.startId);
		const endIndex = headingOrder.get(indicatorRange.endId);
		const currentIndex = headingOrder.get(id);

		if (startIndex === undefined || endIndex === undefined || currentIndex === undefined) {
			return activeId === id;
		}

		const min = Math.min(startIndex, endIndex);
		const max = Math.max(startIndex, endIndex);

		return currentIndex >= min && currentIndex <= max;
	}

	function manageToc(
		_node: HTMLElement,
		deps: { path: string; active: boolean; selector: string }
	) {
		let currentDeps = deps;

		const run = () => {
			clearCollectionWork();
			syncInitialHeadings();

			if (!currentDeps.active) {
				resetTocState({ clearHeadings: false });
				return;
			}

			collectFrame = window.requestAnimationFrame(() => {
				collectFrame = null;
				collectCleanup = collectHeadings();
			});
		};

		run();

		return {
			update(nextDeps: { path: string; active: boolean; selector: string }) {
				currentDeps = nextDeps;
				run();
			},
			destroy() {
				clearCollectionWork();
				resetTocState();
			}
		};
	}

	function observeLinksWrapper(node: HTMLOListElement, active: boolean) {
		let observer: ResizeObserver | null = null;

		const sync = (enabled: boolean) => {
			observer?.disconnect();
			observer = null;

			if (typeof window === 'undefined' || !enabled) return;

			observer = new ResizeObserver(() => {
				updateLayout();
				updateFadeState();
				updateIndicator();
			});

			observer.observe(node);
		};

		sync(active);

		return {
			update(nextActive: boolean) {
				sync(nextActive);
			},
			destroy() {
				observer?.disconnect();
			}
		};
	}

	// Attaches the scroll-box element reference and listens for scroll/resize
	// so the top/bottom fade overlays know when to appear, without altering
	// anything about the TOC's own internal layout/line-drawing logic.
	function observeTocScrollBox(node: HTMLDivElement) {
		tocScrollEl = node;
		updateFadeState();

		const onScroll = () => { updateFadeState(); };
		node.addEventListener('scroll', onScroll, { passive: true });

		fadeResizeObserver?.disconnect();
		fadeResizeObserver = null;
		if (typeof window !== 'undefined') {
			fadeResizeObserver = new ResizeObserver(() => { updateFadeState(); });
			fadeResizeObserver.observe(node);
		}

		return {
			destroy() {
				node.removeEventListener('scroll', onScroll);
				fadeResizeObserver?.disconnect();
				fadeResizeObserver = null;
				if (tocScrollEl === node) {
					tocScrollEl = null;
				}
			}
		};
	}
</script>

<svelte:window bind:innerWidth={viewportWidth} />

<div
	class="contents"
	{@attach fromAction(manageToc, () => ({
		path: currentPath,
		active: tocViewportActive,
		selector
	}))}
>
	{#if renderedHeadings.length > 0}
		<nav aria-label={title}>
			<div
				class="mb-2 flex items-center gap-2 text-xs font-medium tracking-wide text-foreground-muted/70 uppercase"
			>
				<TableOfContents size={16} />
				{title}
			</div>

			<!--
				Outer wrapper is `relative` so the fade overlays below can be
				positioned exactly over the scrollable box, without touching
				the internal structure (lines, dots, indicator) at all.
			-->
			<div class="relative">
				<div
					class="scrollbar-none overflow-y-auto"
					style={`max-height: ${maxHeight}; -webkit-mask-image: none;`}
					{@attach fromAction(observeTocScrollBox, () => undefined)}
				>
					<div class="relative mx-1 flex">
						<div
							class="pointer-events-none absolute top-0 left-[2.5px] h-full w-10"
							style={`
	                    mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${svgWidth.toString()} ${lineHeight.toString()}' width='${svgWidth.toString()}' height='${lineHeight.toString()}' preserveAspectRatio='none'%3E%3Cpath d='${svgPath}' stroke='black' stroke-width='1' fill='none'/%3E%3C/svg%3E");
                    -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${svgWidth.toString()} ${lineHeight.toString()}' width='${svgWidth.toString()}' height='${lineHeight.toString()}' preserveAspectRatio='none'%3E%3Cpath d='${svgPath}' stroke='black' stroke-width='1' fill='none'/%3E%3C/svg%3E");
                    mask-repeat: no-repeat;
                    -webkit-mask-repeat: no-repeat;
                    mask-position: left top;
                    -webkit-mask-position: left top;
                    mask-size: 100% 100%;
                    -webkit-mask-size: 100% 100%;
                `}
						>
							<div class="absolute inset-0 h-full w-full bg-border"></div>

							{#if indicatorHeight > 0}
								<div
									class="toc-active-line absolute left-0 w-full transition-[top,bottom] duration-450 ease-out"
									style={`
	                            top: ${indicatorTop.toString()}px;
	                            bottom: ${Math.max(0, lineHeight - indicatorBottom).toString()}px;
                        `}
								></div>
							{/if}
						</div>

						<ol
							id={linksWrapperId}
							class="relative flex flex-col text-sm"
							{@attach fromAction(observeLinksWrapper, () => tocViewportActive)}
						>
							{#each renderedHeadings as heading (heading.id)}
								<li
									class="transition-colors duration-150 ease-out"
									style={`padding-left: ${((heading.level - 2) * 12).toString()}px`}
								>
									<a
										href={`#${heading.id}`}
										onclick={() => {
											pulseDot(heading.id);
										}}
										class={cn(
											'flex items-center gap-2 py-1 font-medium tracking-normal transition-[color] duration-150 ease-out',
											isLinkHighlighted(heading.id)
												? 'text-accent'
												: 'text-foreground-muted hover:text-foreground'
										)}
										{@attach fromAction(registerLink, () => heading.id)}
									>
										<span
											aria-hidden="true"
											class={cn(
												'toc-dot relative size-1.5 flex-none rounded-full transition-[background-color,box-shadow,scale] duration-150 ease-out',
												isLinkHighlighted(heading.id) && 'toc-dot-active',
												pulsingDotIds.includes(heading.id) && 'toc-dot-pulse'
											)}
										></span>
										<span
											class={cn(
												'min-w-0 pl-1',
												isLinkHighlighted(heading.id) && 'text-accent'
											)}
										>
											{heading.text}
										</span>
									</a>
								</li>
							{/each}
						</ol>
					</div>
				</div>

				<!--
					Fade overlays: purely visual, positioned on top of the scroll box.
					They don't affect layout, line drawing, or indicator math at all —
					they just blur/hide overflow content into the page background color
					(#17181a) when the list is taller than its max-height.
				-->
				{#if canScrollUp}
					<div
						class="toc-fade toc-fade-top pointer-events-none absolute inset-x-0 top-0 h-10"
						aria-hidden="true"
					></div>
				{/if}
				{#if canScrollDown}
					<div
						class="toc-fade toc-fade-bottom pointer-events-none absolute inset-x-0 bottom-0 h-10"
						aria-hidden="true"
					></div>
				{/if}
			</div>
		</nav>
	{:else}
		<div class="hidden text-sm tracking-normal text-foreground-muted/70 lg:block">{emptyLabel}</div>
	{/if}
</div>

<style>
	.toc-active-line {
		background-image: linear-gradient(
			to bottom,
			transparent,
			oklch(from var(--color-accent) l c h / 0.68) 22%,
			var(--color-accent) 50%,
			oklch(from var(--color-accent) l c h / 0.68) 78%,
			transparent
		);
		filter: drop-shadow(0 0 6px oklch(from var(--color-accent) l c h / 0.38));
	}

	.toc-dot {
		background-color: var(--color-foreground-muted);
		box-shadow: 0 0 0 2px var(--color-background);
		opacity: 0.72;
	}

	.toc-dot-active {
		background-color: var(--color-accent);
		box-shadow:
			inset 0 1px oklch(from var(--color-white-fixed) l c h / 0.35),
			0 0 0 2px var(--color-background),
			0 0 10px oklch(from var(--color-accent) l c h / 0.38);
		opacity: 1;
	}

	.toc-dot-active::after {
		content: '';
		position: absolute;
		inset: 0;
		border-radius: 9999px;
		box-shadow: 0 0 9px oklch(from var(--color-accent) l c h / 0.5);
	}

	.toc-dot-pulse {
		animation: toc-dot-pulse 0.52s ease-out both;
	}

	@keyframes toc-dot-pulse {
		0% {
			transform: scale(1);
			box-shadow: 0 0 0 2px var(--color-background);
		}

		12% {
			transform: scale(1.15);
			background-color: var(--color-accent);
			box-shadow:
				0 0 0 2px var(--color-background),
				0 0 0 3px oklch(from var(--color-accent) l c h / 0.18),
				0 0 18px oklch(from var(--color-accent) l c h / 0.52);
		}

		100% {
			transform: scale(1);
		}
	}

	/* Hide the native scrollbar on the TOC scroll box so the fade
	   overlays are the only visible cue that there's more content. */
	.scrollbar-none {
		scrollbar-width: none;
		-ms-overflow-style: none;
	}

	.scrollbar-none::-webkit-scrollbar {
		display: none;
	}

	/* Fade-to-background overlays. Uses the same background variable as
	   whatever surface the TOC sits on (desktop sidebar, mobile panel, etc.)
	   so the fade color always matches exactly instead of a fixed hex —
	   content doesn't get clipped abruptly, it softly blurs into the
	   background and disappears. */
	.toc-fade {
		z-index: 1;
		background: var(--toc-fade-color, var(--color-background-inset, var(--color-background)));
	}

	.toc-fade-top {
		mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 100%);
		-webkit-mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 100%);
	}

	.toc-fade-bottom {
		mask-image: linear-gradient(to top, black 0%, black 15%, transparent 100%);
		-webkit-mask-image: linear-gradient(to top, black 0%, black 15%, transparent 100%);
	}
</style>

Особенности

  • Автоматический сбор заголовков h2, h3 со страницы
  • Активный индикатор — gradient glow-линия вдоль дерева
  • SVG-линии иерархии между заголовками
  • Иерархические отступы по уровням
  • Плавная прокрутка к разделу при клике
  • Pulse-анимация точки при навигации
  • Fade-оверлеи при переполнении списка
  • Отключается автоматически при узком viewport (< 1280px)

Использование

<script>
	import TableOfContents from '$lib/components/docs/TableOfContents.svelte';
</script>

<!-- Автоматический сбор заголовков -->
<TableOfContents />

<!-- С явно заданными заголовками -->
<TableOfContents
	title="Содержание"
	headings={[
		{ id: 'section-1', text: 'Раздел 1', level: 2 },
		{ id: 'section-2', text: 'Раздел 2', level: 2 },
		{ id: 'subsection', text: 'Подраздел', level: 3 }
	]}
/>
<script>
	import TableOfContents from '$lib/components/docs/TableOfContents.svelte';
</script>

<!-- Автоматический сбор заголовков -->
<TableOfContents />

<!-- С явно заданными заголовками -->
<TableOfContents
	title="Содержание"
	headings={[
		{ id: 'section-1', text: 'Раздел 1', level: 2 },
		{ id: 'section-2', text: 'Раздел 2', level: 2 },
		{ id: 'subsection', text: 'Подраздел', level: 3 }
	]}
/>

Интеграция с layout

Добавьте атрибут data-doc-content к контейнеру контента — компонент автоматически найдёт заголовки:

<div data-doc-content>
	<!-- Заголовки h2, h3 здесь -->
</div>
<div data-doc-content>
	<!-- Заголовки h2, h3 здесь -->
</div>

Пропсы

Пропс Тип По умолчанию Описание
selector string '[data-doc-content] h2, ...' CSS-селектор заголовков
title string 'On this page' Заголовок секции TOC
emptyLabel string 'No headings' Текст при пустом списке
minViewportWidth number 1280 Мин. ширина для активации
scrollContainerId string — ID контейнера прокрутки
headings ContentTocHeading[] — Явно заданные заголовки
maxHeight string '70vh' Макс. высота списка

Структура ContentTocHeading

interface ContentTocHeading {
	id: string;     // Якорь-ссылка (#id)
	text: string;   // Текст заголовка
	level: number;  // Уровень: 2 для h2, 3 для h3
}
interface ContentTocHeading {
	id: string;     // Якорь-ссылка (#id)
	text: string;   // Текст заголовка
	level: number;  // Уровень: 2 для h2, 3 для h3
}