import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import { readTextFile } from "@tauri-apps/plugin-fs"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import MarkdownIt from "markdown-it"; import hljs from "highlight.js"; import { motion, AnimatePresence } from "framer-motion"; import TaskLists from "markdown-it-task-lists"; import sup from "markdown-it-sup"; import sub from "markdown-it-sub"; import mark from "markdown-it-mark"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import "overlayscrollbars/overlayscrollbars.css"; import { Minus, Plus, Square, X, FileText, ChevronLeft, ChevronRight, FolderOpen, FileDown } from "lucide-react"; interface Tab { id: string; title: string; content: string; path: string; } interface Heading { id: string; text: string; level: number; } const md: MarkdownIt = new MarkdownIt({ html: true, breaks: true, linkify: true, typographer: true, highlight: function(str: string, lang: string): string { if (lang && hljs.getLanguage(lang)) { try { return '
' + hljs.highlight(str, { language: lang, ignoreIllegals: true }).value + '
'; } catch { } } return '
' + md.utils.escapeHtml(str) + '
'; } }).use(TaskLists).use(sup).use(sub).use(mark); const materialEase = [0.4, 0, 0.2, 1] as const; const smoothTransition = { duration: 0.2, ease: materialEase }; // Saved selection range - set in mousedown (before browser clears it), read in contextmenu handler let _savedSelectionRange: Range | null = null; // Reusable kinetic scroll + iOS overscroll setup for any scrollable area function setupKineticScroll( viewport: HTMLElement, contentEl: HTMLElement, kineticDragRef: { current: boolean } ): () => void { const DRAG_THRESHOLD = 5; let isTracking = false; let isDragging = false; let startX = 0; let startY = 0; let startScrollTop = 0; let velocityY = 0; let lastY = 0; let lastTime = 0; let momentumRaf: number | null = null; let springRaf: number | null = null; let overscrollOffset = 0; const cancelAnimations = () => { if (momentumRaf) { cancelAnimationFrame(momentumRaf); momentumRaf = null; } if (springRaf) { cancelAnimationFrame(springRaf); springRaf = null; } }; const rubberBand = (offset: number, dimension: number): number => { const c = 0.55; const x = Math.abs(offset); return Math.sign(offset) * (1.0 - (1.0 / ((x * c / dimension) + 1.0))) * dimension; }; const setOverscroll = (offset: number) => { overscrollOffset = offset; contentEl.style.transform = offset !== 0 ? `translateY(${offset}px)` : ''; contentEl.style.transition = ''; }; const springBack = () => { cancelAnimations(); let position = overscrollOffset; let velocity = 0; const stiffness = 60; const damping = 14; const dt = 1 / 60; const animate = () => { const acceleration = -stiffness * position - damping * velocity; velocity += acceleration * dt; position += velocity * dt; if (Math.abs(position) < 0.5 && Math.abs(velocity) < 0.5) { setOverscroll(0); return; } setOverscroll(position); springRaf = requestAnimationFrame(animate); }; springRaf = requestAnimationFrame(animate); }; const applyMomentum = () => { cancelAnimations(); let v = velocityY; const friction = 0.99; const animate = () => { v *= friction; if (Math.abs(v) < 0.05) return; const desiredTop = viewport.scrollTop - v; // Top edge if (desiredTop < 0) { viewport.scrollTop = 0; const stretch = rubberBand(Math.abs(v) * 10, viewport.clientHeight); setOverscroll(Math.abs(stretch)); springBack(); return; } // Set scroll and let browser clamp to the real maximum viewport.scrollTop = desiredTop; const actualTop = viewport.scrollTop; // Bottom edge: browser clamped scrollTop below what we asked for if (desiredTop - actualTop > 1) { const stretch = rubberBand(Math.abs(v) * 10, viewport.clientHeight); setOverscroll(-Math.abs(stretch)); springBack(); return; } momentumRaf = requestAnimationFrame(animate); }; momentumRaf = requestAnimationFrame(animate); }; const onMouseDown = (e: MouseEvent) => { if (e.button !== 2) return; const target = e.target as HTMLElement; if (target.closest('.os-scrollbar')) return; // If text is selected, don't start kinetic scroll const sel = window.getSelection(); if (sel && sel.toString().length > 0) return; cancelAnimations(); if (overscrollOffset !== 0) setOverscroll(0); isTracking = true; isDragging = false; startX = e.clientX; startY = e.clientY; startScrollTop = viewport.scrollTop; lastY = e.clientY; lastTime = performance.now(); velocityY = 0; }; const onMouseMove = (e: MouseEvent) => { if (!isTracking) return; if (!isDragging) { const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.sqrt(dx * dx + dy * dy) < DRAG_THRESHOLD) return; isDragging = true; document.body.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; } e.preventDefault(); const now = performance.now(); const dt = now - lastTime; const dy = e.clientY - lastY; if (dt > 0) { velocityY = velocityY * 0.85 + (dy / dt) * 16 * 0.15; } lastY = e.clientY; lastTime = now; const scrollDelta = startY - e.clientY; const desiredTop = startScrollTop + scrollDelta; // Top edge if (desiredTop < 0) { viewport.scrollTop = 0; setOverscroll(rubberBand(Math.abs(desiredTop), viewport.clientHeight)); } else { // Set scroll and let browser clamp viewport.scrollTop = desiredTop; const actualTop = viewport.scrollTop; // Bottom edge: browser clamped if (desiredTop - actualTop > 1) { setOverscroll(-rubberBand(desiredTop - actualTop, viewport.clientHeight)); } else { if (overscrollOffset !== 0) setOverscroll(0); } } }; const onMouseUp = () => { if (!isTracking) return; isTracking = false; if (isDragging) { isDragging = false; kineticDragRef.current = true; document.body.style.cursor = ''; document.body.style.userSelect = ''; if (overscrollOffset !== 0) { springBack(); } else if (Math.abs(velocityY) > 0.5) { applyMomentum(); } } }; viewport.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); return () => { cancelAnimations(); viewport.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; contentEl.style.transform = ''; }; } function App() { const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); const [showSidebar, setShowSidebar] = useState(false); const [showSearch, setShowSearch] = useState(false); const [focusMode, setFocusMode] = useState(true); const [isDraggingOver, setIsDraggingOver] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchMatches, setSearchMatches] = useState([]); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [headings, setHeadings] = useState([]); const [menuOpen, setMenuOpen] = useState(null); const [menuFocusIndex, setMenuFocusIndex] = useState(0); const menuItems = ['file', 'view', 'help'] as const; const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showAboutModal, setShowAboutModal] = useState(false); const [zoom, setZoom] = useState(100); const [uiZoom, setUiZoom] = useState(() => { const saved = localStorage.getItem('vesper-ui-zoom'); return saved ? parseInt(saved, 10) : 100; }); const [contentWidth, setContentWidth] = useState(680); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem('vesper-sidebar-width'); return saved ? parseInt(saved, 10) : 220; }); const [searchTriggerCount, setSearchTriggerCount] = useState(0); const [resizeStartState, setResizeStartState] = useState<{ startX: number; startWidth: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; selectedText: string } | null>(null); const [selectionRects, setSelectionRects] = useState<{ left: number; top: number; width: number; height: number }[]>([]); const contentRef = useRef(null); const sidebarRef = useRef(null); const searchInputRef = useRef(null); const menuItemRefs = useRef<{ [key: string]: HTMLButtonElement | null }>({}); const kineticDragActiveRef = useRef(false); const contextMenuRef = useRef(null); const tabScrollRef = useRef(null); const [tabScrollState, setTabScrollState] = useState({ canLeft: false, canRight: false }); const tabsRef = useRef(tabs); tabsRef.current = tabs; const [appWindow, setAppWindow] = useState | null>(null); const activeTab = tabs.find(t => t.id === activeTabId) || null; useEffect(() => { setAppWindow(getCurrentWindow()); }, []); // Capture-phase mousedown: save selection range before browser clears it useEffect(() => { const saveSelection = (e: MouseEvent) => { if (e.button === 2) { const sel = window.getSelection(); if (sel && sel.rangeCount > 0 && sel.toString().length > 0) { _savedSelectionRange = sel.getRangeAt(0).cloneRange(); } else { _savedSelectionRange = null; } } }; document.addEventListener('mousedown', saveSelection, true); return () => document.removeEventListener('mousedown', saveSelection, true); }, []); useEffect(() => { const handleContextMenu = (e: MouseEvent) => { e.preventDefault(); if (kineticDragActiveRef.current) { kineticDragActiveRef.current = false; return; } if (activeTab) { const selectedText = window.getSelection()?.toString() || ''; const zf = uiZoom / 100; const x = e.clientX / zf; const y = e.clientY / zf; // Build selection overlay rects from saved range (before browser cleared it) let rects: { left: number; top: number; width: number; height: number }[] = []; if (selectedText && _savedSelectionRange) { const clientRects = _savedSelectionRange.getClientRects(); rects = Array.from(clientRects).map(r => ({ left: r.left / zf, top: r.top / zf, width: r.width / zf, height: r.height / zf })); } setContextMenu({ x, y, selectedText }); setSelectionRects(rects); } }; const handleClick = () => setContextMenu(null); document.addEventListener('contextmenu', handleContextMenu); document.addEventListener('click', handleClick); return () => { document.removeEventListener('contextmenu', handleContextMenu); document.removeEventListener('click', handleClick); }; }, [activeTab, uiZoom]); // Open external links in default OS browser useEffect(() => { const handleLinkClick = (e: MouseEvent) => { const anchor = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null; if (!anchor) return; const href = anchor.getAttribute('href'); if (href && /^https?:\/\//.test(href)) { e.preventDefault(); openUrl(href); } }; document.addEventListener('click', handleLinkClick); return () => document.removeEventListener('click', handleLinkClick); }, []); // Clear selection overlay when context menu closes useEffect(() => { if (!contextMenu) setSelectionRects(prev => prev.length > 0 ? [] : prev); }, [contextMenu]); // Clamp context menu within window bounds after render useLayoutEffect(() => { const menu = contextMenuRef.current; if (!menu || !contextMenu) return; const zf = uiZoom / 100; // Menu rect is in viewport pixels (already scaled by zoom) const rect = menu.getBoundingClientRect(); // Convert menu dimensions to zoomed coordinate space const menuW = rect.width / zf; const menuH = rect.height / zf; // Window dimensions in zoomed coordinate space const winW = window.innerWidth / zf; const winH = window.innerHeight / zf; let x = contextMenu.x; let y = contextMenu.y; if (x + menuW > winW) x = winW - menuW; if (y + menuH > winH) y = winH - menuH; if (x < 0) x = 0; if (y < 0) y = 0; menu.style.left = `${x}px`; menu.style.top = `${y}px`; }, [contextMenu, uiZoom]); const parseHeadings = useCallback((content: string) => { const headingRegex = /^(#{1,6})\s+(.+)$/gm; const parsed: Heading[] = []; let match; while ((match = headingRegex.exec(content))) { parsed.push({ id: match[2].toLowerCase().replace(/[^\w]+/g, '-'), text: match[2], level: match[1].length }); } setHeadings(parsed); }, []); // Render markdown to HTML - memoized so it only re-runs when content changes const renderedHtml = useMemo(() => { if (!activeTab) return ''; return md.render(activeTab.content); }, [activeTab]); // Inject search highlights into the HTML string (survives React re-renders) const { highlightedHtml, highlightCount } = useMemo(() => { if (!searchQuery || !renderedHtml) return { highlightedHtml: renderedHtml, highlightCount: 0 }; const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(escaped, 'gi'); let count = 0; // Split on HTML tags, only highlight text segments const parts = renderedHtml.split(/(<[^>]*>)/); const highlighted = parts.map(part => { if (part.startsWith('<')) return part; return part.replace(regex, (match) => { const idx = count++; return `${match}`; }); }).join(''); return { highlightedHtml: highlighted, highlightCount: count }; }, [renderedHtml, searchQuery]); // Sync highlight count with search matches state useEffect(() => { setSearchMatches(Array.from({ length: highlightCount }, (_, i) => i)); if (highlightCount === 0) setCurrentMatchIndex(0); }, [highlightCount]); // Scroll to active match and toggle active class useEffect(() => { const container = document.querySelector('.markdown-content'); if (!container || highlightCount === 0) return; container.querySelectorAll('.search-highlight-active').forEach(el => { el.classList.remove('search-highlight-active'); }); const active = container.querySelector(`[data-match-index="${currentMatchIndex}"]`); if (active) { active.classList.add('search-highlight-active'); active.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [currentMatchIndex, highlightCount, searchTriggerCount]); const updateTabScrollState = useCallback(() => { const el = tabScrollRef.current; if (!el) { setTabScrollState({ canLeft: false, canRight: false }); return; } setTabScrollState({ canLeft: el.scrollLeft > 0, canRight: el.scrollLeft < el.scrollWidth - el.clientWidth - 1, }); }, []); const tabScrollInterval = useRef | null>(null); const startScrollingTabs = useCallback((dir: 'left' | 'right') => { const el = tabScrollRef.current; if (!el) return; const step = dir === 'left' ? -8 : 8; el.scrollBy({ left: step }); updateTabScrollState(); tabScrollInterval.current = setInterval(() => { el.scrollBy({ left: step }); updateTabScrollState(); }, 16); }, [updateTabScrollState]); const stopScrollingTabs = useCallback(() => { if (tabScrollInterval.current) { clearInterval(tabScrollInterval.current); tabScrollInterval.current = null; } }, []); const scrollTabsToEnd = useCallback(() => { const el = tabScrollRef.current; if (!el) return; requestAnimationFrame(() => { el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); setTimeout(updateTabScrollState, 300); }); }, [updateTabScrollState]); const scrollToTabIndex = useCallback((index: number) => { const el = tabScrollRef.current; if (!el) return; requestAnimationFrame(() => { const tab = el.children[0]?.children[index] as HTMLElement | undefined; if (tab) tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); setTimeout(updateTabScrollState, 300); }); }, [updateTabScrollState]); // Tauri native drag-and-drop (bypasses browser event interception) useEffect(() => { let unlisten: (() => void) | undefined; getCurrentWebview().onDragDropEvent(async (event) => { if (event.payload.type === 'enter') { setIsDraggingOver(true); } else if (event.payload.type === 'leave') { setIsDraggingOver(false); } else if (event.payload.type === 'drop') { setIsDraggingOver(false); const paths = event.payload.paths; const mdPath = paths.find((p: string) => p.endsWith('.md') || p.endsWith('.markdown') || p.endsWith('.txt')); if (mdPath) { try { const existing = tabsRef.current.find(t => t.path === mdPath); if (existing) { setActiveTabId(existing.id); parseHeadings(existing.content); scrollToTabIndex(tabsRef.current.indexOf(existing)); } else { const content = await readTextFile(mdPath); const title = mdPath.split(/[/\\]/).pop() || 'Untitled'; const newTab: Tab = { id: Date.now().toString(), title, content, path: mdPath }; setTabs(prev => [...prev, newTab]); setActiveTabId(newTab.id); parseHeadings(content); scrollTabsToEnd(); } } catch (err) { console.error('Failed to read dropped file:', err); } } } }).then(fn => { unlisten = fn; }); return () => { unlisten?.(); }; }, [parseHeadings]); // Handle file opened via OS file association (e.g. double-clicking a .md file) useEffect(() => { invoke<{ path: string; content: string } | null>('get_cli_file').then(result => { if (!result) return; const title = result.path.split(/[/\\]/).pop() || 'Untitled'; const newTab: Tab = { id: Date.now().toString(), title, content: result.content, path: result.path }; setTabs(prev => [...prev, newTab]); setActiveTabId(newTab.id); parseHeadings(result.content); }).catch(err => console.error('Failed to open file from CLI args:', err)); }, []); // Handle files opened from a second instance (single-instance plugin forwards them here) useEffect(() => { let unlisten: (() => void) | undefined; listen<{ path: string; content: string }>('open-file', (event) => { const { path: filePath, content } = event.payload; const existing = tabsRef.current.find(t => t.path === filePath); if (existing) { setActiveTabId(existing.id); parseHeadings(existing.content); } else { const title = filePath.split(/[/\\]/).pop() || 'Untitled'; const newTab: Tab = { id: Date.now().toString(), title, content, path: filePath }; setTabs(prev => [...prev, newTab]); setActiveTabId(newTab.id); parseHeadings(content); } }).then(fn => { unlisten = fn; }); return () => { unlisten?.(); }; }, [parseHeadings]); const handleOpenDialog = useCallback(async () => { try { const selected = await open({ multiple: false, filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'txt'] }] }); if (selected) { const existing = tabsRef.current.find(t => t.path === selected); if (existing) { setActiveTabId(existing.id); parseHeadings(existing.content); scrollToTabIndex(tabsRef.current.indexOf(existing)); } else { const content = await readTextFile(selected); const newTab: Tab = { id: Date.now().toString(), title: selected.split(/[/\\]/).pop() || 'Untitled', content, path: selected }; setTabs(prev => [...prev, newTab]); setActiveTabId(newTab.id); parseHeadings(content); scrollTabsToEnd(); } } } catch (err) { console.error('Failed to open file:', err); } }, [parseHeadings, scrollTabsToEnd, scrollToTabIndex]); const closeTab = useCallback((id: string) => { setTabs(prev => { const newTabs = prev.filter(t => t.id !== id); if (activeTabId === id) { const idx = prev.findIndex(t => t.id === id); const nextTab = newTabs[idx] || newTabs[idx - 1]; setActiveTabId(nextTab?.id || null); if (nextTab) parseHeadings(nextTab.content); else setHeadings([]); } return newTabs; }); }, [activeTabId, parseHeadings]); // Track tab scroll state + mouse wheel horizontal scroll useEffect(() => { updateTabScrollState(); const el = tabScrollRef.current; if (!el) return; const onScroll = () => updateTabScrollState(); const onWheel = (e: WheelEvent) => { if (e.deltaY !== 0) { e.preventDefault(); el.scrollBy({ left: e.deltaY }); updateTabScrollState(); } }; el.addEventListener('scroll', onScroll, { passive: true }); el.addEventListener('wheel', onWheel, { passive: false }); window.addEventListener('resize', onScroll); return () => { el.removeEventListener('scroll', onScroll); el.removeEventListener('wheel', onWheel); window.removeEventListener('resize', onScroll); }; }, [tabs.length, updateTabScrollState]); const closeWindow = useCallback(async () => { await appWindow?.close(); }, [appWindow]); const scrollToHeading = useCallback((text: string) => { const allHeadings = document.querySelectorAll('.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6'); const target = Array.from(allHeadings).find(h => h.textContent?.trim() === text); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, []); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey) { if (e.key === 'o') { e.preventDefault(); handleOpenDialog(); } else if (e.key === 'w') { e.preventDefault(); if (activeTabId) closeTab(activeTabId); } else if (e.key === 'q') { e.preventDefault(); closeWindow(); } else if (e.key === 'f') { e.preventDefault(); setShowSearch(s => !s); } else if (e.shiftKey && e.key === 'S') { e.preventDefault(); setShowSidebar(s => !s); } } else if (e.key === 'F11') { e.preventDefault(); setFocusMode(f => !f); } else if (e.key === 'Escape') { setShowSearch(false); setShowSidebar(false); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleOpenDialog, closeTab, activeTabId, closeWindow]); useEffect(() => { const handleWheel = (e: WheelEvent) => { if (e.ctrlKey) { e.preventDefault(); const delta = e.deltaY > 0 ? -10 : 10; setZoom(z => Math.max(50, Math.min(200, z + delta))); } else if (e.shiftKey) { e.preventDefault(); setContentWidth(w => Math.max(400, Math.min(1200, w - e.deltaY))); } }; document.addEventListener('wheel', handleWheel, { passive: false }); return () => document.removeEventListener('wheel', handleWheel); }, []); useEffect(() => { let isDraggingWindow = false; const handleMouseDown = (e: MouseEvent) => { if (e.buttons === 3 && focusMode) { e.preventDefault(); isDraggingWindow = true; appWindow?.startDragging(); } }; const handleMouseUp = () => { isDraggingWindow = false; }; document.addEventListener('mousedown', handleMouseDown); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('mouseup', handleMouseUp); }; }, [appWindow, focusMode]); // Kinetic scroll: right-click + drag with iOS-style elastic overscroll // Applied to both content area and sidebar useEffect(() => { let cancelled = false; const cleanups: (() => void)[] = []; requestAnimationFrame(() => { requestAnimationFrame(() => { if (cancelled) return; // Content area const contentWrapper = document.querySelector('.content-area-scroll-wrapper'); if (contentWrapper) { const viewport = contentWrapper.querySelector('[data-overlayscrollbars-viewport]') as HTMLElement; const contentDiv = contentWrapper.querySelector('.content-area-scroll') as HTMLElement; if (viewport && contentDiv) { cleanups.push(setupKineticScroll(viewport, contentDiv, kineticDragActiveRef)); } } // Sidebar const sidebarWrapper = document.querySelector('.sidebar-scroll-wrapper'); if (sidebarWrapper) { const viewport = sidebarWrapper.querySelector('[data-overlayscrollbars-viewport]') as HTMLElement; const sidebarDiv = sidebarWrapper.querySelector('.sidebar') as HTMLElement; if (viewport && sidebarDiv) { cleanups.push(setupKineticScroll(viewport, sidebarDiv, kineticDragActiveRef)); } } }); }); return () => { cancelled = true; cleanups.forEach(fn => fn()); }; }, [activeTabId, showSidebar]); const startSidebarResize = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setResizeStartState({ startX: e.clientX, startWidth: sidebarWidth }); }; useEffect(() => { if (!resizeStartState) return; const handleMouseMove = (e: MouseEvent) => { const zoomFactor = uiZoom / 100; const diff = (e.clientX - resizeStartState.startX) / zoomFactor; const newWidth = Math.max(150, Math.min(400, resizeStartState.startWidth + diff)); setSidebarWidth(newWidth); }; const handleMouseUp = () => { setResizeStartState(null); }; document.body.style.cursor = 'col-resize'; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.body.style.cursor = ''; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [resizeStartState, uiZoom]); // Persist sidebar width to localStorage useEffect(() => { localStorage.setItem('vesper-sidebar-width', String(Math.round(sidebarWidth))); }, [sidebarWidth]); // Persist UI zoom to localStorage useEffect(() => { localStorage.setItem('vesper-ui-zoom', String(Math.round(uiZoom))); }, [uiZoom]); // Menu bar keyboard navigation (WAI-ARIA menu pattern) const handleMenuBarKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowRight') { e.preventDefault(); const next = (menuFocusIndex + 1) % menuItems.length; setMenuFocusIndex(next); menuItemRefs.current[menuItems[next]]?.focus(); if (menuOpen) setMenuOpen(menuItems[next]); } else if (e.key === 'ArrowLeft') { e.preventDefault(); const prev = (menuFocusIndex - 1 + menuItems.length) % menuItems.length; setMenuFocusIndex(prev); menuItemRefs.current[menuItems[prev]]?.focus(); if (menuOpen) setMenuOpen(menuItems[prev]); } else if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setMenuOpen(menuItems[menuFocusIndex]); } else if (e.key === 'Escape') { setMenuOpen(null); } }; const handleMenuDropdownKeyDown = (e: React.KeyboardEvent) => { const menu = e.currentTarget as HTMLElement; const items = menu.querySelectorAll('[role="menuitem"]'); if (!items.length) return; const currentIndex = Array.from(items).indexOf(document.activeElement as HTMLElement); if (e.key === 'ArrowDown') { e.preventDefault(); const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0; items[next]?.focus(); } else if (e.key === 'ArrowUp') { e.preventDefault(); const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1; items[prev]?.focus(); } else if (e.key === 'Home') { e.preventDefault(); items[0]?.focus(); } else if (e.key === 'End') { e.preventDefault(); items[items.length - 1]?.focus(); } else if (e.key === 'Escape') { e.preventDefault(); setMenuOpen(null); menuItemRefs.current[menuItems[menuFocusIndex]]?.focus(); } else if (e.key === 'ArrowRight') { e.preventDefault(); const next = (menuFocusIndex + 1) % menuItems.length; setMenuFocusIndex(next); setMenuOpen(menuItems[next]); } else if (e.key === 'ArrowLeft') { e.preventDefault(); const prev = (menuFocusIndex - 1 + menuItems.length) % menuItems.length; setMenuFocusIndex(prev); setMenuOpen(menuItems[prev]); } }; // Auto-focus first menuitem when a dropdown opens useEffect(() => { if (menuOpen) { requestAnimationFrame(() => { const dropdown = document.querySelector('.menu-dropdown[role="menu"]'); const firstItem = dropdown?.querySelector('[role="menuitem"]'); firstItem?.focus(); }); } }, [menuOpen]); const osScrollbarOptions = { scrollbars: { autoHide: 'scroll' as const, autoHideDelay: 800, theme: 'os-theme-dark' as const } }; return (
Skip to content {isDraggingOver && (
Drop markdown file here
.md, .markdown, or .txt
)}
{!focusMode && (
Vesper
)}
{!focusMode && ( {menuOpen === 'file' && menuItemRefs.current['file'] && (
)} {menuOpen === 'view' && menuItemRefs.current['view'] && (
e.stopPropagation()}> UI Scale
{uiZoom}%
)} {menuOpen === 'help' && menuItemRefs.current['help'] && ( )}
)}
{tabs.length > 1 && (
{tabs.map(tab => ( { setActiveTabId(tab.id); parseHeadings(tab.content); }}> {tab.title} ))}
)}
{showSidebar && (

Contents

{headings.length > 0 ? headings.map(h => ) :
No headings
}
)}
{showSearch && activeTab && ( { setSearchQuery(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); setCurrentMatchIndex(i => (i + 1) % Math.max(searchMatches.length, 1)); setSearchTriggerCount(c => c + 1); } }} aria-label="Search document" autoFocus /> {searchMatches.length > 0 && ( <> {currentMatchIndex + 1} of {searchMatches.length} )} {searchQuery && searchMatches.length === 0 && No results} )}
{activeTab ? (
) : (

Vesper

A beautiful markdown reader

Or drag and drop a markdown file here

)}
{showShortcutsModal && ( setShowShortcutsModal(false)}> e.stopPropagation()}>

Keyboard Shortcuts

File

Open FileCtrl+O
Close TabCtrl+W
ExitCtrl+Q

View

Toggle SearchCtrl+F
Toggle SidebarCtrl+Shift+S
Focus ModeF11

Navigation

Close Search / SidebarEsc
Zoom In / OutCtrl+Scroll
Content WidthShift+Scroll
)} {showAboutModal && ( setShowAboutModal(false)}> e.stopPropagation()}>

About Vesper

Vesper

Version 1.0.0

A beautiful markdown reader

Built with Tauri + React

)}
{menuOpen && (