Spaces:
Running
Running
File size: 3,375 Bytes
35a414b |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
/**
* T037-T047: Table of Contents hook
* Extracts headings from rendered markdown and provides scroll navigation
*/
import { useEffect, useState, useCallback } from 'react';
export interface Heading {
id: string;
text: string;
level: number;
}
interface UseTableOfContentsReturn {
headings: Heading[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
scrollToHeading: (id: string) => void;
}
/**
* T040: Slugify text to create valid HTML IDs
* Handles duplicates by appending -2, -3, etc.
*/
export function slugify(text: string, existingSlugs: Set<string> = new Set()): string {
// T040: Basic slugification
const baseSlug = text
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '');
// T050: Handle duplicates
if (!existingSlugs.has(baseSlug)) {
return baseSlug;
}
let counter = 2;
let uniqueSlug = `${baseSlug}-${counter}`;
while (existingSlugs.has(uniqueSlug)) {
counter++;
uniqueSlug = `${baseSlug}-${counter}`;
}
return uniqueSlug;
}
/**
* T037-T047: Hook for managing TOC state and heading extraction
*/
export function useTableOfContents(): UseTableOfContentsReturn {
// T042: TOC panel open state
const [isOpen, setIsOpenState] = useState<boolean>(() => {
// T043: Restore from localStorage
const saved = localStorage.getItem('toc-panel-open');
return saved ? JSON.parse(saved) : false;
});
// T041: Store extracted headings
const [headings, setHeadings] = useState<Heading[]>([]);
// T043: Persist panel state to localStorage
const setIsOpen = useCallback((open: boolean) => {
setIsOpenState(open);
localStorage.setItem('toc-panel-open', JSON.stringify(open));
}, []);
// T041: Extract headings from DOM (called after render)
const extractHeadings = useCallback(() => {
const headingElements = document.querySelectorAll('h1, h2, h3');
const extracted: Heading[] = [];
headingElements.forEach((element) => {
const id = element.id;
const text = element.textContent || '';
const level = parseInt(element.tagName.charAt(1));
if (id && text) {
extracted.push({ id, text, level });
}
});
setHeadings(extracted);
}, []);
// T046-T047: Scroll to heading with smooth behavior and accessibility
const scrollToHeading = useCallback((id: string) => {
const element = document.getElementById(id);
if (element) {
// T047: Respect prefers-reduced-motion
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
element.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'start'
});
}
}, []);
// Re-extract headings when content changes
useEffect(() => {
// Use MutationObserver to detect when markdown is rendered
const observer = new MutationObserver(() => {
extractHeadings();
});
// Observe the markdown content container
const contentContainer = document.querySelector('.prose');
if (contentContainer) {
observer.observe(contentContainer, {
childList: true,
subtree: true,
});
// Initial extraction
extractHeadings();
}
return () => {
observer.disconnect();
};
}, [extractHeadings]);
return {
headings,
isOpen,
setIsOpen,
scrollToHeading,
};
}
|