@@ -31,6 +31,7 @@ import {
3131 AlertTriangle ,
3232 Share2 ,
3333 GitGraph ,
34+ List ,
3435} from 'lucide-react' ;
3536import { visit } from 'unist-util-visit' ;
3637import { useLayerStack } from '../contexts/LayerStackContext' ;
@@ -247,6 +248,37 @@ const countMarkdownTasks = (content: string): { open: number; closed: number } =
247248 } ;
248249} ;
249250
251+ // Interface for table of contents entries
252+ interface TocEntry {
253+ level : number ; // 1-6 for h1-h6
254+ text : string ;
255+ slug : string ;
256+ }
257+
258+ // Extract headings from markdown content for table of contents
259+ const extractHeadings = ( content : string ) : TocEntry [ ] => {
260+ const headings : TocEntry [ ] = [ ] ;
261+ const lines = content . split ( '\n' ) ;
262+
263+ for ( const line of lines ) {
264+ // Match ATX-style headings (# H1, ## H2, etc.)
265+ const match = line . match ( / ^ ( # { 1 , 6 } ) \s + ( .+ ) $ / ) ;
266+ if ( match ) {
267+ const level = match [ 1 ] . length ;
268+ const text = match [ 2 ] . trim ( ) ;
269+ // Generate slug same way rehype-slug does (lowercase, replace spaces with hyphens, remove special chars)
270+ const slug = text
271+ . toLowerCase ( )
272+ . replace ( / [ ^ \w \s - ] / g, '' )
273+ . replace ( / \s + / g, '-' )
274+ . replace ( / ^ - + | - + $ / g, '' ) ;
275+ headings . push ( { level, text, slug } ) ;
276+ }
277+ }
278+
279+ return headings ;
280+ } ;
281+
250282// Helper to resolve image path relative to markdown file directory
251283const resolveImagePath = ( src : string , markdownFilePath : string ) : string => {
252284 // If it's already a data URL or http(s) URL, return as-is
@@ -518,6 +550,7 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
518550 const [ showCopyNotification , setShowCopyNotification ] = useState ( false ) ;
519551 const [ showBackPopup , setShowBackPopup ] = useState ( false ) ;
520552 const [ showForwardPopup , setShowForwardPopup ] = useState ( false ) ;
553+ const [ showTocOverlay , setShowTocOverlay ] = useState ( false ) ;
521554 const backPopupTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
522555 const forwardPopupTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
523556 const [ currentMatchIndex , setCurrentMatchIndex ] = useState ( 0 ) ;
@@ -601,6 +634,12 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
601634 return counts ;
602635 } , [ isMarkdown , file ?. content ] ) ;
603636
637+ // Extract table of contents entries for markdown files
638+ const tocEntries = useMemo ( ( ) => {
639+ if ( ! isMarkdown || ! file ?. content ) return [ ] ;
640+ return extractHeadings ( file . content ) ;
641+ } , [ isMarkdown , file ?. content ] ) ;
642+
604643 // Memoize file tree indices to avoid O(n) traversal on every render
605644 const fileTreeIndices = useMemo ( ( ) => {
606645 if ( fileTree && fileTree . length > 0 ) {
@@ -875,11 +914,16 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
875914 // Auto-focus on mount and when file changes so keyboard shortcuts work immediately
876915 useEffect ( ( ) => {
877916 containerRef . current ?. focus ( ) ;
917+ // Close TOC overlay when file changes
918+ setShowTocOverlay ( false ) ;
878919 } , [ file ?. path ] ) ; // Run on mount and when navigating to a different file
879920
880921 // Helper to handle escape key - shows confirmation modal if there are unsaved changes
881922 const handleEscapeRequest = useCallback ( ( ) => {
882- if ( searchOpen ) {
923+ if ( showTocOverlay ) {
924+ setShowTocOverlay ( false ) ;
925+ containerRef . current ?. focus ( ) ;
926+ } else if ( searchOpen ) {
883927 setSearchOpen ( false ) ;
884928 setSearchQuery ( '' ) ;
885929 // Refocus container so keyboard navigation (arrow keys) still works
@@ -890,7 +934,7 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
890934 } else {
891935 onClose ( ) ;
892936 }
893- } , [ searchOpen , hasChanges , onClose ] ) ;
937+ } , [ showTocOverlay , searchOpen , hasChanges , onClose ] ) ;
894938
895939 // Register layer on mount - only register once, use updateLayerHandler for handler changes
896940 // Note: handleEscapeRequest is intentionally NOT in the dependency array to prevent
@@ -1993,6 +2037,99 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
19932037 </ SyntaxHighlighter >
19942038 </ div >
19952039 ) }
2040+
2041+ { /* Table of Contents Floating Button and Overlay - Only for markdown in preview mode */ }
2042+ { isMarkdown && ! markdownEditMode && tocEntries . length > 0 && (
2043+ < >
2044+ { /* Floating TOC Button */ }
2045+ < button
2046+ onClick = { ( ) => setShowTocOverlay ( ! showTocOverlay ) }
2047+ className = "absolute bottom-4 left-4 p-2.5 rounded-full shadow-lg transition-all duration-200 hover:scale-105 z-10"
2048+ style = { {
2049+ backgroundColor : showTocOverlay ? theme . colors . accent : theme . colors . bgSidebar ,
2050+ color : showTocOverlay ? theme . colors . accentForeground : theme . colors . textMain ,
2051+ border : `1px solid ${ theme . colors . border } ` ,
2052+ } }
2053+ title = "Table of Contents"
2054+ >
2055+ < List className = "w-5 h-5" />
2056+ </ button >
2057+
2058+ { /* TOC Overlay */ }
2059+ { showTocOverlay && (
2060+ < div
2061+ className = "absolute bottom-16 left-4 rounded-lg shadow-xl overflow-hidden z-20 animate-in fade-in slide-in-from-bottom-2 duration-200"
2062+ style = { {
2063+ backgroundColor : theme . colors . bgSidebar ,
2064+ border : `1px solid ${ theme . colors . border } ` ,
2065+ maxHeight : '70%' ,
2066+ minWidth : '200px' ,
2067+ maxWidth : '350px' ,
2068+ } }
2069+ >
2070+ { /* TOC Header */ }
2071+ < div
2072+ className = "px-3 py-2 border-b flex items-center justify-between"
2073+ style = { { borderColor : theme . colors . border } }
2074+ >
2075+ < span
2076+ className = "text-xs font-medium uppercase tracking-wide"
2077+ style = { { color : theme . colors . textDim } }
2078+ >
2079+ Contents
2080+ </ span >
2081+ < span
2082+ className = "text-[10px]"
2083+ style = { { color : theme . colors . textDim } }
2084+ >
2085+ { tocEntries . length } headings
2086+ </ span >
2087+ </ div >
2088+ { /* TOC Entries */ }
2089+ < div className = "overflow-y-auto p-1" style = { { maxHeight : 'calc(70vh - 40px)' } } >
2090+ { tocEntries . map ( ( entry , index ) => {
2091+ // Get color based on heading level (match the prose styles)
2092+ const levelColors : Record < number , string > = {
2093+ 1 : theme . colors . accent ,
2094+ 2 : theme . colors . success ,
2095+ 3 : theme . colors . warning ,
2096+ 4 : theme . colors . textMain ,
2097+ 5 : theme . colors . textMain ,
2098+ 6 : theme . colors . textDim ,
2099+ } ;
2100+ const headingColor = levelColors [ entry . level ] || theme . colors . textMain ;
2101+
2102+ return (
2103+ < button
2104+ key = { `${ entry . slug } -${ index } ` }
2105+ onClick = { ( ) => {
2106+ // Find and scroll to the heading
2107+ const targetElement = markdownContainerRef . current ?. querySelector (
2108+ `#${ CSS . escape ( entry . slug ) } `
2109+ ) ;
2110+ if ( targetElement ) {
2111+ targetElement . scrollIntoView ( { behavior : 'smooth' , block : 'start' } ) ;
2112+ }
2113+ setShowTocOverlay ( false ) ;
2114+ } }
2115+ className = "w-full px-2 py-1.5 text-left text-sm rounded hover:bg-white/10 transition-colors truncate flex items-center gap-1"
2116+ style = { {
2117+ color : headingColor ,
2118+ paddingLeft : `${ ( entry . level - 1 ) * 12 + 8 } px` ,
2119+ opacity : entry . level > 3 ? 0.85 : 1 ,
2120+ fontSize : entry . level === 1 ? '0.875rem' : entry . level === 2 ? '0.8125rem' : '0.75rem' ,
2121+ } }
2122+ title = { entry . text }
2123+ >
2124+ < span className = "truncate" > { entry . text } </ span >
2125+ </ button >
2126+ ) ;
2127+ } ) }
2128+ </ div >
2129+ </ div >
2130+ ) }
2131+ </ >
2132+ ) }
19962133 </ div >
19972134
19982135 { /* Copy Notification Toast */ }
0 commit comments