MAESTRO: Fix ToC blocking file content scroll

Replace backdrop-based click-outside detection with useClickOutside hook.
The previous fixed backdrop div intercepted all pointer events including
wheel events, preventing file content from scrolling while ToC was open.
Now wheel events over file content scroll the content, while wheel events
over the ToC scroll the ToC list.
This commit is contained in:
Pedram Amini
2026-02-02 17:17:41 -06:00
parent 2aa3cc701e
commit a7117f3d41
2 changed files with 26 additions and 29 deletions

View File

@@ -753,9 +753,9 @@ print("world")
expect(screen.getByText('Contents')).toBeInTheDocument();
});
it('stops wheel event propagation on TOC overlay to isolate scrolling', () => {
it('closes TOC when clicking outside of it', async () => {
const markdownWithHeadings = '# Heading 1\n## Heading 2\n## Heading 3';
render(
const { container } = render(
<FilePreview
{...defaultProps}
file={{ name: 'doc.md', content: markdownWithHeadings, path: '/test/doc.md' }}
@@ -767,23 +767,18 @@ print("world")
const tocButton = screen.getByTitle('Table of Contents');
fireEvent.click(tocButton);
// Find the TOC container by looking for the element with the heading entries
const tocContainer = screen.getByText('Contents').closest('div')?.parentElement;
expect(tocContainer).toBeInTheDocument();
// Verify TOC is open
expect(screen.getByText('Contents')).toBeInTheDocument();
// Create a wheel event and verify it doesn't propagate
const wheelEvent = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 100,
});
const stopPropagationSpy = vi.spyOn(wheelEvent, 'stopPropagation');
// Wait for the delay in useClickOutside hook
await new Promise((resolve) => setTimeout(resolve, 10));
// Dispatch the wheel event on the TOC container
tocContainer?.dispatchEvent(wheelEvent);
// Click outside the TOC (on the main container)
const mainContainer = container.firstChild as HTMLElement;
fireEvent.mouseDown(mainContainer);
// The onWheel handler should have called stopPropagation
expect(stopPropagationSpy).toHaveBeenCalled();
// TOC should be closed
expect(screen.queryByText('Contents')).not.toBeInTheDocument();
});
});

View File

@@ -689,6 +689,8 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
const matchElementsRef = useRef<HTMLElement[]>([]);
const cancelButtonRef = useRef<HTMLButtonElement>(null);
const scrollSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const tocButtonRef = useRef<HTMLButtonElement>(null);
const tocOverlayRef = useRef<HTMLDivElement>(null);
// Expose focus method to parent via ref
useImperativeHandle(
@@ -1158,6 +1160,11 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
// Disable click-outside in tab mode - tabs should only close via explicit user action
useClickOutside(containerRef, handleEscapeRequest, !!file && !isTabMode, { delay: true });
// Click outside ToC overlay to dismiss (exclude both overlay and the toggle button)
// Use delay to prevent the click that opened it from immediately closing it
const closeTocOverlay = useCallback(() => setShowTocOverlay(false), []);
useClickOutside<HTMLElement>([tocOverlayRef, tocButtonRef], closeTocOverlay, showTocOverlay, { delay: true });
// Keep search input focused when search is open
useEffect(() => {
if (searchOpen && searchInputRef.current) {
@@ -2231,6 +2238,7 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
<>
{/* Floating TOC Button */}
<button
ref={tocButtonRef}
onClick={() => setShowTocOverlay(!showTocOverlay)}
className="absolute bottom-4 right-4 p-2.5 rounded-full shadow-lg transition-all duration-200 hover:scale-105 z-10"
style={{
@@ -2243,19 +2251,14 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
<List className="w-5 h-5" />
</button>
{/* TOC Overlay */}
{/* TOC Overlay - click outside handled by useClickOutside hook */}
{showTocOverlay && (
<>
{/* Click-outside backdrop */}
<div
className="fixed inset-0 z-15"
onClick={() => setShowTocOverlay(false)}
/>
<div
className="absolute bottom-16 right-4 rounded-lg shadow-xl overflow-hidden z-20 animate-in fade-in slide-in-from-bottom-2 duration-200 flex flex-col"
style={{
backgroundColor: theme.colors.bgSidebar,
border: `1px solid ${theme.colors.border}`,
<div
ref={tocOverlayRef}
className="absolute bottom-16 right-4 rounded-lg shadow-xl overflow-hidden z-20 animate-in fade-in slide-in-from-bottom-2 duration-200 flex flex-col"
style={{
backgroundColor: theme.colors.bgSidebar,
border: `1px solid ${theme.colors.border}`,
maxHeight: 'calc(70vh - 80px)',
minWidth: '200px',
maxWidth: '350px',
@@ -2363,7 +2366,6 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
<span>Bottom</span>
</button>
</div>
</>
)}
</>
)}