Skip to content

Commit 9103538

Browse files
committed
MAESTRO: Add table of contents jumper for markdown file preview
- Added floating TOC button (List icon) in bottom-left of markdown preview - TOC overlay shows all H1-H6 headings with proper indentation by level - Headings are color-coded to match the markdown prose heading colors - Maximum overlay height is 70% of viewport for readability - Clicking a heading scrolls to that section and closes the overlay - Escape key closes the overlay - Only visible in preview mode when document has headings - Added 6 unit tests for the TOC feature
1 parent fdd5768 commit 9103538

File tree

2 files changed

+237
-2
lines changed

2 files changed

+237
-2
lines changed

src/__tests__/renderer/components/FilePreview.test.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock('lucide-react', () => ({
2222
AlertTriangle: () => <span data-testid="alert-icon">AlertTriangle</span>,
2323
Share2: () => <span data-testid="share-icon">Share2</span>,
2424
GitGraph: () => <span data-testid="gitgraph-icon">GitGraph</span>,
25+
List: () => <span data-testid="list-icon">List</span>,
2526
}));
2627

2728
// Mock react-markdown
@@ -386,4 +387,101 @@ describe('FilePreview', () => {
386387
expect(screen.queryByText(/tokens/)).not.toBeInTheDocument();
387388
});
388389
});
390+
391+
describe('table of contents', () => {
392+
it('shows TOC button for markdown files with headings in preview mode', () => {
393+
const markdownWithHeadings = '# Heading 1\n## Heading 2\n### Heading 3\nContent here';
394+
render(
395+
<FilePreview
396+
{...defaultProps}
397+
file={{ name: 'doc.md', content: markdownWithHeadings, path: '/test/doc.md' }}
398+
markdownEditMode={false}
399+
/>
400+
);
401+
402+
expect(screen.getByTitle('Table of Contents')).toBeInTheDocument();
403+
expect(screen.getByTestId('list-icon')).toBeInTheDocument();
404+
});
405+
406+
it('does not show TOC button for markdown without headings', () => {
407+
const markdownNoHeadings = 'This is just plain text.\nNo headings here.';
408+
render(
409+
<FilePreview
410+
{...defaultProps}
411+
file={{ name: 'doc.md', content: markdownNoHeadings, path: '/test/doc.md' }}
412+
markdownEditMode={false}
413+
/>
414+
);
415+
416+
expect(screen.queryByTitle('Table of Contents')).not.toBeInTheDocument();
417+
});
418+
419+
it('does not show TOC button in edit mode', () => {
420+
const markdownWithHeadings = '# Heading 1\n## Heading 2';
421+
render(
422+
<FilePreview
423+
{...defaultProps}
424+
file={{ name: 'doc.md', content: markdownWithHeadings, path: '/test/doc.md' }}
425+
markdownEditMode={true}
426+
/>
427+
);
428+
429+
expect(screen.queryByTitle('Table of Contents')).not.toBeInTheDocument();
430+
});
431+
432+
it('does not show TOC button for non-markdown files', () => {
433+
const jsonContent = '{"title": "Not markdown"}';
434+
render(
435+
<FilePreview
436+
{...defaultProps}
437+
file={{ name: 'config.json', content: jsonContent, path: '/test/config.json' }}
438+
/>
439+
);
440+
441+
expect(screen.queryByTitle('Table of Contents')).not.toBeInTheDocument();
442+
});
443+
444+
it('opens TOC overlay when button is clicked', () => {
445+
const markdownWithHeadings = '# Heading 1\n## Heading 2\n### Heading 3';
446+
render(
447+
<FilePreview
448+
{...defaultProps}
449+
file={{ name: 'doc.md', content: markdownWithHeadings, path: '/test/doc.md' }}
450+
markdownEditMode={false}
451+
/>
452+
);
453+
454+
const tocButton = screen.getByTitle('Table of Contents');
455+
fireEvent.click(tocButton);
456+
457+
// TOC overlay should be visible with heading entries
458+
expect(screen.getByText('Contents')).toBeInTheDocument();
459+
expect(screen.getByText('3 headings')).toBeInTheDocument();
460+
expect(screen.getByText('Heading 1')).toBeInTheDocument();
461+
expect(screen.getByText('Heading 2')).toBeInTheDocument();
462+
expect(screen.getByText('Heading 3')).toBeInTheDocument();
463+
});
464+
465+
it('closes TOC overlay when clicking a heading entry', () => {
466+
const markdownWithHeadings = '# Heading 1\n## Heading 2';
467+
render(
468+
<FilePreview
469+
{...defaultProps}
470+
file={{ name: 'doc.md', content: markdownWithHeadings, path: '/test/doc.md' }}
471+
markdownEditMode={false}
472+
/>
473+
);
474+
475+
// Open TOC
476+
const tocButton = screen.getByTitle('Table of Contents');
477+
fireEvent.click(tocButton);
478+
479+
// Click a heading entry
480+
const headingEntry = screen.getByText('Heading 1');
481+
fireEvent.click(headingEntry);
482+
483+
// TOC overlay should close
484+
expect(screen.queryByText('Contents')).not.toBeInTheDocument();
485+
});
486+
});
389487
});

src/renderer/components/FilePreview.tsx

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
AlertTriangle,
3232
Share2,
3333
GitGraph,
34+
List,
3435
} from 'lucide-react';
3536
import { visit } from 'unist-util-visit';
3637
import { 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
251283
const 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

Comments
 (0)