From e675b0fa3597d1a7845e1872abc44b0fbd2bfd9e Mon Sep 17 00:00:00 2001 From: Adam Weber Date: Wed, 14 Jan 2026 12:31:15 -0800 Subject: [PATCH 1/6] Zotero integration, memory improvements, etc --- app/package.json | 3 +- app/public/worker.js | 14 + app/src/App.css | 17 +- app/src/App.tsx | 614 +++++---------- .../CitationPopover/CitationPopover.css | 253 ++++++ .../CitationPopover/CitationPopover.tsx | 323 ++++++++ app/src/components/CitationPopover/index.ts | 2 + app/src/components/CommandPalette.css | 18 +- app/src/components/CommandPalette.tsx | 76 +- app/src/components/EmptyState.css | 29 +- app/src/components/LaTeXEditor/InlineHint.ts | 287 +++++++ .../components/LaTeXEditor/LaTeXEditor.css | 97 ++- .../components/LaTeXEditor/LaTeXEditor.tsx | 282 ++++++- app/src/components/PDFViewer.css | 40 +- app/src/components/PDFViewer.tsx | 310 +++++--- .../ProjectSidebar/ProjectSidebar.css | 734 ++++++++++++++++-- .../ProjectSidebar/ProjectSidebar.tsx | 432 ++++++++--- app/src/components/icons/ZoteroIcon.tsx | 28 + app/src/hooks/useCompilation.ts | 185 +++++ app/src/hooks/useEventBus.ts | 51 ++ app/src/hooks/useIsMobile.ts | 19 +- app/src/hooks/useKeyboardShortcuts.ts | 186 +++++ app/src/hooks/useModals.ts | 124 +++ app/src/services/CitationService.ts | 193 +++++ app/src/services/DocumentManager.ts | 20 +- app/src/services/DocumentSearcher.ts | 227 +++++- app/src/services/EventBus.ts | 142 ++++ app/src/services/ProjectSearcher.ts | 254 ++++-- app/src/services/StateStore.ts | 442 +++++++++++ app/src/services/UndoManager.ts | 360 ++++++++- app/src/types/busytex-lazy.d.ts | 51 ++ app/src/workers/search.worker.ts | 253 ++++++ app/vite.config.ts | 35 +- 33 files changed, 5171 insertions(+), 930 deletions(-) create mode 100644 app/src/components/CitationPopover/CitationPopover.css create mode 100644 app/src/components/CitationPopover/CitationPopover.tsx create mode 100644 app/src/components/CitationPopover/index.ts create mode 100644 app/src/components/LaTeXEditor/InlineHint.ts create mode 100644 app/src/components/icons/ZoteroIcon.tsx create mode 100644 app/src/hooks/useCompilation.ts create mode 100644 app/src/hooks/useEventBus.ts create mode 100644 app/src/hooks/useKeyboardShortcuts.ts create mode 100644 app/src/hooks/useModals.ts create mode 100644 app/src/services/CitationService.ts create mode 100644 app/src/services/EventBus.ts create mode 100644 app/src/services/StateStore.ts create mode 100644 app/src/workers/search.worker.ts diff --git a/app/package.json b/app/package.json index f50716b..0d0004d 100644 --- a/app/package.json +++ b/app/package.json @@ -25,7 +25,7 @@ "@siglum/filesystem": "github:SiglumProject/siglum-filesystem#1d2bbb0", "@siglum/git": "github:SiglumProject/siglum-git", "buffer": "^6.0.3", - "busytex-lazy": "github:SiglumProject/busytex-lazy", + "busytex-lazy": "github:SiglumProject/busytex-lazy#9e5d319", "codemirror": "^6.0.2", "codemirror-lang-latex": "^0.2.0", "comlink": "^4.4.2", @@ -43,6 +43,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.0", + "@vitejs/plugin-basic-ssl": "^2.1.3", "@vitejs/plugin-react": "^4.3.0", "jest": "^30.2.0", "ts-jest": "^29.4.6", diff --git a/app/public/worker.js b/app/public/worker.js index 1c372be..17d454f 100644 --- a/app/public/worker.js +++ b/app/public/worker.js @@ -1295,6 +1295,13 @@ self.onmessage = async function(e) { // Queue compile operations to prevent concurrent execution operationQueue = operationQueue.then(() => handleCompile(msg)).catch(e => { workerLog(`Compile queue error: ${e.message}`); + // IMPORTANT: Send error response so main thread doesn't hang + self.postMessage({ + type: 'compile-response', + id: msg.id, + success: false, + error: e.message, + }); }); break; @@ -1302,6 +1309,13 @@ self.onmessage = async function(e) { // Queue format operations to prevent concurrent execution operationQueue = operationQueue.then(() => handleFormatGenerate(msg)).catch(e => { workerLog(`Format queue error: ${e.message}`); + // IMPORTANT: Send error response so main thread doesn't hang + self.postMessage({ + type: 'format-generate-response', + id: msg.id, + success: false, + error: e.message, + }); }); break; diff --git a/app/src/App.css b/app/src/App.css index 3c13f4f..b19a44f 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -30,7 +30,7 @@ height: 100%; opacity: 0; transform: translateY(8px); - transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1); + transition: opacity 0.25s cubic-bezier(0.23, 1, 0.32, 1), transform 0.25s cubic-bezier(0.23, 1, 0.32, 1); } .desktop-view.loaded { @@ -43,7 +43,7 @@ height: 100%; opacity: 0; transform: translateY(8px); - transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1); + transition: opacity 0.25s cubic-bezier(0.23, 1, 0.32, 1), transform 0.25s cubic-bezier(0.23, 1, 0.32, 1); } .mobile-view.loaded { @@ -57,7 +57,7 @@ background: var(--color-base); opacity: 0; transform: translateY(8px); - transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1); + transition: opacity 0.25s cubic-bezier(0.23, 1, 0.32, 1), transform 0.25s cubic-bezier(0.23, 1, 0.32, 1); } .pdf-preview-panel.loaded { @@ -105,7 +105,7 @@ .resize-handle { width: 2px !important; background: var(--color-border); - transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1); + transition: background 0.3s cubic-bezier(0.23, 1, 0.32, 1), width 0.15s ease; position: relative; border-radius: 1px; overflow: hidden; @@ -128,7 +128,6 @@ .resize-handle:hover { background: var(--color-border); width: 3px !important; - box-shadow: 0 0 8px rgba(181, 133, 106, 0.2); } .resize-handle:hover::before { @@ -138,9 +137,6 @@ .resize-handle:active { background: var(--color-accent); width: 4px !important; - box-shadow: - 0 0 12px rgba(181, 133, 106, 0.3), - 0 0 24px rgba(181, 133, 106, 0.15); } .resize-handle:active::before { @@ -200,7 +196,7 @@ background: rgba(232, 227, 211, 0.06); border-radius: 4px; text-decoration: none; - transition: all 0.15s ease; + transition: background 0.15s ease, color 0.15s ease; } .conflict-modal-repo:hover { @@ -229,7 +225,8 @@ font-weight: 500; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; cursor: pointer; - transition: all 0.15s ease; + transition: background 0.15s ease, filter 0.15s ease; + touch-action: manipulation; } .conflict-modal-btn.primary { diff --git a/app/src/App.tsx b/app/src/App.tsx index 4c57959..294e552 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,19 +4,23 @@ import type { ImperativePanelHandle } from 'react-resizable-panels' import CommandPalette from './components/CommandPalette' import LaTeXEditor from './components/LaTeXEditor/LaTeXEditor' import EmptyState from './components/EmptyState' +import type { FileItem } from './components/ProjectSidebar' +import type { CitationItem } from './services/CitationService' -// Lazy load PDF viewer - only loads when preview is shown +// Lazy load heavy components - only loads when needed const PDFViewer = lazy(() => import('./components/PDFViewer')) -import DocumentationModal from './components/DocumentationModal' -import ProjectSidebar from './components/ProjectSidebar' -import Onboarding from './components/Onboarding/Onboarding' -import type { FileItem } from './components/ProjectSidebar' +const DocumentationModal = lazy(() => import('./components/DocumentationModal')) +const ProjectSidebar = lazy(() => import('./components/ProjectSidebar')) +const Onboarding = lazy(() => import('./components/Onboarding/Onboarding')) import { useDocument } from './hooks/useDocument' import { useAutoSave } from './hooks/useAutoSave' +import { useCompilation } from './hooks/useCompilation' +import { useModals } from './hooks/useModals' +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import DocumentManager from './services/DocumentManager' import GitService from './services/GitService' -import CompilerService from './services/CompilerService' -import type { CompileStatus } from './services/CompilerService' +import EventBus from './services/EventBus' +import { useEventBus } from './hooks/useEventBus' import './App.css' // Make DocumentManager available globally in development @@ -31,33 +35,11 @@ const App: React.FC = () => { const [showDesktopPreview, setShowDesktopPreview] = useState(true) const [isLoaded, setIsLoaded] = useState(false) const [touchStart, setTouchStart] = useState({ x: 0, y: 0 }) - const [showCommandPalette, setShowCommandPalette] = useState(false) - - // Compiler state - const [pdfData, setPdfData] = useState(null) - const [compileStatus, setCompileStatus] = useState('idle') - const [compileTimeMs, setCompileTimeMs] = useState(null) - const compileTimeoutRef = useRef(null) - - // Compiler settings (shared with editor) - const [compilerSettings, setCompilerSettings] = useState({ - autoCompile: true, - compiler: 'auto' as 'auto' | 'pdflatex' | 'xelatex', - ctanFetch: true, - cachePreamble: true, - autoUnload: true, - }) - - const [isClosingCommandPalette, setIsClosingCommandPalette] = useState(false) - const [showDocumentation, setShowDocumentation] = useState(false) - const [isClosingDocumentation, setIsClosingDocumentation] = useState(false) - const [documentationOpenedFromPalette, setDocumentationOpenedFromPalette] = useState(false) const [sidebarExpanded, setSidebarExpanded] = useState(() => { const saved = localStorage.getItem('siglum-sidebar-expanded') return saved === 'true' }) const [projectFiles, setProjectFiles] = useState(() => { - // Load from localStorage on init const saved = localStorage.getItem('siglum-project-files') return saved ? JSON.parse(saved) : [] }) @@ -71,6 +53,96 @@ const App: React.FC = () => { const editorPanelRef = useRef(null) const previewPanelRef = useRef(null) + // Compilation state from hook + const { + pdfData, + compileStatus, + compileTimeMs, + compilerSettings, + setCompilerSettings, + compile, + compileRef, + autoCompileRef, + clearPendingAutoCompile, + } = useCompilation(latexCode, isLoaded) + + // Modal state from hook + const { + showCommandPalette, + isClosingCommandPalette, + showDocumentation, + isClosingDocumentation, + documentationOpenedFromPalette, + openCommandPalette, + closeCommandPalette, + openDocumentation, + closeDocumentation, + backFromDocumentation, + setShowCommandPalette, + } = useModals() + + // Memoized callbacks for keyboard shortcuts + const handleCloseCurrentDocument = useCallback(async () => { + const recentDocs = DocumentManager.getRecentDocuments() + if (recentDocs.length > 1) { + await loadDocument(recentDocs[1].id) + } else { + await createNewDocument() + } + }, [loadDocument, createNewDocument]) + + const handleToggleSidebar = useCallback(() => { + setSidebarExpanded(prev => !prev) + }, []) + + const handleExpandSidebar = useCallback(() => { + setSidebarExpanded(true) + }, []) + + const handleCollapseSidebar = useCallback(() => { + setSidebarExpanded(false) + }, []) + + const handleCloseCommandPaletteModal = useCallback(() => { + setShowCommandPalette(false) + }, []) + + const handleOpenCommandPaletteModal = useCallback(() => { + setShowCommandPalette(true) + }, []) + + const handleGitConnectTriggered = useCallback(() => { + setTriggerGitConnect(false) + }, []) + + const handleOnboardingComplete = useCallback(() => { + setShowOnboarding(false) + }, []) + + const handleConnectGithub = useCallback(() => { + setTriggerGitConnect(true) + setSidebarExpanded(true) + }, []) + + // Keyboard shortcuts hook + useKeyboardShortcuts({ + showCommandPalette, + showDesktopPreview, + showDocumentation, + onOpenCommandPalette: openCommandPalette, + onCloseCommandPalette: closeCommandPalette, + onOpenDocumentation: openDocumentation, + onCloseDocumentation: closeDocumentation, + onCreateNewDocument: createNewDocument, + onCloseCurrentDocument: handleCloseCurrentDocument, + onToggleSidebar: handleToggleSidebar, + onSetLatexCode: setLatexCode, + setShowDesktopPreview, + setShowPreview, + editorPanelRef, + previewPanelRef, + }) + // Sync local state with current document and update browser title useEffect(() => { if (currentDocument) { @@ -94,23 +166,7 @@ const App: React.FC = () => { return () => clearTimeout(timer) }, []) - // Eagerly initialize compiler so it's ready when user starts editing - useEffect(() => { - CompilerService.initialize().catch(() => { - // Initialization errors will be handled when compile is called - }) - }, []) - // Also update title when content changes (for auto-generated titles) - useEffect(() => { - if (currentDocument && latexCode !== currentDocument.content) { - // Title might have changed due to content update - const titlePrefix = currentDocument.title === `Untitled ${new Date().toLocaleDateString()}` - ? 'Untitled' - : currentDocument.title - document.title = `${titlePrefix} - Siglum` - } - }, [currentDocument, latexCode]) // Save project files to localStorage when they change useEffect(() => { @@ -170,15 +226,13 @@ const App: React.FC = () => { // Trigger immediate compile on document switch if auto-compile is enabled if (autoCompileRef.current) { // Clear any pending debounced compile - if (compileTimeoutRef.current) { - clearTimeout(compileTimeoutRef.current) - } + clearPendingAutoCompile() // Defer compile to allow state to update setTimeout(() => compileRef.current(), 0) } } } - }, []) + }, [clearPendingAutoCompile, autoCompileRef, compileRef]) const handleLatexCodeChange = useCallback((newContent: string) => { setLatexCode(newContent) @@ -191,113 +245,6 @@ const App: React.FC = () => { } }, [latexCode, selectedFilePath]) - // Compile function - const compile = useCallback(async () => { - // Use CompilerService.getStatus() directly to avoid stale closure issues - const currentStatus = CompilerService.getStatus() - if (!latexCode || currentStatus === 'compiling' || currentStatus === 'initializing') { - return - } - - try { - const result = await CompilerService.compile(latexCode, { - engine: compilerSettings.compiler, - }) - if (result.success && result.pdf) { - // Copy the bytes explicitly to avoid "detached ArrayBuffer" issues from worker transfers - // Use Uint8Array (not ArrayBuffer) to avoid Safari detachment issues - const pdfCopy = new Uint8Array(result.pdf.length) - pdfCopy.set(result.pdf) - setPdfData(pdfCopy) - setCompileTimeMs(result.timeMs ?? null) - - // Generate format (cache preamble) after successful compile if enabled - if (compilerSettings.cachePreamble) { - CompilerService.generateFormat(latexCode, { - engine: compilerSettings.compiler, - }).catch(() => { - // Format generation errors are logged but don't affect the user - }) - } - } - } catch { - // Compilation errors are shown in the UI - } - }, [latexCode, compilerSettings.compiler, compilerSettings.cachePreamble]) - - // Wrapper for compiler settings that handles side effects - const handleCompilerSettingsChange = useCallback((newSettings: typeof compilerSettings) => { - // If cachePreamble was just turned off, clear the format cache - if (compilerSettings.cachePreamble && !newSettings.cachePreamble) { - CompilerService.clearCache() - } - // Update auto-unload setting - if (compilerSettings.autoUnload !== newSettings.autoUnload) { - CompilerService.setAutoUnload(newSettings.autoUnload) - } - setCompilerSettings(newSettings) - }, [compilerSettings.cachePreamble, compilerSettings.autoUnload]) - - // Keep refs to avoid stale closures in callbacks - const compileRef = useRef(compile) - const latexCodeRef = useRef(latexCode) - const autoCompileRef = useRef(compilerSettings.autoCompile) - useEffect(() => { - compileRef.current = compile - }, [compile]) - useEffect(() => { - latexCodeRef.current = latexCode - }, [latexCode]) - useEffect(() => { - autoCompileRef.current = compilerSettings.autoCompile - }, [compilerSettings.autoCompile]) - - // Subscribe to compiler status - const prevStatusRef = useRef('idle') - useEffect(() => { - return CompilerService.onStatus((status) => { - const prevStatus = prevStatusRef.current - prevStatusRef.current = status - setCompileStatus(status) - - // Trigger compile when initialization completes and we have content - if (prevStatus === 'initializing' && status === 'idle' && latexCodeRef.current && autoCompileRef.current) { - // Defer to allow React state to update - setTimeout(() => compileRef.current(), 0) - } - }) - }, []) - - // Listen for triggerCompile event from editor (Cmd+S) - useEffect(() => { - const handleTriggerCompile = () => { - compile() - } - window.addEventListener('triggerCompile', handleTriggerCompile) - return () => window.removeEventListener('triggerCompile', handleTriggerCompile) - }, [compile]) - - // Auto-compile on content change (debounced) - useEffect(() => { - // Skip if auto-compile disabled, no content, or during initial load - if (!compilerSettings.autoCompile || !latexCode || !isLoaded) return - - // Clear previous timeout - if (compileTimeoutRef.current) { - clearTimeout(compileTimeoutRef.current) - } - - // Debounce compile by 1 second - compileTimeoutRef.current = window.setTimeout(() => { - compileRef.current() - }, 1000) - - return () => { - if (compileTimeoutRef.current) { - clearTimeout(compileTimeoutRef.current) - } - } - }, [latexCode, isLoaded, compilerSettings.autoCompile]) // Auto-save with debouncing const saveStatus = useAutoSave(latexCode, { @@ -321,48 +268,22 @@ const App: React.FC = () => { // DocumentManager now handles search index updates automatically - // Listen for command palette event from Monaco Editor - React.useEffect(() => { - const handleToggleCommandPalette = () => { - setShowCommandPalette(prev => !prev) + // Type-safe event subscriptions via EventBus + useEventBus('toggleCommandPalette', () => { + if (showCommandPalette) { + closeCommandPalette() + } else { + openCommandPalette() } + }, [showCommandPalette, closeCommandPalette, openCommandPalette]) - window.addEventListener('toggleCommandPalette', handleToggleCommandPalette) - return () => window.removeEventListener('toggleCommandPalette', handleToggleCommandPalette) - }, [showCommandPalette]) - - // Animated close handler for command palette - const handleCloseCommandPalette = useCallback(() => { - setIsClosingCommandPalette(true) - setTimeout(() => { - setIsClosingCommandPalette(false) - setShowCommandPalette(false) - }, 200) // Match animation duration - }, []) - - // Animated close handler for documentation modal - const handleCloseDocumentation = useCallback(() => { - setIsClosingDocumentation(true) - setTimeout(() => { - setIsClosingDocumentation(false) - setShowDocumentation(false) - setDocumentationOpenedFromPalette(false) - }, 200) // Match animation duration - }, []) - - // Listen for toggleHelp event from editor toolbar - useEffect(() => { - const handleToggleHelp = () => { - if (showDocumentation) { - handleCloseDocumentation() - } else { - setShowDocumentation(true) - } + useEventBus('toggleHelp', () => { + if (showDocumentation) { + closeDocumentation() + } else { + openDocumentation(false) } - - window.addEventListener('toggleHelp', handleToggleHelp) - return () => window.removeEventListener('toggleHelp', handleToggleHelp) - }, [showDocumentation, handleCloseDocumentation]) + }, [showDocumentation, closeDocumentation, openDocumentation]) // Command palette handlers const handleSelectDocument = useCallback(async (documentId: string) => { @@ -378,7 +299,7 @@ const App: React.FC = () => { if (lineNumber) { // Delay to let the editor update with new content setTimeout(() => { - window.dispatchEvent(new CustomEvent('goToLine', { detail: { line: lineNumber } })) + EventBus.emit('goToLine', { line: lineNumber }) }, 100) } } @@ -388,169 +309,9 @@ const App: React.FC = () => { await createNewDocument() }, [createNewDocument]) - const handleCloseCurrentDocument = useCallback(async () => { - const recentDocs = DocumentManager.getRecentDocuments() - if (recentDocs.length > 1) { - // Switch to the second most recent document (first is current) - await loadDocument(recentDocs[1].id) - } else { - // Create a new document if no other documents exist - await createNewDocument() - } - }, [loadDocument, createNewDocument]) - - // Keyboard shortcuts - React.useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Command palette shortcuts (global) - if ((e.metaKey || e.ctrlKey) && e.key === 'k' && !e.shiftKey && !e.altKey) { - e.preventDefault() - e.stopPropagation() - - if (showCommandPalette) { - handleCloseCommandPalette() - } else { - // Close any open modals when opening command palette - if (showDocumentation) { - handleCloseDocumentation() - } else { - setShowDocumentation(false) - } - setIsClosingCommandPalette(false) - setShowCommandPalette(true) - } - return - } - - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'F') { - e.preventDefault() - if (showCommandPalette) { - // Close with animation - handleCloseCommandPalette() - } else { - // Close any open modals when opening command palette - if (showDocumentation) { - handleCloseDocumentation() - } else { - setShowDocumentation(false) - } - setIsClosingCommandPalette(false) - setShowCommandPalette(true) - } - return - } - - if ((e.metaKey || e.ctrlKey) && e.key === 'n') { - e.preventDefault() - handleCreateNewDocument() - return - } - - if ((e.metaKey || e.ctrlKey) && e.key === 'w') { - e.preventDefault() - // Close current document (go to most recent) - handleCloseCurrentDocument() - return - } - - // Undo/Redo shortcuts - if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { - e.preventDefault() - // Perform undo - DocumentManager.undo().then(result => { - if (result) { - setLatexCode(result.content) - // TODO: Restore cursor position in editor - } - }) - return - } - - if ((e.metaKey || e.ctrlKey) && ((e.shiftKey && e.key === 'z') || e.key === 'y')) { - e.preventDefault() - // Perform redo - DocumentManager.redo().then(result => { - if (result) { - setLatexCode(result.content) - // TODO: Restore cursor position in editor - } - }) - return - } - - if ((e.metaKey || e.ctrlKey) && e.key === 'h') { - e.preventDefault() - - if (showDocumentation) { - handleCloseDocumentation() - } else { - if (showCommandPalette) { - setDocumentationOpenedFromPalette(true) - handleCloseCommandPalette() - } else { - setDocumentationOpenedFromPalette(false) - } - setShowDocumentation(true) - } - return - } - - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'E') { - e.preventDefault() - // Export current document - DocumentManager.exportDocument() - return - } - - // Cmd+. or Ctrl+. - toggle project sidebar - if ((e.metaKey || e.ctrlKey) && e.key === '.') { - e.preventDefault() - setSidebarExpanded(prev => !prev) - return - } - - // Cmd+1 or Ctrl+1 - show only editor - if ((e.metaKey || e.ctrlKey) && e.key === '1') { - e.preventDefault() - if (window.innerWidth >= 768) { - setShowDesktopPreview(false) - } else { - setShowPreview(false) // For mobile - } - } - // Cmd+2 or Ctrl+2 - show editor and preview - else if ((e.metaKey || e.ctrlKey) && e.key === '2') { - e.preventDefault() - if (window.innerWidth >= 768) { - setShowDesktopPreview(true) - setTimeout(() => { - editorPanelRef.current?.resize(50) - previewPanelRef.current?.resize(50) - }, 0) - } else { - setShowPreview(true) // For mobile - } - } - // Cmd+\ or Ctrl+\ - toggle/balance panels - else if ((e.metaKey || e.ctrlKey) && e.key === '\\') { - e.preventDefault() - if (window.innerWidth >= 768) { - setShowDesktopPreview(prev => !prev) - if (showDesktopPreview) { - setTimeout(() => { - editorPanelRef.current?.resize(50) - previewPanelRef.current?.resize(50) - }, 0) - } - } else { - setShowPreview(prev => !prev) // For mobile - } - } - } - - document.addEventListener('keydown', handleKeyDown, true) - return () => document.removeEventListener('keydown', handleKeyDown, true) - }, [showCommandPalette, showDesktopPreview, showDocumentation, handleCloseCommandPalette, handleCloseDocumentation, handleCreateNewDocument, handleCloseCurrentDocument, createNewDocument, loadDocument]) + const handleSelectCitation = useCallback((citation: CitationItem) => { + EventBus.emit('insertText', { text: `\\cite{${citation.citeKey}}` }) + }, []) // Mobile swipe gestures const handleTouchStart = useCallback((e: React.TouchEvent) => { @@ -580,24 +341,8 @@ const App: React.FC = () => { // Modal handlers for command palette integration const handleOpenHelp = useCallback(() => { - if (showCommandPalette) { - setDocumentationOpenedFromPalette(true) - handleCloseCommandPalette() // Close command palette with animation - } else { - setDocumentationOpenedFromPalette(false) - } - setShowDocumentation(true) - }, [showCommandPalette, handleCloseCommandPalette]) - - const handleBackFromDocumentation = useCallback(() => { - setIsClosingDocumentation(true) - setTimeout(() => { - setIsClosingDocumentation(false) - setShowDocumentation(false) - setDocumentationOpenedFromPalette(false) - setShowCommandPalette(true) - }, 200) // Match animation duration - }, []) + openDocumentation(showCommandPalette) + }, [openDocumentation, showCommandPalette]) const handleCreateFromTemplate = useCallback(async (template: string) => { const DocumentManager = (await import('./services/DocumentManager')).default @@ -694,22 +439,27 @@ const App: React.FC = () => { setShowCommandPalette(false)} + onClose={handleCloseCommandPaletteModal} onSelectDocument={handleSelectDocument} onSelectFile={handleSelectFile} + onSelectCitation={handleSelectCitation} onCreateNew={handleCreateNewDocument} onOpenHelp={handleOpenHelp} /> setShowCommandPalette(true)} - /> - + {showDocumentation && ( + + + + )} ) } @@ -723,9 +473,10 @@ const App: React.FC = () => { setShowCommandPalette(false)} + onClose={handleCloseCommandPaletteModal} onSelectDocument={handleSelectDocument} onSelectFile={handleSelectFile} + onSelectCitation={handleSelectCitation} onCreateNew={handleCreateNewDocument} onOpenHelp={handleOpenHelp} /> @@ -735,22 +486,25 @@ const App: React.FC = () => {
- setSidebarExpanded(prev => !prev)} - files={projectFiles} - onFilesChange={setProjectFiles} - onFileSelect={handleFileSelect} - selectedFileId={selectedFileId} - triggerGitConnect={triggerGitConnect} - onGitConnectTriggered={() => setTriggerGitConnect(false)} - /> + + + { {/* Mobile: Single panel view */}
- setSidebarExpanded(prev => !prev)} - files={projectFiles} - onFilesChange={setProjectFiles} - onFileSelect={handleFileSelect} - selectedFileId={selectedFileId} - triggerGitConnect={triggerGitConnect} - onGitConnectTriggered={() => setTriggerGitConnect(false)} - /> + + + {sidebarExpanded && (
setSidebarExpanded(false)} + onClick={handleCollapseSidebar} /> )} {!showPreview ? ( @@ -797,9 +553,10 @@ const App: React.FC = () => { value={latexCode} onChange={handleLatexCodeChange} onSelectionChange={handleEditorSelection} - onMenuClick={() => setSidebarExpanded(true)} + onMenuClick={handleExpandSidebar} + documentId={currentDocument?.id} compilerSettings={compilerSettings} - onCompilerSettingsChange={handleCompilerSettingsChange} + onCompilerSettingsChange={setCompilerSettings} compileStatus={compileStatus} compileTimeMs={compileTimeMs} onCompile={compile} @@ -814,23 +571,26 @@ const App: React.FC = () => {
- + {showDocumentation && ( + + + + )} {showOnboarding && ( - setShowOnboarding(false)} - onExpandSidebar={() => setSidebarExpanded(true)} - onCollapseSidebar={() => setSidebarExpanded(false)} - onConnectGithub={() => { - setTriggerGitConnect(true) - setSidebarExpanded(true) - }} - /> + + + )} {showConflictModal && ( diff --git a/app/src/components/CitationPopover/CitationPopover.css b/app/src/components/CitationPopover/CitationPopover.css new file mode 100644 index 0000000..dca6d77 --- /dev/null +++ b/app/src/components/CitationPopover/CitationPopover.css @@ -0,0 +1,253 @@ +.citation-popover { + position: fixed; + z-index: 1000; + width: 320px; + max-height: 280px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.citation-popover-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px; + border-bottom: 1px solid var(--color-border); +} + +.citation-popover-search { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + background: var(--color-bg-elevated); + border-radius: 4px; + padding: 5px 8px; +} + +.citation-popover-search-icon { + color: var(--color-text-muted); + flex-shrink: 0; +} + +.citation-popover-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--color-text); + font-size: 13px; +} + +.citation-popover-input::placeholder { + color: var(--color-text-muted); +} + +.citation-popover-input:disabled { + cursor: not-allowed; +} + +.citation-popover-spinner { + color: var(--color-text-muted); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.citation-popover-close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--color-text-muted); + cursor: pointer; + transition: all 0.15s ease; +} + +.citation-popover-close:hover { + background: var(--color-bg-elevated); + color: var(--color-text); +} + +.citation-popover-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px 12px; + color: var(--color-text-muted); + text-align: center; +} + +.citation-popover-empty svg { + margin-bottom: 8px; + opacity: 0.5; +} + +.citation-popover-empty p { + margin: 0; + font-size: 12px; + color: var(--color-text-secondary); +} + +.citation-popover-hint { + font-size: 11px; + margin-top: 2px; + opacity: 0.7; +} + +.citation-popover-results { + flex: 1; + overflow-y: auto; + padding: 4px; +} + +.citation-popover-item { + position: relative; + padding: 8px; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s ease; +} + +.citation-popover-item:hover, +.citation-popover-item.selected { + background: var(--color-bg-elevated); +} + +.citation-popover-item.selected { + background: var(--color-bg-hover, var(--color-bg-elevated)); +} + +.citation-popover-item-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 2px; +} + +.citation-popover-item-type { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 4px; + background: var(--color-accent-subtle); + color: var(--color-accent); + border-radius: 3px; + font-weight: 500; +} + +.citation-popover-item-key { + font-size: 10px; + color: var(--color-text-muted); + font-family: var(--font-mono, 'SF Mono', monospace); +} + +.citation-popover-item-title { + font-size: 12px; + color: var(--color-text); + line-height: 1.3; + margin-bottom: 3px; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.citation-popover-item-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--color-text-muted); +} + +.citation-popover-item-author, +.citation-popover-item-year { + display: flex; + align-items: center; + gap: 3px; +} + +.citation-popover-item-author { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.citation-popover-item-action { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 3px; + font-size: 10px; + color: var(--color-accent); + opacity: 0; + transition: opacity 0.15s ease; +} + +.citation-popover-item.selected .citation-popover-item-action { + opacity: 1; +} + +.citation-popover-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 8px; + border-top: 1px solid var(--color-border); + background: var(--color-bg-elevated); + font-size: 10px; + color: var(--color-text-muted); +} + +.citation-popover-status { + display: flex; + align-items: center; + gap: 4px; +} + +.citation-popover-dot { + width: 5px; + height: 5px; + border-radius: 50%; +} + +.citation-popover-dot.connected { + background: #22c55e; +} + +.citation-popover-dot.disconnected { + background: var(--color-text-muted); +} + +.citation-popover-shortcuts { + display: flex; + align-items: center; + gap: 6px; +} + +.citation-popover-shortcuts kbd { + padding: 1px 4px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 2px; + font-size: 9px; + font-family: var(--font-mono, 'SF Mono', monospace); +} diff --git a/app/src/components/CitationPopover/CitationPopover.tsx b/app/src/components/CitationPopover/CitationPopover.tsx new file mode 100644 index 0000000..3a7adac --- /dev/null +++ b/app/src/components/CitationPopover/CitationPopover.tsx @@ -0,0 +1,323 @@ +/** + * CitationPopover - Inline citation search and insertion + * + * Provides a popover interface for searching and inserting citations + * at the cursor position in the editor. Integrates with Zotero through + * CitationService. + */ + +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { Search, BookOpen, User, Calendar, X, Check, Loader2 } from 'lucide-react' +import citationService, { type CitationItem, type ZoteroStatus } from '../../services/CitationService' +import './CitationPopover.css' + +interface CitationPopoverProps { + isOpen: boolean + position: { top: number; left: number } + onClose: () => void + onInsert: (citeKey: string, bibtex: string) => void +} + +const CitationPopover: React.FC = React.memo(({ + isOpen, + position, + onClose, + onInsert +}) => { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(0) + const [isSearching, setIsSearching] = useState(false) + const [zoteroStatus, setZoteroStatus] = useState(null) + const inputRef = useRef(null) + const listRef = useRef(null) + const searchTimeoutRef = useRef(null) + + // Subscribe to Zotero status + useEffect(() => { + const unsubscribe = citationService.onStatus((status) => { + setZoteroStatus(status.zotero) + }) + return unsubscribe + }, []) + + // Focus input when opened - show tracked citations by default + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus() + setQuery('') + setSelectedIndex(0) + // Show tracked citations by default + const tracked = citationService.getTrackedCitations() + setResults(tracked) + } + }, [isOpen]) + + // Global Escape key handler (works even when input is disabled) + useEffect(() => { + if (!isOpen) return + + const handleGlobalKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + document.addEventListener('keydown', handleGlobalKeyDown) + return () => document.removeEventListener('keydown', handleGlobalKeyDown) + }, [isOpen, onClose]) + + // Debounced search - boost tracked citations to top + const performSearch = useCallback(async (searchQuery: string) => { + if (!searchQuery.trim()) { + // Show tracked citations when query is empty + const tracked = citationService.getTrackedCitations() + setResults(tracked) + return + } + + setIsSearching(true) + try { + const searchResults = await citationService.search(searchQuery, 10) + // Sort: tracked citations first, then by relevance + const trackedKeys = new Set(citationService.getTrackedCitations().map(c => c.key)) + const sorted = [...searchResults].sort((a, b) => { + const aTracked = trackedKeys.has(a.key) + const bTracked = trackedKeys.has(b.key) + if (aTracked && !bTracked) return -1 + if (!aTracked && bTracked) return 1 + return 0 + }) + setResults(sorted) + setSelectedIndex(0) + } catch (error) { + console.error('Citation search failed:', error) + setResults([]) + } finally { + setIsSearching(false) + } + }, []) + + // Handle query change with debounce + useEffect(() => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + + if (query.trim()) { + searchTimeoutRef.current = window.setTimeout(() => { + performSearch(query) + }, 200) + } else { + // Show tracked citations when query is empty + const tracked = citationService.getTrackedCitations() + setResults(tracked) + } + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current) + } + } + }, [query, performSearch]) + + // Handle keyboard navigation + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelectedIndex(prev => Math.min(prev + 1, results.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setSelectedIndex(prev => Math.max(prev - 1, 0)) + break + case 'Enter': + e.preventDefault() + if (results[selectedIndex]) { + handleSelect(results[selectedIndex]) + } + break + case 'Escape': + e.preventDefault() + onClose() + break + } + }, [results, selectedIndex, onClose]) + + // Handle citation selection + const handleSelect = useCallback(async (item: CitationItem) => { + try { + // Track the citation + await citationService.trackCitation(item) + + // Get BibTeX for the item + const bibtex = citationService.getBibtex(item) + + // Insert the citation + onInsert(item.citeKey, bibtex) + onClose() + } catch (error) { + console.error('Failed to insert citation:', error) + } + }, [onInsert, onClose]) + + // Scroll selected item into view + useEffect(() => { + if (listRef.current && results.length > 0) { + const selectedElement = listRef.current.children[selectedIndex] as HTMLElement + if (selectedElement) { + selectedElement.scrollIntoView({ block: 'nearest' }) + } + } + }, [selectedIndex, results.length]) + + // Memoize formatted results + const formattedResults = useMemo(() => { + return results.map(item => ({ + ...item, + displayCreators: item.creatorsText || 'Unknown Author', + displayYear: item.year || 'n.d.', + displayType: formatItemType(item.itemType) + })) + }, [results]) + + if (!isOpen) return null + + const isConnected = zoteroStatus?.connected ?? false + + return ( +
+
+
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={isConnected ? "Search citations..." : "Connect Zotero to search"} + disabled={!isConnected} + className="citation-popover-input" + /> + {isSearching && } +
+ +
+ + {!isConnected && ( +
+ +

Connect to Zotero to search

+ Use the sidebar to connect +
+ )} + + {isConnected && !query && results.length === 0 && ( +
+ +

No recent citations

+ Start typing to search +
+ )} + + {isConnected && query && results.length === 0 && !isSearching && ( +
+ +

No results found

+ Try a different search term +
+ )} + + {results.length > 0 && ( +
+ {formattedResults.map((item, index) => ( +
handleSelect(item)} + onMouseEnter={() => setSelectedIndex(index)} + > +
+ {item.displayType} + {item.citeKey} +
+
{item.title}
+
+ + + {item.displayCreators} + + + + {item.displayYear} + +
+ {index === selectedIndex && ( +
+ + Insert +
+ )} +
+ ))} +
+ )} + +
+ + {isConnected ? ( + <> + + {zoteroStatus?.itemCount ?? 0} items + + ) : ( + <> + + Not connected + + )} + + + ↑↓ navigate + insert + esc close + +
+
+ ) +}, (prevProps, nextProps) => { + return ( + prevProps.isOpen === nextProps.isOpen && + prevProps.position.top === nextProps.position.top && + prevProps.position.left === nextProps.position.left + ) +}) + +function formatItemType(type: string): string { + const typeMap: Record = { + journalArticle: 'Article', + book: 'Book', + bookSection: 'Chapter', + conferencePaper: 'Conference', + thesis: 'Thesis', + report: 'Report', + webpage: 'Web', + preprint: 'Preprint', + manuscript: 'Manuscript', + patent: 'Patent' + } + return typeMap[type] || type +} + +export default CitationPopover diff --git a/app/src/components/CitationPopover/index.ts b/app/src/components/CitationPopover/index.ts new file mode 100644 index 0000000..631c60f --- /dev/null +++ b/app/src/components/CitationPopover/index.ts @@ -0,0 +1,2 @@ +export { default } from './CitationPopover' +export type { } from './CitationPopover' diff --git a/app/src/components/CommandPalette.css b/app/src/components/CommandPalette.css index de2018f..f5c6138 100644 --- a/app/src/components/CommandPalette.css +++ b/app/src/components/CommandPalette.css @@ -13,7 +13,7 @@ justify-content: center; padding-top: 8vh; opacity: 0; - animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; + animation: fadeIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; } .command-palette-container { @@ -47,7 +47,8 @@ padding: 0; border-radius: 12px; background: transparent; - animation: slideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; + animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; + will-change: transform, opacity; transform: translateY(-12px) scale(0.96); box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5); } @@ -58,8 +59,8 @@ .command-palette { background: var(--color-base-lighter); - backdrop-filter: blur(40px); - -webkit-backdrop-filter: blur(40px); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); border-radius: 12px; border: 1px solid var(--color-border); width: 680px; @@ -122,7 +123,7 @@ border: none; outline: none; color: var(--color-text-primary); - font-size: 14px; + font-size: 16px; /* Prevents iOS zoom on focus */ font-weight: 500; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; letter-spacing: -0.01em; @@ -146,6 +147,8 @@ scrollbar-width: thin; scrollbar-color: var(--color-border) transparent; background: transparent; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } .command-palette-results::-webkit-scrollbar { @@ -173,10 +176,13 @@ cursor: pointer; border-radius: 0; border: none; - transition: all 0.15s ease; + transition: background 0.15s ease, color 0.15s ease; background: transparent; position: relative; color: var(--color-text-secondary); + /* scroll-margin for keyboard navigation - avoids scrollIntoView jank */ + scroll-margin-top: 8px; + scroll-margin-bottom: 8px; } .command-palette-item:hover { diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx index e9e064a..1c50673 100644 --- a/app/src/components/CommandPalette.tsx +++ b/app/src/components/CommandPalette.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect, useCallback, useRef } from 'react' -import { Search, FileText, Plus, RotateCw, HelpCircle, File } from 'lucide-react' +import { Search, FileText, Plus, RotateCw, HelpCircle, File, BookOpen } from 'lucide-react' import type { SearchResult } from '../types/Document' import DocumentSearcher from '../services/DocumentSearcher' import ProjectSearcher from '../services/ProjectSearcher' import type { FileSearchResult } from '../services/ProjectSearcher' +import citationService, { type CitationItem } from '../services/CitationService' import './CommandPalette.css' interface CommandPaletteProps { @@ -12,6 +13,7 @@ interface CommandPaletteProps { onClose: () => void onSelectDocument: (documentId: string) => void onSelectFile?: (filePath: string, lineNumber?: number) => void + onSelectCitation?: (citation: CitationItem) => void onCreateNew: () => void onOpenHelp?: () => void } @@ -32,12 +34,14 @@ const CommandPalette: React.FC = ({ onClose, onSelectDocument, onSelectFile, + onSelectCitation, onCreateNew, onOpenHelp }) => { const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [fileResults, setFileResults] = useState([]) + const [citationResults, setCitationResults] = useState([]) const [commands, setCommands] = useState([]) const [selectedIndex, setSelectedIndex] = useState(0) const [isSearching, setIsSearching] = useState(false) @@ -137,13 +141,14 @@ const CommandPalette: React.FC = ({ setCommands([]) setResults(recentResults) setFileResults([]) + setCitationResults([]) return } setIsSearching(true) try { - // Search for documents and project files in parallel - const [searchResults, projectFileResults] = await Promise.all([ + // Search for documents, project files, and citations in parallel + const [searchResults, projectFileResults, citations] = await Promise.all([ DocumentSearcher.search(searchQuery, { maxResults: 5, includeContent: true, @@ -152,7 +157,10 @@ const CommandPalette: React.FC = ({ ProjectSearcher.search(searchQuery, { maxResults: 8, searchContent: true - }) + }), + citationService.isZoteroConnected() + ? citationService.search(searchQuery, 5) + : Promise.resolve([]) ]) // Use natural language intent detection @@ -160,11 +168,13 @@ const CommandPalette: React.FC = ({ setResults(searchResults) setFileResults(projectFileResults) + setCitationResults(citations) setCommands(intentBasedCommands) } catch (error) { console.error('Search error:', error) setResults([]) setFileResults([]) + setCitationResults([]) setCommands([]) } finally { setIsSearching(false) @@ -209,14 +219,14 @@ const CommandPalette: React.FC = ({ // Update selected index when results change useEffect(() => { setSelectedIndex(0) - }, [results, fileResults, commands]) + }, [results, fileResults, citationResults, commands]) // Keyboard navigation useEffect(() => { if (!isOpen) return const handleKeyDown = (e: KeyboardEvent) => { - const totalItems = commands.length + fileResults.length + results.length + const totalItems = commands.length + citationResults.length + fileResults.length + results.length switch (e.key) { case 'Escape': @@ -243,16 +253,17 @@ const CommandPalette: React.FC = ({ document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [isOpen, selectedIndex, commands, fileResults, results, handleClose, onSelectDocument, onSelectFile]) + }, [isOpen, selectedIndex, commands, citationResults, fileResults, results, handleClose, onSelectDocument, onSelectFile, onSelectCitation]) - // Scroll selected item into view + // Scroll selected item into view - using 'instant' to avoid jank + // CSS scroll-margin handles the padding, so no layout thrashing useEffect(() => { if (listRef.current) { const selectedElement = listRef.current.children[selectedIndex] as HTMLElement if (selectedElement) { selectedElement.scrollIntoView({ block: 'nearest', - behavior: 'smooth' + behavior: 'instant' }) } } @@ -264,9 +275,17 @@ const CommandPalette: React.FC = ({ if (commands[index]) { commands[index].action() } - } else if (index < commands.length + fileResults.length) { + } else if (index < commands.length + citationResults.length) { + // Citation item clicked + const citationIndex = index - commands.length + const citation = citationResults[citationIndex] + if (citation && onSelectCitation) { + onSelectCitation(citation) + handleClose() + } + } else if (index < commands.length + citationResults.length + fileResults.length) { // Project file item clicked - const fileIndex = index - commands.length + const fileIndex = index - commands.length - citationResults.length const result = fileResults[fileIndex] if (result && onSelectFile) { onSelectFile(result.file.path, result.lineNumber) @@ -274,7 +293,7 @@ const CommandPalette: React.FC = ({ } } else { // Document item clicked - const docIndex = index - commands.length - fileResults.length + const docIndex = index - commands.length - citationResults.length - fileResults.length if (results[docIndex]) { onSelectDocument(results[docIndex].document.id) handleClose() @@ -356,12 +375,39 @@ const CommandPalette: React.FC = ({
)} + {/* Citations Section */} + {citationResults.length > 0 && ( +
+
Citations
+ {citationResults.map((citation, index) => { + const itemIndex = commands.length + index + return ( +
handleItemClick(itemIndex)} + > +
+ +
+
+
{citation.title}
+
+ {citation.creatorsText}{citation.year ? ` (${citation.year})` : ''} · {citation.citeKey} +
+
+
+ ) + })} +
+ )} + {/* Project Files Section */} {fileResults.length > 0 && (
Project Files
{fileResults.map((result, index) => { - const itemIndex = commands.length + index + const itemIndex = commands.length + citationResults.length + index return (
= ({ {query ? 'Documents' : 'Recent'}
{results.map((result, index) => { - const itemIndex = commands.length + fileResults.length + index + const itemIndex = commands.length + citationResults.length + fileResults.length + index return (
= ({ )} {/* Empty State */} - {results.length === 0 && fileResults.length === 0 && commands.length === 0 && ( + {results.length === 0 && fileResults.length === 0 && citationResults.length === 0 && commands.length === 0 && (
{query ? (
diff --git a/app/src/components/EmptyState.css b/app/src/components/EmptyState.css index 4591a6b..c5bf9a0 100644 --- a/app/src/components/EmptyState.css +++ b/app/src/components/EmptyState.css @@ -9,7 +9,7 @@ color: var(--color-text); padding: 60px 40px; opacity: 0; - animation: fadeIn 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; + animation: fadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; position: relative; overflow: hidden; } @@ -66,14 +66,14 @@ background: rgba(34, 31, 26, 0.7); border: 1px solid rgba(232, 227, 211, 0.08); border-radius: 28px; - backdrop-filter: blur(60px) saturate(200%); - -webkit-backdrop-filter: blur(60px) saturate(200%); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); box-shadow: var(--shadow-xl), 0 0 0 1px rgba(232, 227, 211, 0.06) inset, 0 0 0 1.5px rgba(217, 119, 87, 0.04) inset, 0 0 20px rgba(217, 119, 87, 0.15), 0 0 40px rgba(217, 119, 87, 0.08); - transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1); + transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease; } .empty-state-content:hover { @@ -96,7 +96,7 @@ letter-spacing: -0.04em; line-height: 1.1; opacity: 0; - animation: slideUp 0.8s ease-out forwards; + animation: slideUp 0.4s ease-out forwards; animation-delay: 0.2s; background: linear-gradient(135deg, #E8E3D3 0%, #C9A88B 100%); -webkit-background-clip: text; @@ -118,7 +118,7 @@ font-weight: 500; margin-bottom: 16px; opacity: 0; - animation: slideUp 0.8s ease-out forwards; + animation: slideUp 0.4s ease-out forwards; animation-delay: 0.3s; } @@ -129,7 +129,7 @@ font-family: var(--font-body); margin-bottom: 40px; opacity: 0; - animation: slideUp 0.8s ease-out forwards; + animation: slideUp 0.4s ease-out forwards; animation-delay: 0.4s; } @@ -137,7 +137,7 @@ .empty-state-actions { margin-bottom: 36px; opacity: 0; - animation: slideUp 0.8s ease-out forwards; + animation: slideUp 0.4s ease-out forwards; animation-delay: 0.5s; } @@ -154,7 +154,8 @@ font-weight: 600; font-family: var(--font-body); cursor: pointer; - transition: all 0.2s ease; + transition: background 0.15s ease, color 0.15s ease; + touch-action: manipulation; box-shadow: var(--shadow-md); } @@ -170,7 +171,7 @@ gap: 4px; flex-wrap: wrap; opacity: 0; - animation: slideUp 0.8s ease-out forwards; + animation: slideUp 0.4s ease-out forwards; animation-delay: 0.6s; } @@ -185,7 +186,8 @@ cursor: pointer; padding: 8px 14px; border-radius: 8px; - transition: all 0.2s ease; + transition: background 0.15s ease, color 0.15s ease; + touch-action: manipulation; display: inline-flex; align-items: center; gap: 6px; @@ -223,7 +225,8 @@ transform: translateX(-50%); color: var(--color-text-muted); opacity: 0.6; - transition: all 0.2s ease; + transition: background 0.15s ease, color 0.15s ease; + touch-action: manipulation; display: flex; align-items: center; justify-content: center; @@ -297,7 +300,7 @@ background: rgba(232, 227, 211, 0.04); border-radius: 12px; opacity: 0; - animation: slideUp 0.8s ease-out forwards; + animation: slideUp 0.4s ease-out forwards; animation-delay: 0.7s; } diff --git a/app/src/components/LaTeXEditor/InlineHint.ts b/app/src/components/LaTeXEditor/InlineHint.ts new file mode 100644 index 0000000..f2875de --- /dev/null +++ b/app/src/components/LaTeXEditor/InlineHint.ts @@ -0,0 +1,287 @@ +/** + * InlineHint - Inline hint indicators for CodeMirror + * + * Adds a subtle dotted underline to text ranges with a small clickable + * indicator at the end. Clicking the indicator shows a tooltip with an action. + * This approach avoids interfering with CodeMirror's built-in hover tooltips. + * + * Usage: + * import { showInlineHint, clearInlineHints, inlineHintExtension } from './InlineHint' + * + * // Add extension when creating editor + * extensions: [inlineHintExtension] + * + * // Show a hint on a text range + * showInlineHint(view, { + * from: startPos, + * to: endPos, + * text: 'Add bibliography?', + * onClick: () => insertBibliography() + * }) + * + * // Clear all hints + * clearInlineHints(view) + */ + +import { EditorView, Decoration, WidgetType } from '@codemirror/view' +import { StateField, StateEffect, RangeSet } from '@codemirror/state' + +type DecorationSet = RangeSet + +// ======================================== +// Types +// ======================================== + +export interface InlineHintConfig { + /** Start position of text to underline */ + from: number + /** End position of text to underline */ + to: number + /** Tooltip text to display on click */ + text: string + /** Callback when action is triggered */ + onClick: () => void + /** Optional: auto-dismiss after delay (ms). Default: no auto-dismiss */ + dismissAfter?: number + /** Optional: unique ID for this hint (for targeted removal) */ + id?: string +} + +// Store hint data globally so widgets can access it +const hintData = new Map void }>() +let hintIdCounter = 0 + +// ======================================== +// Widget for the clickable indicator +// ======================================== + +class HintIndicatorWidget extends WidgetType { + constructor(readonly hintId: string) { + super() + } + + toDOM(view: EditorView): HTMLElement { + const indicator = document.createElement('span') + indicator.className = 'cm-hint-indicator' + indicator.textContent = '+' + indicator.setAttribute('role', 'button') + indicator.setAttribute('tabindex', '0') + indicator.setAttribute('aria-label', 'Suggestion available') + + const data = hintData.get(this.hintId) + + indicator.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + if (data) { + this.showPopover(indicator, data.text, data.onClick, view) + } + }) + + indicator.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + if (data) { + this.showPopover(indicator, data.text, data.onClick, view) + } + } + }) + + return indicator + } + + private showPopover( + anchor: HTMLElement, + text: string, + onClick: () => void, + view: EditorView + ) { + // Remove any existing popover + hideActivePopover() + + const popover = document.createElement('div') + popover.className = 'cm-hint-popover' + popover.textContent = text + popover.setAttribute('role', 'button') + popover.setAttribute('tabindex', '0') + + // Position popover above the indicator + const anchorRect = anchor.getBoundingClientRect() + const editorRect = view.dom.getBoundingClientRect() + popover.style.left = `${anchorRect.left - editorRect.left}px` + popover.style.top = `${anchorRect.top - editorRect.top - 28}px` + + popover.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + onClick() + hideActivePopover() + // Remove this hint after action + removeInlineHint(view, this.hintId) + }) + + popover.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + hideActivePopover() + removeInlineHint(view, this.hintId) + } else if (e.key === 'Escape') { + hideActivePopover() + } + }) + + // Click outside to dismiss + const dismissHandler = (e: MouseEvent) => { + if (!popover.contains(e.target as Node) && e.target !== anchor) { + hideActivePopover() + document.removeEventListener('click', dismissHandler) + } + } + setTimeout(() => document.addEventListener('click', dismissHandler), 0) + + view.dom.appendChild(popover) + activePopover = popover + popover.focus() + } + + eq(other: HintIndicatorWidget): boolean { + return this.hintId === other.hintId + } + + ignoreEvent(): boolean { + return false + } +} + +let activePopover: HTMLElement | null = null + +function hideActivePopover() { + if (activePopover) { + activePopover.remove() + activePopover = null + } +} + +// ======================================== +// State Management +// ======================================== + +const addHintEffect = StateEffect.define() +const removeHintEffect = StateEffect.define() +const clearAllHintsEffect = StateEffect.define() + +const inlineHintField = StateField.define({ + create() { + return Decoration.none + }, + + update(hints, tr) { + hints = hints.map(tr.changes) + + for (const effect of tr.effects) { + if (effect.is(addHintEffect)) { + const config = effect.value + const hintId = config.id || `hint-${++hintIdCounter}` + + // Store hint data for widget access + hintData.set(hintId, { text: config.text, onClick: config.onClick }) + + // Widget decoration for the clickable indicator (after the text) + const widgetDeco = Decoration.widget({ + widget: new HintIndicatorWidget(hintId), + side: 1 + }) + + hints = hints.update({ + add: [widgetDeco.range(config.to)] + }) + } + + if (effect.is(removeHintEffect)) { + const idToRemove = effect.value + if (idToRemove === undefined) { + // Remove all + hintData.clear() + hints = Decoration.none + } else { + // Remove specific hint + hintData.delete(idToRemove) + const newDecos: { from: number; to: number; value: Decoration }[] = [] + const iter = hints.iter() + while (iter.value) { + const spec = iter.value.spec + const id = spec.attributes?.['data-hint-id'] || + (spec.widget as HintIndicatorWidget)?.hintId + if (id !== idToRemove) { + newDecos.push({ from: iter.from, to: iter.to, value: iter.value }) + } + iter.next() + } + hints = Decoration.set(newDecos.map(d => d.value.range(d.from, d.to)), true) + } + } + + if (effect.is(clearAllHintsEffect)) { + hintData.clear() + hints = Decoration.none + } + } + + return hints + }, + + provide: f => EditorView.decorations.from(f) +}) + +// ======================================== +// Public API +// ======================================== + +/** + * Extension to include in editor - required for hints to work + */ +export const inlineHintExtension = [inlineHintField] + +/** + * Show an inline hint on a text range + */ +export function showInlineHint(view: EditorView, config: InlineHintConfig): void { + view.dispatch({ + effects: addHintEffect.of(config) + }) + + if (config.dismissAfter) { + setTimeout(() => { + removeInlineHint(view, config.id) + }, config.dismissAfter) + } +} + +/** + * Remove a specific hint by ID, or all hints if no ID provided + */ +export function removeInlineHint(view: EditorView, id?: string): void { + hideActivePopover() + view.dispatch({ + effects: removeHintEffect.of(id) + }) +} + +/** + * Clear all inline hints + */ +export function clearInlineHints(view: EditorView): void { + hideActivePopover() + view.dispatch({ + effects: clearAllHintsEffect.of(undefined) + }) +} + +/** + * Check if document has any inline hints + */ +export function hasInlineHints(view: EditorView): boolean { + const hints = view.state.field(inlineHintField, false) + return hints ? hints.size > 0 : false +} diff --git a/app/src/components/LaTeXEditor/LaTeXEditor.css b/app/src/components/LaTeXEditor/LaTeXEditor.css index 177f303..8937c32 100644 --- a/app/src/components/LaTeXEditor/LaTeXEditor.css +++ b/app/src/components/LaTeXEditor/LaTeXEditor.css @@ -2,7 +2,8 @@ height: 100%; width: 100%; background: var(--color-base); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + /* Only animate background - avoids GPU recalc on all properties */ + transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; padding: 0; @@ -130,6 +131,12 @@ border-radius: 4px; cursor: pointer; transition: background 0.15s ease; + touch-action: manipulation; +} + +.toolbar-item:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; } .toolbar-item:hover { @@ -185,7 +192,7 @@ border: none; border-radius: 4px; cursor: pointer; - transition: all 0.15s ease; + transition: background 0.15s ease, color 0.15s ease; } .compile-status:hover { @@ -238,7 +245,7 @@ transform: translateY(-50%); height: 2px; background: transparent; - transition: all 0.2s ease; + transition: background 0.2s ease, box-shadow 0.2s ease; } .console-resize-handle:hover::before { @@ -285,7 +292,7 @@ border-radius: 4px; color: var(--color-text-tertiary); cursor: pointer; - transition: all 0.15s ease; + transition: background 0.15s ease, color 0.15s ease; } .console-action:hover { @@ -299,6 +306,8 @@ padding: 8px 0; font-family: 'SF Mono', Monaco, monospace; font-size: 11px; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } .console-logs::-webkit-scrollbar { @@ -458,8 +467,8 @@ left: 50%; transform: translateX(-50%); background: var(--color-base-lighter); - backdrop-filter: blur(40px); - -webkit-backdrop-filter: blur(40px); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px; @@ -487,7 +496,7 @@ justify-content: space-between; padding: 8px 14px; border-radius: 6px; - transition: all 0.15s ease; + transition: background 0.15s ease; } .compiler-setting-row:hover { @@ -547,7 +556,7 @@ color: var(--color-text-primary); cursor: pointer; outline: none; - transition: all 0.15s ease; + transition: border-color 0.15s ease; } .setting-select:hover { @@ -574,7 +583,7 @@ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; color: var(--color-text-secondary); cursor: pointer; - transition: all 0.15s ease; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; } .setting-action-btn:hover { @@ -582,3 +591,73 @@ border-color: rgba(232, 227, 211, 0.2); color: var(--color-text-primary); } + +/* Small clickable indicator after citations without bibliography */ +.cm-hint-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin-left: 6px; + font-size: 10px; + font-weight: 600; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + color: var(--color-text-tertiary); + background: rgba(232, 227, 211, 0.08); + border-radius: 3px; + cursor: pointer; + vertical-align: middle; + transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease; +} + +.cm-hint-indicator:hover { + background: var(--color-accent); + color: var(--color-base); + transform: scale(1.1); +} + +.cm-hint-indicator:focus { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +/* Click-triggered popover for hint actions */ +.cm-hint-popover { + position: absolute; + background: var(--color-base-darker); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 6px 12px; + font-size: 12px; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + color: var(--color-text-secondary); + cursor: pointer; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + white-space: nowrap; + transition: background 0.15s ease, color 0.15s ease; + animation: hintPopoverIn 0.15s cubic-bezier(0.23, 1, 0.32, 1); +} + +@keyframes hintPopoverIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.cm-hint-popover:hover { + background: var(--color-accent); + color: var(--color-base); + border-color: var(--color-accent); +} + +.cm-hint-popover:focus { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} diff --git a/app/src/components/LaTeXEditor/LaTeXEditor.tsx b/app/src/components/LaTeXEditor/LaTeXEditor.tsx index 3ec0dd5..95c148d 100644 --- a/app/src/components/LaTeXEditor/LaTeXEditor.tsx +++ b/app/src/components/LaTeXEditor/LaTeXEditor.tsx @@ -10,6 +10,9 @@ import { latex, latexLinter } from 'codemirror-lang-latex' import { Settings, Play, Menu } from 'lucide-react' import DocumentManager from '../../services/DocumentManager' import CompilerService from '../../services/CompilerService' +import EventBus from '../../services/EventBus' +import CitationPopover from '../CitationPopover' +import stateStore from '../../services/StateStore' import './LaTeXEditor.css' interface LaTeXEditorProps { @@ -17,6 +20,7 @@ interface LaTeXEditorProps { onChange: (value: string) => void onSelectionChange?: (start: number, end: number, selectedText: string) => void onMenuClick?: () => void + documentId?: string compilerSettings?: CompilerSettings onCompilerSettingsChange?: (settings: CompilerSettings) => void compileStatus?: CompileStatusType @@ -184,11 +188,12 @@ const desertHighlightStyle = HighlightStyle.define([ { tag: tags.variableName, color: '#c9956e' }, ]) -const LaTeXEditor: React.FC = ({ +const LaTeXEditor: React.FC = React.memo(({ value, onChange, onSelectionChange, onMenuClick, + documentId, compilerSettings: externalSettings, onCompilerSettingsChange, compileStatus = 'idle', @@ -209,6 +214,13 @@ const LaTeXEditor: React.FC = ({ const [sections, setSections] = useState<{ level: number; title: string; line: number }[]>([]) const [showCompilerSettings, setShowCompilerSettings] = useState(false) + // Citation popover state + const [citationPopoverOpen, setCitationPopoverOpen] = useState(false) + const [citationPopoverPos, setCitationPopoverPos] = useState({ top: 0, left: 0 }) + const citationInsertPosRef = useRef(0) + // Track if user dismissed the popover (via Escape) to prevent re-opening + const citationDismissedRef = useRef(false) + // Use external settings if provided, otherwise use local state const [localSettings, setLocalSettings] = useState({ autoCompile: true, @@ -225,10 +237,21 @@ const LaTeXEditor: React.FC = ({ // Track if we're updating value from external source const isExternalUpdate = useRef(false) + // Ref for documentId to avoid stale closure in updateListener + const documentIdRef = useRef(documentId) + useEffect(() => { + documentIdRef.current = documentId + }, [documentId]) + + // Track which document we've restored cursor for (to avoid duplicate restores) + const restoredCursorForDoc = useRef(null) + // Compartment for dynamic configuration const readOnlyCompartment = useMemo(() => new Compartment(), []) - // Subscribe to compiler logs + // Subscribe to compiler logs with truncation to prevent memory leak + // Keep max 500 logs to bound memory usage + const MAX_LOGS = 500 useEffect(() => { const unsubscribe = CompilerService.onLog((message) => { const now = new Date() @@ -237,7 +260,14 @@ const LaTeXEditor: React.FC = ({ : message.toLowerCase().includes('warning') ? 'warning' : message.toLowerCase().includes('success') ? 'success' : 'info' - setLogs(prev => [...prev, { time, type, message }]) + setLogs(prev => { + const newLogs = [...prev, { time, type, message }] + // Truncate if exceeding max - keep newest logs + if (newLogs.length > MAX_LOGS) { + return newLogs.slice(-MAX_LOGS) + } + return newLogs + }) }) return unsubscribe }, []) @@ -247,6 +277,28 @@ const LaTeXEditor: React.FC = ({ if (compileStatus === 'compiling') setLogs([]) }, [compileStatus]) + // Reset cursor restoration tracking when document changes + useEffect(() => { + // When switching documents, allow cursor restoration for the new document + if (documentId && restoredCursorForDoc.current !== documentId) { + restoredCursorForDoc.current = null + } + }, [documentId]) + + // Save cursor position when selection changes + const saveCursorPosition = useCallback((start: number, end: number) => { + if (documentId) { + stateStore.setCursorPosition(documentId, start, end) + } + }, [documentId]) + + // Save scroll position when scrolling + const saveScrollPosition = useCallback((scrollTop: number, scrollLeft: number) => { + if (documentId) { + stateStore.setScrollPosition(documentId, scrollTop, scrollLeft) + } + }, [documentId]) + // Create editor on mount useEffect(() => { if (!editorContainerRef.current || editorViewRef.current) return @@ -257,13 +309,49 @@ const LaTeXEditor: React.FC = ({ onChange(newValue) } - if (update.selectionSet && onSelectionChange) { + if (update.selectionSet) { const selection = update.state.selection.main const selectedText = update.state.sliceDoc(selection.from, selection.to) - onSelectionChange(selection.from, selection.to, selectedText) + if (onSelectionChange) { + onSelectionChange(selection.from, selection.to, selectedText) + } - // Save cursor position + // Save cursor position to DocumentManager (legacy) DocumentManager.setCursorPosition({ start: selection.from, end: selection.to }) + + // Save cursor position to StateStore (uses ref to avoid stale closure) + if (documentIdRef.current) { + stateStore.setCursorPosition(documentIdRef.current, selection.from, selection.to) + } + } + + // Detect \cite{ pattern for citation popover (only on doc changes, not cursor movement) + if (update.docChanged) { + const pos = update.state.selection.main.head + const doc = update.state.doc.toString() + // Look back up to 20 chars to find \cite{ or \citep{ or \citet{ etc + const lookback = doc.slice(Math.max(0, pos - 20), pos) + const citeMatch = lookback.match(/\\cite[pt]?\{([^}]*)$/) + + if (citeMatch) { + // We're inside a cite command - show popover (unless dismissed) + if (!citationDismissedRef.current) { + const coords = update.view.coordsAtPos(pos) + if (coords) { + setCitationPopoverPos({ + top: coords.bottom + 4, + left: coords.left + }) + citationInsertPosRef.current = pos + setCitationPopoverOpen(true) + } + } + } else { + // Close popover and reset dismissed state when leaving cite context + setCitationPopoverOpen(false) + citationDismissedRef.current = false + } + } }) @@ -273,7 +361,7 @@ const LaTeXEditor: React.FC = ({ { key: 'Mod-k', run: () => { - window.dispatchEvent(new CustomEvent('toggleCommandPalette')) + EventBus.emit('toggleCommandPalette') return true }, }, @@ -281,7 +369,7 @@ const LaTeXEditor: React.FC = ({ { key: 'Mod-Shift-k', run: () => { - window.dispatchEvent(new CustomEvent('toggleCommandPalette')) + EventBus.emit('toggleCommandPalette') return true }, }, @@ -289,7 +377,7 @@ const LaTeXEditor: React.FC = ({ { key: 'Mod-Shift-f', run: () => { - window.dispatchEvent(new CustomEvent('toggleCommandPalette')) + EventBus.emit('toggleCommandPalette') return true }, }, @@ -297,7 +385,7 @@ const LaTeXEditor: React.FC = ({ { key: 'Mod-s', run: () => { - window.dispatchEvent(new CustomEvent('triggerCompile')) + EventBus.emit('triggerCompile') return true }, }, @@ -437,20 +525,83 @@ const LaTeXEditor: React.FC = ({ const view = editorViewRef.current if (!view) return - const currentValue = view.state.doc.toString() - if (currentValue !== value) { + // Optimization: Check length first to avoid O(n) string comparison on every keystroke + // For large documents (thousands of lines), this skips creating a full string copy + // when the length already differs (common case: adding/deleting text) + const currentLength = view.state.doc.length + if (currentLength !== value.length) { + // Length differs, definitely need to update isExternalUpdate.current = true view.dispatch({ - changes: { - from: 0, - to: currentValue.length, - insert: value, - }, + changes: { from: 0, to: currentLength, insert: value }, }) isExternalUpdate.current = false + } else if (currentLength > 0) { + // Same length - need full comparison (rare case: character replacement) + // Only create string copy when absolutely necessary + const currentValue = view.state.doc.toString() + if (currentValue !== value) { + isExternalUpdate.current = true + view.dispatch({ + changes: { from: 0, to: currentLength, insert: value }, + }) + isExternalUpdate.current = false + } + } + + // Restore cursor/scroll position AFTER content is loaded (once per document) + const docId = documentIdRef.current + if (docId && value.length > 0 && restoredCursorForDoc.current !== docId) { + restoredCursorForDoc.current = docId + + stateStore.getDocumentState(docId).then(state => { + if (!state || !editorViewRef.current) return + + const currentView = editorViewRef.current + const docLength = currentView.state.doc.length + + // Restore cursor position (clamp to document length) + const cursorStart = Math.min(state.cursorStart, docLength) + const cursorEnd = Math.min(state.cursorEnd, docLength) + + // Only restore if there's a meaningful saved position + if (cursorStart > 0 || cursorEnd > 0) { + currentView.dispatch({ + selection: { anchor: cursorStart, head: cursorEnd } + }) + } + + // Restore scroll position + if (state.scrollTop > 0 || state.scrollLeft > 0) { + currentView.scrollDOM.scrollTop = state.scrollTop + currentView.scrollDOM.scrollLeft = state.scrollLeft + } + }) } }, [value]) + // Set up scroll event listener for state persistence + useEffect(() => { + const view = editorViewRef.current + if (!view || !documentId) return + + let scrollTimeout: number | null = null + const handleScroll = () => { + // Debounce scroll saves to avoid excessive writes + if (scrollTimeout) clearTimeout(scrollTimeout) + scrollTimeout = window.setTimeout(() => { + const { scrollTop, scrollLeft } = view.scrollDOM + saveScrollPosition(scrollTop, scrollLeft) + }, 200) + } + + view.scrollDOM.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + view.scrollDOM.removeEventListener('scroll', handleScroll) + if (scrollTimeout) clearTimeout(scrollTimeout) + } + }, [documentId, saveScrollPosition]) + // Listen for goToLine events from search results useEffect(() => { const handleGoToLine = (e: CustomEvent<{ line: number }>) => { @@ -472,6 +623,21 @@ const LaTeXEditor: React.FC = ({ return () => window.removeEventListener('goToLine', handleGoToLine as EventListener) }, []) + // Listen for insertText events from CommandPalette citation selection + useEffect(() => { + return EventBus.on('insertText', ({ text }) => { + const view = editorViewRef.current + if (!view) return + + const pos = view.state.selection.main.head + view.dispatch({ + changes: { from: pos, to: pos, insert: text }, + selection: { anchor: pos + text.length } + }) + view.focus() + }) + }, []) + // Helper to wrap selection const wrapSelectionInView = useCallback((view: EditorView, prefix: string, suffix: string) => { const selection = view.state.selection.main @@ -493,11 +659,11 @@ const LaTeXEditor: React.FC = ({ }, []) const handleCommandPalette = useCallback(() => { - window.dispatchEvent(new CustomEvent('toggleCommandPalette')) + EventBus.emit('toggleCommandPalette') }, []) const handleHelp = useCallback(() => { - window.dispatchEvent(new CustomEvent('toggleHelp')) + EventBus.emit('toggleHelp') }, []) const handleClearLogs = useCallback(() => { @@ -509,6 +675,30 @@ const LaTeXEditor: React.FC = ({ navigator.clipboard.writeText(logText) }, [logs]) + // Handle citation insertion from popover + const handleCitationInsert = useCallback((citeKey: string, _bibtex: string) => { + const view = editorViewRef.current + if (!view) return + + const pos = citationInsertPosRef.current + // Insert the cite key and close the brace + view.dispatch({ + changes: { from: pos, to: pos, insert: `${citeKey}}` }, + selection: { anchor: pos + citeKey.length + 1 } + }) + setCitationPopoverOpen(false) + // Reset dismissed flag after the call stack completes (after onClose runs) + // This ensures future citations will trigger the popover + setTimeout(() => { + citationDismissedRef.current = false + }, 0) + view.focus() + }, []) + + // Use RAF to debounce resize updates - prevents layout thrashing + const pendingHeightRef = useRef(null) + const rafIdRef = useRef(null) + const handleConsoleResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault() isResizingRef.current = true @@ -519,11 +709,30 @@ const LaTeXEditor: React.FC = ({ if (!isResizingRef.current) return const delta = startY - e.clientY const newHeight = Math.max(100, Math.min(600, startHeight + delta)) - setConsoleHeight(newHeight) + + // Store pending height and schedule RAF update + pendingHeightRef.current = newHeight + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(() => { + if (pendingHeightRef.current !== null) { + setConsoleHeight(pendingHeightRef.current) + } + rafIdRef.current = null + }) + } } const handleMouseUp = () => { isResizingRef.current = false + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + } + // Apply final height + if (pendingHeightRef.current !== null) { + setConsoleHeight(pendingHeightRef.current) + pendingHeightRef.current = null + } document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } @@ -587,10 +796,13 @@ const LaTeXEditor: React.FC = ({ return results }, []) - // Update sections when content changes + // Update sections when content changes (debounced to avoid parsing on every keystroke) useEffect(() => { - const newSections = parseSections(value) - setSections(newSections) + const timeoutId = setTimeout(() => { + const newSections = parseSections(value) + setSections(newSections) + }, 300) + return () => clearTimeout(timeoutId) }, [value, parseSections]) // Jump to section @@ -796,8 +1008,30 @@ const LaTeXEditor: React.FC = ({
)} + + { + setCitationPopoverOpen(false) + citationDismissedRef.current = true + }} + onInsert={handleCitationInsert} + />
) -} +}, (prevProps, nextProps) => { + // Custom comparison - only re-render when significant props change + return ( + prevProps.value === nextProps.value && + prevProps.compileStatus === nextProps.compileStatus && + prevProps.compileTimeMs === nextProps.compileTimeMs && + prevProps.compilerSettings?.autoCompile === nextProps.compilerSettings?.autoCompile && + prevProps.compilerSettings?.compiler === nextProps.compilerSettings?.compiler && + prevProps.compilerSettings?.ctanFetch === nextProps.compilerSettings?.ctanFetch && + prevProps.compilerSettings?.cachePreamble === nextProps.compilerSettings?.cachePreamble && + prevProps.compilerSettings?.autoUnload === nextProps.compilerSettings?.autoUnload + ) +}) export default LaTeXEditor diff --git a/app/src/components/PDFViewer.css b/app/src/components/PDFViewer.css index cfdde9c..bf3c748 100644 --- a/app/src/components/PDFViewer.css +++ b/app/src/components/PDFViewer.css @@ -4,7 +4,7 @@ background: var(--color-base); position: relative; overflow: hidden; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .pdf-document { @@ -21,6 +21,8 @@ z-index: 1; background: transparent; container-type: inline-size; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } @media (min-width: 1200px) { @@ -144,7 +146,7 @@ border: none; border-radius: 8px; cursor: pointer; - transition: all 0.15s ease; + transition: background 0.15s ease, transform 0.15s ease; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; } @@ -189,7 +191,7 @@ border-radius: 4px; background: transparent; border: none; - transition: all 0.15s ease; + transition: background 0.15s ease, color 0.15s ease; } .zoom-level:hover { @@ -204,8 +206,8 @@ left: 50%; transform: translateX(-50%); background: var(--color-base-lighter); - backdrop-filter: blur(40px); - -webkit-backdrop-filter: blur(40px); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px; @@ -238,7 +240,7 @@ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; text-align: left; cursor: pointer; - transition: all 0.15s ease; + transition: background 0.15s ease, color 0.15s ease; } .zoom-preset:hover { @@ -318,8 +320,14 @@ border-radius: 4px; color: var(--color-text-tertiary); cursor: pointer; - transition: all 0.15s ease; + transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease; padding: 0; + touch-action: manipulation; +} + +.control-button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; } .control-button:hover:not(:disabled) { @@ -351,14 +359,19 @@ border: 1px solid var(--color-border); border-radius: 4px; padding: 4px 8px; - font-size: 11px; + font-size: 16px; /* Prevents iOS zoom on focus */ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; color: var(--color-text-primary); width: 160px; height: 24px; box-sizing: border-box; outline: none; - transition: all 0.15s ease; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.search-input:focus-visible { + border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(181, 133, 106, 0.2); } .search-input:focus { @@ -402,12 +415,14 @@ .search-highlight { background: rgba(255, 200, 0, 0.35); border-radius: 2px; - transition: background 0.15s ease; + /* No transition on non-active highlights - saves CPU on 100+ elements */ } .search-highlight.active { background: rgba(255, 140, 0, 0.6); box-shadow: 0 0 0 2px rgba(255, 140, 0, 0.4); + /* Only animate the active highlight */ + transition: background 0.15s ease, box-shadow 0.15s ease; } /* Outline/TOC Panel */ @@ -469,7 +484,7 @@ color: var(--color-text-tertiary); font-size: 16px; cursor: pointer; - transition: all 0.15s ease; + transition: background 0.15s ease, color 0.15s ease; } .pdf-outline-close:hover { @@ -481,6 +496,8 @@ flex: 1; overflow-y: auto; padding: 8px 0; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } .pdf-outline-content::-webkit-scrollbar { @@ -510,7 +527,6 @@ gap: 6px; padding: 8px 12px; cursor: pointer; - transition: background 0.15s ease; min-height: 32px; } diff --git a/app/src/components/PDFViewer.tsx b/app/src/components/PDFViewer.tsx index 7611c91..4db1f5a 100644 --- a/app/src/components/PDFViewer.tsx +++ b/app/src/components/PDFViewer.tsx @@ -7,12 +7,16 @@ * - Zoom presets dropdown * - Fit-width mode * - Text selection + * - Memory-optimized for 100+ page documents: + * - Explicit page.cleanup() after rendering + * - Streaming search (doesn't load all pages at once) + * - Page cache with cleanup for off-screen pages */ import React, { useState, useRef, useEffect, useCallback } from 'react' import { ChevronLeft, ChevronRight, Search, Sun, Moon, ChevronUp, ChevronDown, X, Download } from 'lucide-react' import * as pdfjsLib from 'pdfjs-dist' -import type { TextItem } from 'pdfjs-dist/types/src/display/api' +import type { TextItem, TextContent } from 'pdfjs-dist/types/src/display/api' import './PDFViewer.css' // Set worker path @@ -21,6 +25,9 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( import.meta.url ).toString() +// How many pages to keep in cache around current page +const PAGE_CACHE_SIZE = 3 + interface PDFViewerProps { pdfUrl?: string pdfData?: Uint8Array @@ -49,7 +56,7 @@ const ZOOM_PRESETS = [ { label: '200%', value: 2.0 }, ] -const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCompiling }) => { +const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, onCompile, isCompiling }) => { const [pdf, setPdf] = useState(null) const [currentPage, setCurrentPage] = useState(1) const [pageCount, setPageCount] = useState(0) @@ -76,10 +83,25 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom const zoomButtonRef = useRef(null) const pdfDataCopyRef = useRef(null) // Keep a copy for download (pdf.js may detach the buffer) const pdfRef = useRef(null) // Track current PDF for cleanup + const pageCacheRef = useRef>(new Map()) // LRU page cache + const searchAbortRef = useRef(null) // For cancelling ongoing search + const measureCanvasRef = useRef(null) // Reusable canvas for text measurement + const pixelRatioRef = useRef(window.devicePixelRatio || 1) // Cache pixel ratio (rarely changes) + const resizeTimeoutRef = useRef | null>(null) // For debounced resize // Cleanup PDF on unmount useEffect(() => { return () => { + // Cancel any ongoing search + if (searchAbortRef.current) { + searchAbortRef.current.abort() + } + // Cleanup all cached pages + for (const page of pageCacheRef.current.values()) { + page.cleanup() + } + pageCacheRef.current.clear() + // Destroy PDF document if (pdfRef.current) { pdfRef.current.destroy() pdfRef.current = null @@ -112,13 +134,13 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom let loadingTask: pdfjsLib.PDFDocumentLoadingTask if (pdfData) { - // Keep a copy for download (pdf.js may detach the buffer) - // pdfData is already a Uint8Array, but we need our own copy - const copy = new Uint8Array(pdfData.length) - copy.set(pdfData) - pdfDataCopyRef.current = copy - // Pass a copy to pdf.js as well since it may detach the buffer - loadingTask = pdfjsLib.getDocument({ data: copy.slice() }) + // Keep the original pdfData reference for download + // (it comes from compilation and won't be modified) + pdfDataCopyRef.current = pdfData + // Create a single copy for pdf.js since it may detach the ArrayBuffer + const pdfCopy = new Uint8Array(pdfData.length) + pdfCopy.set(pdfData) + loadingTask = pdfjsLib.getDocument({ data: pdfCopy }) } else if (pdfUrl) { loadingTask = pdfjsLib.getDocument(pdfUrl) } else { @@ -129,6 +151,11 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom // Destroy previous PDF before setting new one (frees worker) if (pdfRef.current) { + // Clean up all cached pages first + for (const page of pageCacheRef.current.values()) { + page.cleanup() + } + pageCacheRef.current.clear() pdfRef.current.destroy() } @@ -152,6 +179,37 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom } }, [pdfUrl, pdfData]) + // Get page from cache or load and cache it + const getPageCached = useCallback(async (pdf: pdfjsLib.PDFDocumentProxy, pageNum: number): Promise => { + const cache = pageCacheRef.current + + // Return cached page if available + if (cache.has(pageNum)) { + return cache.get(pageNum)! + } + + // Load and cache the page + const page = await pdf.getPage(pageNum) + cache.set(pageNum, page) + + // Evict pages outside the cache window (LRU-style based on distance from current page) + // Keep pages within PAGE_CACHE_SIZE of the current page + const pagesToKeep = new Set() + for (let i = Math.max(1, pageNum - PAGE_CACHE_SIZE); i <= Math.min(pdf.numPages, pageNum + PAGE_CACHE_SIZE); i++) { + pagesToKeep.add(i) + } + + // Cleanup pages outside the window + for (const [cachedPageNum, cachedPage] of cache) { + if (!pagesToKeep.has(cachedPageNum)) { + cachedPage.cleanup() + cache.delete(cachedPageNum) + } + } + + return page + }, []) + // Render current page const renderPage = useCallback(async () => { if (!pdf || !canvasRef.current) return @@ -168,15 +226,15 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom } try { - const page = await pdf.getPage(currentPage) + const page = await getPageCached(pdf, currentPage) const viewport = page.getViewport({ scale }) const canvas = canvasRef.current const context = canvas.getContext('2d') if (!context) return - // Set canvas size - const pixelRatio = window.devicePixelRatio || 1 + // Set canvas size (use cached pixel ratio - rarely changes) + const pixelRatio = pixelRatioRef.current canvas.width = viewport.width * pixelRatio canvas.height = viewport.height * pixelRatio canvas.style.width = `${viewport.width}px` @@ -193,12 +251,15 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom }) await renderTaskRef.current.promise + // Fetch text content once and reuse for text layer and search highlights + const textContent = await page.getTextContent() + // Render text layer for selection - await renderTextLayer(page, viewport) + await renderTextLayer(page, viewport, textContent) - // Render search highlights if there are matches + // Render search highlights if there are matches (reuses textContent) if (searchMatches.length > 0) { - renderSearchHighlights(page, viewport) + renderSearchHighlights(textContent, viewport) } } catch (err: unknown) { // Ignore cancelled renders @@ -206,24 +267,33 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom console.error('Render error:', err) } } - }, [pdf, currentPage, scale, searchMatches]) + }, [pdf, currentPage, scale, searchMatches, getPageCached]) + + // Get or create the measurement canvas (reused to avoid GC pressure) + const getMeasureContext = useCallback((): CanvasRenderingContext2D | null => { + if (!measureCanvasRef.current) { + measureCanvasRef.current = document.createElement('canvas') + } + return measureCanvasRef.current.getContext('2d') + }, []) // Render text layer for selection // Uses scaleX transform to match text width to PDF bounding box (same approach as pdf.js) - const renderTextLayer = async (page: pdfjsLib.PDFPageProxy, viewport: pdfjsLib.PageViewport) => { + // Optimized: DocumentFragment for batch DOM insertion, cssText for single style assignment + const renderTextLayer = async (_page: pdfjsLib.PDFPageProxy, viewport: pdfjsLib.PageViewport, textContent: TextContent) => { if (!textLayerRef.current) return - const textContent = await page.getTextContent() const textLayerDiv = textLayerRef.current textLayerDiv.innerHTML = '' - textLayerDiv.style.width = `${viewport.width}px` - textLayerDiv.style.height = `${viewport.height}px` + textLayerDiv.style.cssText = `width:${viewport.width}px;height:${viewport.height}px` - // Create a canvas context for measuring text - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') + // Reuse canvas for text measurement (avoids creating new element each render) + const ctx = getMeasureContext() if (!ctx) return + // Use DocumentFragment for batch DOM insertion (avoids layout thrash per span) + const fragment = document.createDocumentFragment() + textContent.items.forEach((item) => { if (!('str' in item) || !item.str) return const textItem = item as TextItemWithTransform @@ -248,37 +318,42 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom // ty is baseline from bottom of page in PDF coords // Convert to top-left CSS coords, accounting for ascender (text above baseline) - span.style.left = `${tx}px` - span.style.top = `${viewport.height - ty - fontSize * 0.3}px` - span.style.fontSize = `${fontSize}px` - span.style.fontFamily = fontFamily - span.style.position = 'absolute' - span.style.whiteSpace = 'pre' - span.style.pointerEvents = 'auto' - span.style.cursor = 'text' - span.style.transformOrigin = '0% 0%' - span.style.transform = `scaleX(${scaleX})` - - textLayerDiv.appendChild(span) + const top = viewport.height - ty - fontSize * 0.3 + + // Single cssText assignment instead of 10 individual style properties + // Reduces DOM operations from 10 to 1 per span + span.style.cssText = `left:${tx}px;top:${top}px;font-size:${fontSize}px;font-family:${fontFamily};position:absolute;white-space:pre;pointer-events:auto;cursor:text;transform-origin:0% 0%;transform:scaleX(${scaleX})` + + fragment.appendChild(span) }) + + // Single DOM insertion for all spans (avoids 500+ potential layout thrashes) + textLayerDiv.appendChild(fragment) } - // Render search highlights - const renderSearchHighlights = async (page: pdfjsLib.PDFPageProxy, viewport: pdfjsLib.PageViewport) => { + // Render search highlights - uses textContent passed from renderPage to avoid duplicate fetch + // Optimized: DocumentFragment for batch DOM insertion, cssText for single style assignment + const renderSearchHighlights = (textContent: TextContent, viewport: pdfjsLib.PageViewport) => { if (!highlightLayerRef.current) return const highlightLayer = highlightLayerRef.current highlightLayer.innerHTML = '' - highlightLayer.style.width = `${viewport.width}px` - highlightLayer.style.height = `${viewport.height}px` + highlightLayer.style.cssText = `width:${viewport.width}px;height:${viewport.height}px` - const textContent = await page.getTextContent() const pageMatches = searchMatches.filter(m => m.pageNum === currentPage) + if (pageMatches.length === 0) return + + // Pre-compute match indices for O(1) lookup instead of O(n) findIndex per match + const matchIndexMap = new Map() + searchMatches.forEach((m, idx) => { + matchIndexMap.set(`${m.pageNum}-${m.itemIndex}-${m.startIndex}`, idx) + }) + + // Use DocumentFragment for batch DOM insertion + const fragment = document.createDocumentFragment() pageMatches.forEach((match) => { - const globalIdx = searchMatches.findIndex( - m => m.pageNum === match.pageNum && m.itemIndex === match.itemIndex && m.startIndex === match.startIndex - ) + const globalIdx = matchIndexMap.get(`${match.pageNum}-${match.itemIndex}-${match.startIndex}`) ?? -1 const isActive = globalIdx === currentMatchIndex const item = textContent.items[match.itemIndex] @@ -294,33 +369,35 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom // Approximate position and width const charWidth = (textItem.width || fontSize * 0.6) / textItem.str.length - const left = tx + match.startIndex * charWidth - const width = (match.endIndex - match.startIndex) * charWidth + const left = (tx + match.startIndex * charWidth) * scale + const top = (viewport.height / scale - ty) * scale - fontSize * scale + const width = (match.endIndex - match.startIndex) * charWidth * scale + const height = fontSize * scale * 1.2 - highlight.style.position = 'absolute' - highlight.style.left = `${left * scale}px` - highlight.style.top = `${(viewport.height / scale - ty) * scale - fontSize * scale}px` - highlight.style.width = `${width * scale}px` - highlight.style.height = `${fontSize * scale * 1.2}px` + // Single cssText assignment instead of 5 individual style properties + highlight.style.cssText = `position:absolute;left:${left}px;top:${top}px;width:${width}px;height:${height}px` - highlightLayer.appendChild(highlight) + fragment.appendChild(highlight) }) + + // Single DOM insertion for all highlights + highlightLayer.appendChild(fragment) } useEffect(() => { renderPage() }, [renderPage]) - // Fit width calculation + // Fit width calculation - uses page cache to avoid redundant page loads const calculateFitWidth = useCallback(async () => { if (!pdf || !containerRef.current) return - const page = await pdf.getPage(1) + const page = await getPageCached(pdf, 1) const viewport = page.getViewport({ scale: 1 }) const containerWidth = containerRef.current.clientWidth - 48 // padding const optimalScale = containerWidth / viewport.width return Math.min(optimalScale, 3) // Cap at 300% - }, [pdf]) + }, [pdf, getPageCached]) // Fit width on initial load - wait for container to have size useEffect(() => { @@ -336,69 +413,118 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom doFitWidth() }, [pdf, fitWidth, calculateFitWidth]) - // Resize handler - always fit to container width + // Resize handler - always fit to container width (debounced to avoid layout thrashing) useEffect(() => { if (!pdf || !containerRef.current) return - const resizeObserver = new ResizeObserver(async () => { - const optimalScale = await calculateFitWidth() - if (optimalScale) { - // If fitWidth is enabled, always use optimal scale - // If not, cap the current scale to not exceed container - if (fitWidth) { - setScale(optimalScale) - } else if (scale > optimalScale) { - setScale(optimalScale) - } + const resizeObserver = new ResizeObserver(() => { + // Debounce resize handling to avoid recalculating on every animation frame + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current) } + + resizeTimeoutRef.current = setTimeout(async () => { + const optimalScale = await calculateFitWidth() + if (optimalScale) { + // If fitWidth is enabled, always use optimal scale + // If not, cap the current scale to not exceed container + if (fitWidth) { + setScale(optimalScale) + } else if (scale > optimalScale) { + setScale(optimalScale) + } + } + }, 100) // 100ms debounce }) resizeObserver.observe(containerRef.current) - return () => resizeObserver.disconnect() + return () => { + resizeObserver.disconnect() + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current) + } + } }, [pdf, fitWidth, scale, calculateFitWidth]) - // Search functionality + // Search functionality - streaming approach for memory efficiency + // Processes pages one at a time and cleans up after each to avoid loading all text content at once const performSearch = useCallback(async () => { if (!pdf || !searchQuery.trim()) { setSearchMatches([]) return } + // Cancel any previous search + if (searchAbortRef.current) { + searchAbortRef.current.abort() + } + + const abortController = new AbortController() + searchAbortRef.current = abortController + const matches: SearchMatch[] = [] const query = searchQuery.toLowerCase() + // Process pages one at a time (streaming) to avoid memory spike + // For 100+ page documents, loading all pages at once would use ~100MB+ for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { - const page = await pdf.getPage(pageNum) - const textContent = await page.getTextContent() - - textContent.items.forEach((item, itemIndex) => { - if (!('str' in item)) return - const text = item.str.toLowerCase() - let startIndex = 0 + // Check if search was cancelled (user started new search or unmounted) + if (abortController.signal.aborted) { + return + } - while (true) { - const idx = text.indexOf(query, startIndex) - if (idx === -1) break + try { + const page = await pdf.getPage(pageNum) + const textContent = await page.getTextContent() - matches.push({ - pageNum, - itemIndex, - text: item.str.substring(idx, idx + query.length), - startIndex: idx, - endIndex: idx + query.length, - }) + // Check abort again after async operations + if (abortController.signal.aborted) { + page.cleanup() + return + } - startIndex = idx + 1 + textContent.items.forEach((item, itemIndex) => { + if (!('str' in item)) return + const text = item.str.toLowerCase() + let startIndex = 0 + + while (true) { + const idx = text.indexOf(query, startIndex) + if (idx === -1) break + + matches.push({ + pageNum, + itemIndex, + text: item.str.substring(idx, idx + query.length), + startIndex: idx, + endIndex: idx + query.length, + }) + + startIndex = idx + 1 + } + }) + + // Clean up page if it's not in our render cache window + // This prevents search from consuming memory for every page + const cache = pageCacheRef.current + if (!cache.has(pageNum)) { + page.cleanup() } - }) + } catch (err) { + // If page load fails, continue to next page + console.warn(`Search: Failed to load page ${pageNum}:`, err) + } } - setSearchMatches(matches) - setCurrentMatchIndex(0) + // Only update state if search wasn't cancelled + if (!abortController.signal.aborted) { + setSearchMatches(matches) + setCurrentMatchIndex(0) - // Navigate to first match - if (matches.length > 0) { - setCurrentPage(matches[0].pageNum) + // Navigate to first match + if (matches.length > 0) { + setCurrentPage(matches[0].pageNum) + } } }, [pdf, searchQuery]) @@ -677,6 +803,6 @@ const PDFViewer: React.FC = ({ pdfUrl, pdfData, onCompile, isCom
) -} +}) export default PDFViewer diff --git a/app/src/components/ProjectSidebar/ProjectSidebar.css b/app/src/components/ProjectSidebar/ProjectSidebar.css index 0e75907..b18d307 100644 --- a/app/src/components/ProjectSidebar/ProjectSidebar.css +++ b/app/src/components/ProjectSidebar/ProjectSidebar.css @@ -376,13 +376,13 @@ border: 1px solid var(--color-border); border-radius: 8px; padding: 4px; - min-width: 140px; + min-width: 160px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); z-index: 1000; backdrop-filter: blur(20px); } -.file-context-menu button { +.context-menu-item { display: flex; align-items: center; gap: 8px; @@ -399,21 +399,36 @@ text-align: left; } -.file-context-menu button:hover { +.context-menu-item:hover { background: rgba(232, 227, 211, 0.06); } -.file-context-menu button svg { +.context-menu-item svg { opacity: 0.6; color: var(--color-text-tertiary); + flex-shrink: 0; } -.file-context-menu button.delete-btn { - color: var(--color-accent); +.context-menu-item span:first-of-type { + flex: 1; } -.file-context-menu button.delete-btn:hover { - background: rgba(184, 149, 110, 0.12); +.context-menu-hint { + font-size: 10px; + color: var(--color-text-tertiary); + margin-left: auto; +} + +.context-menu-item.danger { + color: #a06058; +} + +.context-menu-item.danger:hover { + background: rgba(160, 96, 88, 0.12); +} + +.context-menu-item.danger svg { + color: #a06058; } .context-menu-divider { @@ -497,103 +512,378 @@ } .delete-confirm-delete { - color: #e07070; + color: #a06058; } .delete-confirm-delete:hover { - background: rgba(220, 80, 80, 0.2); + background: rgba(160, 96, 88, 0.2); +} + +/* ========================================================================== + INTEGRATIONS SECTION - Unified visual treatment + ========================================================================== */ + +/* Integrations wrapper - subtle visual separation from file tree */ +.sidebar-integrations { + flex-shrink: 0; + margin-top: auto; /* Push to bottom */ + padding: 4px 6px 6px 6px; + background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.06)); +} + +/* Section header - matches "Project" title styling */ +.integrations-header { + display: flex; + align-items: center; + gap: 6px; + position: relative; /* For popover positioning */ + padding: 8px 8px 6px 8px; + font-size: 11px; + font-weight: 600; + color: var(--color-text-tertiary); + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; +} + +/* Integration items wrapper */ +.integration-items { + display: flex; + flex-direction: column; + gap: 2px; } -/* Git Sync Section */ +/* Git Section - legacy class for backwards compat, will be replaced */ .sidebar-git-section { - position: relative; - padding: 8px; - border-top: 1px solid var(--color-border); flex-shrink: 0; + padding: 2px 0; } -.git-setup-btn { +.git-connected-inline, +.git-setup-inline { + display: flex; + flex-direction: column; +} + +.git-connected-inline.expanded .git-settings-content, +.git-setup-inline.expanded .git-setup-content { + max-height: 400px; + opacity: 1; + transition: max-height 0.2s ease-out, opacity 0.2s ease-out 0.05s; +} + +.git-settings-content, +.git-setup-content { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 0.2s ease-out, opacity 0.15s ease-out; + padding: 0 8px; +} + +/* Integration Row - unified styling that matches file tree rows exactly */ +.integration-row-btn { display: flex; align-items: center; - gap: 8px; + gap: 6px; /* Match file tree gap */ width: 100%; - padding: 8px 10px; + padding: 6px 8px; /* Match file tree row exactly */ + margin: 1px 0; background: transparent; border: none; border-radius: 6px; - color: var(--color-text-tertiary); - font-size: 12px; + color: var(--color-text-secondary); + font-size: 13px; /* Match file tree */ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; cursor: pointer; - transition: all 0.15s ease; + transition: all 0.1s ease; + text-align: left; } -.git-setup-btn:hover { +.integration-row-btn:hover { background: rgba(232, 227, 211, 0.04); + color: var(--color-text-primary); +} + +.integration-row-btn svg { + opacity: 0.45; /* Match file tree icon opacity */ + flex-shrink: 0; + color: var(--color-text-tertiary); +} + +.integration-row-btn:hover svg { + opacity: 0.6; +} + +/* Connected state - subtle accent treatment */ +.integration-row-btn.connected { color: var(--color-text-secondary); } -.git-setup-btn svg { - opacity: 0.5; +.integration-row-btn.connected svg { + opacity: 0.55; } -.git-setup-btn:hover svg:not(.git-setup-chevron) { +.integration-row-btn.connected:hover svg { opacity: 0.7; } -.git-setup-chevron { +/* Integration name stays fixed width, detail fills rest */ +.integration-name { + flex-shrink: 0; +} + +.integration-detail { margin-left: auto; - opacity: 0.6; - transition: transform 0.2s ease, opacity 0.15s ease; + font-size: 11px; + color: var(--color-text-tertiary); + font-family: 'SF Mono', Monaco, monospace; + opacity: 0.8; } -.git-setup-chevron.open { +/* Status indicator - consistent green dot */ +.integration-status-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #6b7a5e; + margin-left: 6px; + flex-shrink: 0; +} + +/* Chevron - rotates on expand */ +.integration-chevron { + opacity: 0.35; + flex-shrink: 0; + margin-left: auto; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.15s ease; +} + +.integration-row-btn:hover .integration-chevron { + opacity: 0.5; +} + +.integration-chevron.open { transform: rotate(90deg); + opacity: 0.5; +} + +/* Legacy .git-setup-btn - keep for backwards compat but using unified styles */ +.git-setup-btn { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 8px; + margin: 1px 0; + background: transparent; + border: none; + border-radius: 6px; + color: var(--color-text-secondary); + font-size: 13px; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + cursor: pointer; + transition: all 0.1s ease; + text-align: left; +} + +.git-setup-btn:hover { + background: rgba(232, 227, 211, 0.04); + color: var(--color-text-primary); +} + +.git-setup-btn svg { + opacity: 0.45; + flex-shrink: 0; + color: var(--color-text-tertiary); +} + +.git-setup-btn:hover svg { opacity: 0.6; } +.git-setup-btn span { + flex: 1; +} + +.git-setup-chevron { + opacity: 0.35; + flex-shrink: 0; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.15s ease; +} + .git-setup-btn:hover .git-setup-chevron { - opacity: 0.6; + opacity: 0.5; } -/* Git Setup Inline Expand */ -.git-setup-inline { +.git-setup-chevron.open { + transform: rotate(90deg); + opacity: 0.5; +} +.integrations-info-btn { display: flex; - flex-direction: column; + align-items: center; + justify-content: center; + background: transparent; + border: none; + padding: 2px; + cursor: pointer; + color: var(--color-text-tertiary); + transition: color 0.15s ease; + border-radius: 4px; } -.git-setup-content { - max-height: 0; - opacity: 0; - overflow: hidden; - transition: max-height 0.2s ease-out, opacity 0.15s ease-out; +.integrations-info-btn:hover { + color: var(--color-text-secondary); } -.git-setup-inline.expanded .git-setup-content { - max-height: 200px; - opacity: 1; - transition: max-height 0.2s ease-out, opacity 0.2s ease-out 0.05s; +.integrations-info-popover { + position: absolute; + top: 100%; + left: 8px; + width: 220px; + margin-top: 6px; + background: var(--color-base-darker); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 10px 12px; + font-size: 11px; + font-weight: 400; + color: var(--color-text-secondary); + white-space: normal; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.integrations-info-popover strong { + display: block; + color: var(--color-text-primary); + margin-bottom: 6px; +} + +.integrations-info-popover p { + margin: 0 0 6px 0; + line-height: 1.4; } -/* Git Connected Inline */ -.git-connected-inline { +.integrations-info-popover p:last-child { + margin-bottom: 0; +} + +/* Integration item - compact row */ +.integration-item { display: flex; flex-direction: column; } -.git-settings-content { +.integration-item.expanded { + border-bottom: 1px solid var(--color-border-subtle); + margin-bottom: 4px; + padding-bottom: 4px; +} + +.integration-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--color-text-tertiary); + font-size: 12px; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + cursor: pointer; + transition: all 0.15s ease; + text-align: left; +} + +.integration-row:hover { + background: rgba(232, 227, 211, 0.04); + color: var(--color-text-secondary); +} + +.integration-row svg { + opacity: 0.5; + flex-shrink: 0; +} + +.integration-row:hover svg { + opacity: 0.7; +} + +.integration-name { + flex-shrink: 0; +} + +.integration-status { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + font-size: 11px; +} + +.integration-connected { + width: 6px; + height: 6px; + background: #6b7a5e; + border-radius: 50%; +} + +.integration-spinner { + color: var(--color-text-tertiary); + animation: spin 1s linear infinite; +} + +.integration-error { + color: #a06058; +} + +.integration-detail { + color: var(--color-text-secondary); + font-family: 'SF Mono', Monaco, monospace; + font-size: 10px; +} + +.integration-detail-muted { + color: var(--color-text-tertiary); + font-size: 10px; +} + +.integration-chevron { + opacity: 0.4; + flex-shrink: 0; + transition: transform 0.2s ease, opacity 0.15s ease; +} + +.integration-item.expanded .integration-chevron { + transform: rotate(90deg); + opacity: 0.6; +} + +.integration-row:hover .integration-chevron { + opacity: 0.6; +} + +/* Integration content - expandable */ +.integration-content { max-height: 0; opacity: 0; overflow: hidden; transition: max-height 0.2s ease-out, opacity 0.15s ease-out; } -.git-connected-inline.expanded .git-settings-content { - max-height: 200px; +.integration-item.expanded .integration-content { + max-height: 300px; opacity: 1; transition: max-height 0.2s ease-out, opacity 0.2s ease-out 0.05s; } +.integration-settings, +.integration-connect { + padding: 8px 8px 4px 8px; +} + .git-settings-inner { display: flex; flex-direction: column; @@ -770,7 +1060,7 @@ } .git-disconnect-btn:hover { - color: #dc5050; + color: #a06058; } /* Conflict UI */ @@ -948,17 +1238,19 @@ position: relative; } +/* Connected integration row - matches file tree rhythm */ .git-status-btn { display: flex; align-items: center; - justify-content: space-between; + gap: 6px; width: 100%; - padding: 8px 10px; + padding: 6px 8px; /* Match file tree exactly */ + margin: 1px 0; background: transparent; border: none; border-radius: 6px; cursor: pointer; - transition: all 0.15s ease; + transition: all 0.1s ease; } .git-status-btn:hover { @@ -966,23 +1258,32 @@ } .git-status-btn.syncing { - opacity: 0.7; + opacity: 0.85; } .git-status-info { display: flex; align-items: center; - gap: 8px; + gap: 6px; color: var(--color-text-secondary); + font-size: 13px; } .git-status-info svg { + opacity: 0.45; + color: var(--color-text-tertiary); +} + +.git-status-btn:hover .git-status-info svg { opacity: 0.6; } .git-status-btn .git-setup-chevron { - color: var(--color-text-secondary); - stroke: var(--color-text-secondary); + opacity: 0.35; +} + +.git-status-btn:hover .git-setup-chevron { + opacity: 0.5; } .git-status-btn > svg, @@ -990,12 +1291,17 @@ .git-status-btn > .git-sync-spinner, .git-status-btn > .git-behind-badge, .git-status-btn > .git-sync-indicator { - margin-left: 4px; + margin-left: auto; } .git-status-text { - font-size: 12px; + font-size: 11px; font-family: 'SF Mono', Monaco, monospace; + color: var(--color-text-tertiary); + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .git-sync-indicator { @@ -1006,7 +1312,7 @@ } .git-sync-indicator.connected { - background: #4ade80; + background: #6b7a5e; } .git-sync-spinner { @@ -1020,7 +1326,7 @@ } .git-error-icon { - color: #f87171; + color: #a06058; } .git-behind-badge { @@ -1047,10 +1353,10 @@ .git-error-banner { padding: 8px 12px; margin: 4px; - background: rgba(248, 113, 113, 0.1); + background: rgba(160, 96, 88, 0.12); border-radius: 6px; font-size: 11px; - color: #f87171; + color: #a06058; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; } @@ -1091,8 +1397,8 @@ } .git-action-btn.danger:hover { - color: #f87171; - background: rgba(248, 113, 113, 0.1); + color: #a06058; + background: rgba(160, 96, 88, 0.12); } .git-last-sync { @@ -1116,7 +1422,7 @@ justify-content: center; gap: 8px; padding: 24px; - color: #4ade80; + color: #6b7a5e; } .git-check-icon { @@ -1141,6 +1447,308 @@ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; } +/* Zotero Section - unified with Git section */ +.sidebar-zotero-section { + flex-shrink: 0; + padding: 2px 0; +} + +.zotero-connected-inline, +.zotero-setup-inline { + display: flex; + flex-direction: column; +} + +.zotero-connected-inline.expanded .zotero-settings-content, +.zotero-setup-inline.expanded .zotero-setup-content { + max-height: 300px; + opacity: 1; + transition: max-height 0.2s ease-out, opacity 0.2s ease-out 0.05s; +} + +.zotero-settings-content, +.zotero-setup-content { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 0.2s ease-out, opacity 0.15s ease-out; + padding: 0 8px; +} + +/* Zotero buttons - match file tree rhythm exactly */ +.zotero-status-btn, +.zotero-setup-btn { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 8px; /* Match file tree row exactly */ + margin: 1px 0; + background: transparent; + border: none; + border-radius: 6px; + color: var(--color-text-secondary); + font-size: 13px; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + cursor: pointer; + transition: all 0.1s ease; + text-align: left; +} + +.zotero-status-btn:hover, +.zotero-setup-btn:hover { + background: rgba(232, 227, 211, 0.04); + color: var(--color-text-primary); +} + +.zotero-status-btn svg, +.zotero-setup-btn svg { + opacity: 0.45; + flex-shrink: 0; + color: var(--color-text-tertiary); +} + +.zotero-status-btn:hover svg, +.zotero-setup-btn:hover svg { + opacity: 0.6; +} + +.zotero-status-info { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.zotero-status-text { + font-size: 11px; + font-family: 'SF Mono', Monaco, monospace; + color: var(--color-text-tertiary); +} + +.zotero-setup-btn span { + flex: 1; +} + +.zotero-setup-chevron { + opacity: 0.35; + flex-shrink: 0; + margin-left: auto; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.15s ease; +} + +.zotero-status-btn:hover .zotero-setup-chevron, +.zotero-setup-btn:hover .zotero-setup-chevron { + opacity: 0.5; +} + +.zotero-connected-inline.expanded .zotero-setup-chevron, +.zotero-setup-inline.expanded .zotero-setup-chevron { + transform: rotate(90deg); + opacity: 0.5; +} + +.zotero-settings-inner { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0 4px 0; +} + +.zotero-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0; +} + +.zotero-setting-label { + font-size: 12px; + color: var(--color-text-secondary); +} + +.zotero-setting-value { + font-size: 11px; + color: var(--color-text-tertiary); + font-family: 'SF Mono', Monaco, monospace; +} + +.zotero-key-link { + font-size: 11px; + color: var(--color-text-tertiary); + text-decoration: none; + padding: 0 4px; + transition: color 0.15s ease; +} + +.zotero-key-link:hover { + color: var(--color-accent); +} + +.zotero-setup-done { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + color: #6b7a5e; +} + +.zotero-check-icon { + animation: checkPop 0.3s ease; +} + +.zotero-setup-done span { + font-size: 13px; + font-weight: 500; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; +} + +/* Zotero-specific styles (shared with integration) */ +.zotero-last-sync-info { + font-size: 11px; + color: var(--color-text-tertiary); + padding: 4px 0; +} + +.zotero-actions-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 4px; +} + +.zotero-sync-btn { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + color: var(--color-text-secondary); + font-size: 11px; + cursor: pointer; + padding: 6px 0; + transition: color 0.15s ease; +} + +.zotero-sync-btn:hover:not(:disabled) { + color: var(--color-text-primary); +} + +.zotero-sync-btn:disabled { + opacity: 0.6; + cursor: default; +} + +.zotero-sync-spinner { + color: var(--color-text-tertiary); + animation: spin 1s linear infinite; +} + +.zotero-disconnect-btn { + background: transparent; + border: none; + color: var(--color-text-tertiary); + font-size: 11px; + cursor: pointer; + padding: 6px 0; + text-align: left; + transition: color 0.15s ease; +} + +.zotero-disconnect-btn:hover { + color: #a06058; +} + +.zotero-hint { + font-size: 11px; + color: var(--color-text-tertiary); + padding: 8px 0 4px 0; +} + +.zotero-hint code { + background: rgba(232, 227, 211, 0.08); + padding: 2px 4px; + border-radius: 3px; + font-family: 'SF Mono', Monaco, monospace; + font-size: 10px; +} + +/* Zotero Config Form */ +.zotero-config-form { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0 4px 0; +} + +.zotero-input { + width: 100%; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text-primary); + font-size: 13px; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + outline: none; + box-sizing: border-box; + transition: border-color 0.15s ease; +} + +.zotero-input::placeholder { + color: var(--color-text-tertiary); +} + +.zotero-input:focus { + border-color: var(--color-accent); +} + +.zotero-token-link { + font-size: 11px; + color: var(--color-text-tertiary); + text-decoration: none; + padding: 0 4px; + transition: color 0.15s ease; +} + +.zotero-token-link:hover { + color: var(--color-accent); +} + +.zotero-error { + padding: 8px 12px; + background: rgba(160, 96, 88, 0.12); + border-radius: 6px; + font-size: 11px; + color: #a06058; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; +} + +.zotero-connect-btn { + padding: 10px 14px; + background: var(--color-accent); + border: none; + border-radius: 8px; + color: var(--color-base); + font-size: 13px; + font-weight: 500; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + cursor: pointer; + transition: all 0.15s ease; + margin-top: 4px; +} + +.zotero-connect-btn:hover:not(:disabled) { + filter: brightness(1.1); +} + +.zotero-connect-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* Mobile: sidebar as slide-in overlay */ @media (max-width: 768px) { .project-sidebar { diff --git a/app/src/components/ProjectSidebar/ProjectSidebar.tsx b/app/src/components/ProjectSidebar/ProjectSidebar.tsx index ec17858..4808ad0 100644 --- a/app/src/components/ProjectSidebar/ProjectSidebar.tsx +++ b/app/src/components/ProjectSidebar/ProjectSidebar.tsx @@ -3,8 +3,112 @@ import { ChevronRight, File, FolderClosed, FolderOpen, Plus, FolderPlus, FilePlu import type { FileItem } from '../../services/ProjectStore' import ProjectStore from '../../services/ProjectStore' import GitService, { type GitStatus, type GitConfig } from '../../services/GitService' +import CitationService, { type ZoteroStatus } from '../../services/CitationService' +import ZoteroIcon from '../icons/ZoteroIcon' import './ProjectSidebar.css' +// ======================================== +// Tree helpers with structural sharing (module-level for zero re-creation cost) +// Only clones nodes along the path to target, reuses unchanged subtrees +// ======================================== + +type IndexPath = number[] + +// Optimized: Uses mutable path array, only creates final array on success +const findIndexPath = (items: FileItem[], id: string): IndexPath | null => { + const path: number[] = [] + const search = (nodes: FileItem[]): boolean => { + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].id === id) { + path.push(i) + return true + } + if (nodes[i].children) { + path.push(i) + if (search(nodes[i].children!)) return true + path.pop() + } + } + return false + } + return search(items) ? path : null +} + +const getAtPath = (items: FileItem[], path: IndexPath): FileItem | null => { + if (path.length === 0) return null + let current: FileItem | undefined = items[path[0]] + for (let i = 1; i < path.length && current; i++) { + current = current.children?.[path[i]] + } + return current ?? null +} + +const updateAtPath = ( + items: FileItem[], + path: IndexPath, + updater: (item: FileItem) => FileItem +): FileItem[] => { + if (path.length === 0) return items + const idx = path[0] + const newItems = items.slice() + if (path.length === 1) { + newItems[idx] = updater(items[idx]) + } else { + newItems[idx] = { + ...items[idx], + children: updateAtPath(items[idx].children || [], path.slice(1), updater) + } + } + return newItems +} + +const removeAtPath = (items: FileItem[], path: IndexPath): FileItem[] => { + if (path.length === 0) return items + const idx = path[0] + if (path.length === 1) { + return items.slice(0, idx).concat(items.slice(idx + 1)) + } + const newItems = items.slice() + newItems[idx] = { + ...items[idx], + children: removeAtPath(items[idx].children || [], path.slice(1)) + } + return newItems +} + +const insertAtPath = ( + items: FileItem[], + path: IndexPath, + newItem: FileItem +): FileItem[] => { + if (path.length === 0) return items.concat(newItem) + const idx = path[0] + const newItems = items.slice() + if (path.length === 1) { + newItems[idx] = { + ...items[idx], + children: (items[idx].children || []).concat(newItem) + } + } else { + newItems[idx] = { + ...items[idx], + children: insertAtPath(items[idx].children || [], path.slice(1), newItem) + } + } + return newItems +} + +const computeParentPath = (items: FileItem[], idxPath: IndexPath): string => { + let parentPath = '' + let current = items + for (let i = 0; i < idxPath.length - 1; i++) { + const name = current[idxPath[i]].name + parentPath = parentPath ? `${parentPath}/${name}` : `/${name}` + current = current[idxPath[i]].children || [] + } + return parentPath +} + interface ProjectSidebarProps { isExpanded: boolean onToggle: () => void @@ -32,7 +136,7 @@ interface FileTreeItemProps { onEditingComplete?: () => void } -const FileTreeItem: React.FC = ({ +const FileTreeItem: React.FC = React.memo(({ item, depth, onDelete, @@ -234,9 +338,18 @@ const FileTreeItem: React.FC = ({ )} ) -} +}, (prevProps, nextProps) => { + // Custom comparison for tree item - re-render if selection, drag state, or item changes + return ( + prevProps.item === nextProps.item && + prevProps.depth === nextProps.depth && + prevProps.selectedId === nextProps.selectedId && + prevProps.draggedId === nextProps.draggedId && + prevProps.editingNewId === nextProps.editingNewId + ) +}) -const ProjectSidebar: React.FC = ({ +const ProjectSidebar: React.FC = React.memo(({ isExpanded, onToggle, files, @@ -257,6 +370,14 @@ const ProjectSidebar: React.FC = ({ const [editingNewId, setEditingNewId] = useState(null) const [showGitSetup, setShowGitSetup] = useState(false) const [gitStatus, setGitStatus] = useState(GitService.getStatus()) + // Zotero state + const [showZoteroSetup, setShowZoteroSetup] = useState(false) + const [zoteroStatus, setZoteroStatus] = useState(CitationService.getZoteroStatus()) + const [zoteroUserId, setZoteroUserId] = useState('') + const [zoteroApiKey, setZoteroApiKey] = useState('') + const [isZoteroConnecting, setIsZoteroConnecting] = useState(false) + const [zoteroSetupStep, setZoteroSetupStep] = useState<'connect' | 'done'>('connect') + const zoteroUserIdInputRef = useRef(null) const [gitConfig, setGitConfig] = useState>(() => { const saved = GitService.getConfig() return saved || { @@ -286,6 +407,14 @@ const ProjectSidebar: React.FC = ({ return unsubscribe }, []) + // Subscribe to Zotero status updates + useEffect(() => { + const unsubscribe = CitationService.onStatus((status) => { + setZoteroStatus(status.zotero) + }) + return unsubscribe + }, []) + // Handle trigger from onboarding useEffect(() => { if (triggerGitConnect && !gitStatus.isConnected) { @@ -301,11 +430,18 @@ const ProjectSidebar: React.FC = ({ } }, [showGitSetup, gitStatus.isConnected, setupStep]) + // Track last files received from store to avoid redundant updates + const lastStoreFilesRef = useRef(null) + // Subscribe to ProjectStore changes (from Git sync) useEffect(() => { const unsubscribe = ProjectStore.subscribe((newFiles) => { - // Only update if files are different (from Git) - if (JSON.stringify(newFiles) !== JSON.stringify(files)) { + // Skip if same reference as last time (common case: our own setFiles call) + if (newFiles === lastStoreFilesRef.current) return + lastStoreFilesRef.current = newFiles + + // Only update if store has different reference than current props + if (newFiles !== files) { onFilesChange(newFiles) } }) @@ -387,6 +523,42 @@ const ProjectSidebar: React.FC = ({ setSetupStep('connect') } + // Zotero handlers + const handleZoteroConnect = async () => { + if (!zoteroUserId.trim() || !zoteroApiKey.trim()) return + + setIsZoteroConnecting(true) + try { + await CitationService.connectZotero(zoteroUserId.trim(), zoteroApiKey.trim()) + setZoteroSetupStep('done') + setTimeout(() => { + setShowZoteroSetup(false) + setZoteroSetupStep('connect') + setZoteroUserId('') + setZoteroApiKey('') + }, 1200) + } catch (error) { + console.error('Zotero connect failed:', error) + } finally { + setIsZoteroConnecting(false) + } + } + + const handleZoteroDisconnect = async () => { + await CitationService.disconnectZotero() + setZoteroSetupStep('connect') + setZoteroUserId('') + setZoteroApiKey('') + } + + const handleZoteroSync = async () => { + try { + await CitationService.syncZotero() + } catch (e) { + console.error('Zotero sync failed:', e) + } + } + const handleSyncNow = async () => { await GitService.sync() } @@ -428,107 +600,67 @@ const ProjectSidebar: React.FC = ({ setEditingNewId(newId) } + // ======================================== + // Tree operations using structural sharing + // ======================================== + const deleteItem = (id: string) => { - const removeFromTree = (items: FileItem[]): FileItem[] => { - return items - .filter(item => item.id !== id) - .map(item => ({ - ...item, - children: item.children ? removeFromTree(item.children) : undefined - })) - } - onFilesChange(removeFromTree(files)) + const path = findIndexPath(files, id) + if (!path) return + onFilesChange(removeAtPath(files, path)) } const renameItem = (id: string, newName: string) => { - const updateInTree = (items: FileItem[], parentPath: string = ''): FileItem[] => { - return items.map(item => { - if (item.id === id) { - const newPath = parentPath ? `${parentPath}/${newName}` : `/${newName}` - return { ...item, name: newName, path: newPath } - } - if (item.children) { - const currentPath = parentPath ? `${parentPath}/${item.name}` : `/${item.name}` - return { ...item, children: updateInTree(item.children, currentPath) } - } - return item - }) - } - onFilesChange(updateInTree(files)) + const path = findIndexPath(files, id) + if (!path) return + + const parentPath = computeParentPath(files, path) + const newFilePath = parentPath ? `${parentPath}/${newName}` : `/${newName}` + + onFilesChange(updateAtPath(files, path, item => ({ ...item, name: newName, path: newFilePath }))) } const addChildItem = (parentId: string, type: 'file' | 'folder') => { - const name = type === 'folder' ? 'New Folder' : 'untitled.tex' + const path = findIndexPath(files, parentId) + if (!path) return - const addToParent = (items: FileItem[], parentPath: string = ''): FileItem[] => { - return items.map(item => { - const currentPath = parentPath ? `${parentPath}/${item.name}` : `/${item.name}` - if (item.id === parentId && item.type === 'folder') { - const newItem: FileItem = { - id: generateId(), - name, - path: `${currentPath}/${name}`, - type, - ...(type === 'folder' ? { children: [] } : {}) - } - return { - ...item, - children: [...(item.children || []), newItem] - } - } - if (item.children) { - return { ...item, children: addToParent(item.children, currentPath) } - } - return item - }) + const parent = getAtPath(files, path) + if (!parent || parent.type !== 'folder') return + + const name = type === 'folder' ? 'New Folder' : 'untitled.tex' + const newItem: FileItem = { + id: generateId(), + name, + path: `${parent.path}/${name}`, + type, + ...(type === 'folder' ? { children: [] } : {}) } - onFilesChange(addToParent(files)) + + onFilesChange(insertAtPath(files, path, newItem)) } const moveItem = (itemId: string, targetFolderId: string | null) => { - // Find the item to move - let itemToMove: FileItem | null = null - - const findAndRemove = (items: FileItem[]): FileItem[] => { - return items - .filter(item => { - if (item.id === itemId) { - itemToMove = item - return false - } - return true - }) - .map(item => ({ - ...item, - children: item.children ? findAndRemove(item.children) : undefined - })) - } - - const addToTarget = (items: FileItem[]): FileItem[] => { - return items.map(item => { - if (item.id === targetFolderId && item.type === 'folder' && itemToMove) { - return { - ...item, - children: [...(item.children || []), itemToMove] - } - } - if (item.children) { - return { ...item, children: addToTarget(item.children) } - } - return item - }) - } + const sourcePath = findIndexPath(files, itemId) + if (!sourcePath) return - let newFiles = findAndRemove(files) - if (itemToMove) { - if (targetFolderId === null) { - // Move to root - newFiles = [...newFiles, itemToMove] - } else { - newFiles = addToTarget(newFiles) - } - onFilesChange(newFiles) + const itemToMove = getAtPath(files, sourcePath) + if (!itemToMove) return + + let newFiles = removeAtPath(files, sourcePath) + + if (targetFolderId === null) { + newFiles = [...newFiles, itemToMove] + } else { + const targetPath = findIndexPath(newFiles, targetFolderId) + if (!targetPath) return + + const targetFolder = getAtPath(newFiles, targetPath) + if (!targetFolder || targetFolder.type !== 'folder') return + + newFiles = insertAtPath(newFiles, targetPath, itemToMove) } + + onFilesChange(newFiles) } const handleFileSelect = (file: FileItem) => { @@ -699,9 +831,13 @@ const ProjectSidebar: React.FC = ({ )} - {/* Git Sync Section */} -
- {gitStatus.isConnected ? ( + {/* Integrations Section - unified wrapper */} +
+
Integrations
+
+ {/* Git Sync */} +
+ {gitStatus.isConnected ? (
)}
+ + {/* Zotero Section */} +
+ {zoteroStatus.connected ? ( +
+ + +
+
+
+ User ID + {zoteroStatus.userId} +
+
+ + +
+
+
+
+ ) : ( +
+ + +
+ {zoteroSetupStep === 'connect' && ( +
+ setZoteroUserId(e.target.value)} + /> + setZoteroApiKey(e.target.value)} + /> + + Get API key from Zotero + + +
+ )} + + {zoteroSetupStep === 'done' && ( +
+ + Connected +
+ )} +
+
+ )} +
+
{/* integration-items */} +
{/* sidebar-integrations */}
) -} +}, (prevProps, nextProps) => { + // Re-render only when significant props change + return ( + prevProps.isExpanded === nextProps.isExpanded && + prevProps.files === nextProps.files && + prevProps.selectedFileId === nextProps.selectedFileId && + prevProps.triggerGitConnect === nextProps.triggerGitConnect + ) +}) export default ProjectSidebar diff --git a/app/src/components/icons/ZoteroIcon.tsx b/app/src/components/icons/ZoteroIcon.tsx new file mode 100644 index 0000000..5f67628 --- /dev/null +++ b/app/src/components/icons/ZoteroIcon.tsx @@ -0,0 +1,28 @@ +import React from 'react' + +interface ZoteroIconProps { + size?: number + connected?: boolean + className?: string +} + +const ZoteroIcon: React.FC = ({ size = 24, className }) => { + // Use currentColor to match other integration icons (like GitHub from lucide) + // CSS handles the opacity/color styling via .integration-row svg + return ( + + + + + ) +} + +export default ZoteroIcon diff --git a/app/src/hooks/useCompilation.ts b/app/src/hooks/useCompilation.ts new file mode 100644 index 0000000..d41025b --- /dev/null +++ b/app/src/hooks/useCompilation.ts @@ -0,0 +1,185 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import CompilerService from '../services/CompilerService' +import EventBus from '../services/EventBus' +import type { CompileStatus } from '../services/CompilerService' + +export interface CompilerSettings { + autoCompile: boolean + compiler: 'auto' | 'pdflatex' | 'xelatex' + ctanFetch: boolean + cachePreamble: boolean + autoUnload: boolean +} + +interface UseCompilationReturn { + pdfData: Uint8Array | null + compileStatus: CompileStatus + compileTimeMs: number | null + compilerSettings: CompilerSettings + setCompilerSettings: (settings: CompilerSettings) => void + compile: () => Promise + compileRef: React.MutableRefObject<() => Promise> + autoCompileRef: React.MutableRefObject + clearPendingAutoCompile: () => void +} + +export function useCompilation(latexCode: string, isLoaded: boolean): UseCompilationReturn { + const [pdfData, setPdfData] = useState(null) + const [compileStatus, setCompileStatus] = useState('idle') + const [compileTimeMs, setCompileTimeMs] = useState(null) + const compileTimeoutRef = useRef(null) + const formatGeneratedRef = useRef(false) + const lastCompiledCodeRef = useRef(null) + + const [compilerSettings, setCompilerSettingsState] = useState({ + autoCompile: true, + compiler: 'auto', + ctanFetch: true, + cachePreamble: true, + autoUnload: true, + }) + + // Keep refs to avoid stale closures + const latexCodeRef = useRef(latexCode) + const autoCompileRef = useRef(compilerSettings.autoCompile) + const compilerSettingsRef = useRef(compilerSettings) + + useEffect(() => { + latexCodeRef.current = latexCode + }, [latexCode]) + + useEffect(() => { + autoCompileRef.current = compilerSettings.autoCompile + compilerSettingsRef.current = compilerSettings + }, [compilerSettings]) + + // Eagerly initialize compiler + useEffect(() => { + CompilerService.initialize().catch(() => { + // Initialization errors handled when compile is called + }) + }, []) + + // Compile function - uses refs to avoid recreating on settings change + const compile = useCallback(async () => { + const currentStatus = CompilerService.getStatus() + if (!latexCodeRef.current || currentStatus === 'compiling' || currentStatus === 'initializing') { + return + } + + const settings = compilerSettingsRef.current + const codeToCompile = latexCodeRef.current + + try { + const result = await CompilerService.compile(codeToCompile, { + engine: settings.compiler, + }) + if (result.success && result.pdf) { + const pdfCopy = new Uint8Array(result.pdf.length) + pdfCopy.set(result.pdf) + setPdfData(pdfCopy) + setCompileTimeMs(result.timeMs ?? null) + lastCompiledCodeRef.current = codeToCompile + + // Only generate format once per session (not on every compile) + if (settings.cachePreamble && !formatGeneratedRef.current) { + formatGeneratedRef.current = true + CompilerService.generateFormat(codeToCompile, { + engine: settings.compiler, + }).catch(() => { + // Reset so we retry on next compile if it fails + formatGeneratedRef.current = false + }) + } + } + } catch { + // Compilation errors shown in UI + } + }, []) + + const compileRef = useRef(compile) + useEffect(() => { + compileRef.current = compile + }, [compile]) + + // Settings change handler with side effects + const setCompilerSettings = useCallback((newSettings: CompilerSettings) => { + if (compilerSettings.cachePreamble && !newSettings.cachePreamble) { + CompilerService.clearCache() + } + if (compilerSettings.autoUnload !== newSettings.autoUnload) { + CompilerService.setAutoUnload(newSettings.autoUnload) + } + setCompilerSettingsState(newSettings) + }, [compilerSettings.cachePreamble, compilerSettings.autoUnload]) + + // Subscribe to compiler status + const prevStatusRef = useRef('idle') + useEffect(() => { + return CompilerService.onStatus((status) => { + const prevStatus = prevStatusRef.current + prevStatusRef.current = status + setCompileStatus(status) + + // Trigger compile when initialization completes + if (prevStatus === 'initializing' && status === 'idle' && latexCodeRef.current && autoCompileRef.current) { + // Clear any pending auto-compile debounce to prevent double compilation + if (compileTimeoutRef.current) { + clearTimeout(compileTimeoutRef.current) + compileTimeoutRef.current = null + } + setTimeout(() => compileRef.current(), 0) + } + }) + }, []) + + // Helper to clear pending auto-compile (exposed for external use like file select) + const clearPendingAutoCompile = useCallback(() => { + if (compileTimeoutRef.current) { + clearTimeout(compileTimeoutRef.current) + compileTimeoutRef.current = null + } + }, []) + + // Listen for triggerCompile event from editor (Cmd+S) + useEffect(() => { + return EventBus.on('triggerCompile', () => { + // Clear any pending auto-compile to prevent double compilation + clearPendingAutoCompile() + compileRef.current() + }) + }, [clearPendingAutoCompile]) + + // Auto-compile on content change (debounced) + useEffect(() => { + if (!compilerSettings.autoCompile || !latexCode || !isLoaded) return + + if (compileTimeoutRef.current) { + clearTimeout(compileTimeoutRef.current) + } + + compileTimeoutRef.current = window.setTimeout(() => { + // Check at callback time, not effect time - code may have been compiled by init trigger + if (lastCompiledCodeRef.current === latexCodeRef.current) return + compileRef.current() + }, 1000) + + return () => { + if (compileTimeoutRef.current) { + clearTimeout(compileTimeoutRef.current) + } + } + }, [latexCode, isLoaded, compilerSettings.autoCompile]) + + return { + pdfData, + compileStatus, + compileTimeMs, + compilerSettings, + setCompilerSettings, + compile, + compileRef, + autoCompileRef, + clearPendingAutoCompile, + } +} diff --git a/app/src/hooks/useEventBus.ts b/app/src/hooks/useEventBus.ts new file mode 100644 index 0000000..bd1a767 --- /dev/null +++ b/app/src/hooks/useEventBus.ts @@ -0,0 +1,51 @@ +/** + * useEventBus - React hook for type-safe event subscription + * + * Automatically handles cleanup on unmount. + * + * Usage: + * // Subscribe to single event + * useEventBus('goToLine', ({ line }) => { + * editor.goToLine(line) + * }) + * + * // Subscribe with dependencies (re-subscribes when deps change) + * useEventBus('documentSwitched', ({ id }) => { + * console.log('Switched to:', id, currentMode) + * }, [currentMode]) + */ + +import { useEffect } from 'react' +import EventBus, { type EventMap } from '../services/EventBus' + +type EventCallback = T extends void ? () => void : (payload: T) => void + +/** + * Subscribe to an EventBus event with automatic cleanup + */ +export function useEventBus( + event: K, + callback: EventCallback, + deps: React.DependencyList = [] +): void { + useEffect(() => { + return EventBus.on(event, callback) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [event, ...deps]) +} + +/** + * Subscribe to an event once (auto-unsubscribes after first trigger) + */ +export function useEventBusOnce( + event: K, + callback: EventCallback, + deps: React.DependencyList = [] +): void { + useEffect(() => { + return EventBus.once(event, callback) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [event, ...deps]) +} + +export default useEventBus diff --git a/app/src/hooks/useIsMobile.ts b/app/src/hooks/useIsMobile.ts index ec2ed01..419990b 100644 --- a/app/src/hooks/useIsMobile.ts +++ b/app/src/hooks/useIsMobile.ts @@ -1,19 +1,32 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' const MOBILE_BREAKPOINT = 768 +const RESIZE_DEBOUNCE_MS = 150 export function useIsMobile(): boolean { const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false ) + const timeoutRef = useRef | null>(null) useEffect(() => { const handleResize = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + // Debounce resize handler to avoid excessive state updates during window drag + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + timeoutRef.current = setTimeout(() => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + }, RESIZE_DEBOUNCE_MS) } window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } }, []) return isMobile diff --git a/app/src/hooks/useKeyboardShortcuts.ts b/app/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..46a8a99 --- /dev/null +++ b/app/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,186 @@ +import { useEffect, useCallback } from 'react' +import type { ImperativePanelHandle } from 'react-resizable-panels' +import DocumentManager from '../services/DocumentManager' + +interface UseKeyboardShortcutsOptions { + showCommandPalette: boolean + showDesktopPreview: boolean + showDocumentation: boolean + onOpenCommandPalette: () => void + onCloseCommandPalette: () => void + onOpenDocumentation: (fromPalette?: boolean) => void + onCloseDocumentation: () => void + onCreateNewDocument: () => Promise + onCloseCurrentDocument: () => Promise + onToggleSidebar: () => void + onSetLatexCode: (content: string) => void + setShowDesktopPreview: React.Dispatch> + setShowPreview: React.Dispatch> + editorPanelRef: React.RefObject + previewPanelRef: React.RefObject +} + +export function useKeyboardShortcuts({ + showCommandPalette, + showDesktopPreview, + showDocumentation, + onOpenCommandPalette, + onCloseCommandPalette, + onOpenDocumentation, + onCloseDocumentation, + onCreateNewDocument, + onCloseCurrentDocument, + onToggleSidebar, + onSetLatexCode, + setShowDesktopPreview, + setShowPreview, + editorPanelRef, + previewPanelRef, +}: UseKeyboardShortcutsOptions): void { + const handleKeyDown = useCallback((e: KeyboardEvent) => { + // Command palette shortcuts (global) + if ((e.metaKey || e.ctrlKey) && e.key === 'k' && !e.shiftKey && !e.altKey) { + e.preventDefault() + e.stopPropagation() + + if (showCommandPalette) { + onCloseCommandPalette() + } else { + if (showDocumentation) { + onCloseDocumentation() + } + onOpenCommandPalette() + } + return + } + + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'F') { + e.preventDefault() + if (showCommandPalette) { + onCloseCommandPalette() + } else { + if (showDocumentation) { + onCloseDocumentation() + } + onOpenCommandPalette() + } + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'n') { + e.preventDefault() + onCreateNewDocument() + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'w') { + e.preventDefault() + onCloseCurrentDocument() + return + } + + // Undo/Redo shortcuts + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault() + DocumentManager.undo().then(result => { + if (result) { + onSetLatexCode(result.content) + } + }) + return + } + + if ((e.metaKey || e.ctrlKey) && ((e.shiftKey && e.key === 'z') || e.key === 'y')) { + e.preventDefault() + DocumentManager.redo().then(result => { + if (result) { + onSetLatexCode(result.content) + } + }) + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'h') { + e.preventDefault() + + if (showDocumentation) { + onCloseDocumentation() + } else { + onOpenDocumentation(showCommandPalette) + } + return + } + + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'E') { + e.preventDefault() + DocumentManager.exportDocument() + return + } + + // Cmd+. or Ctrl+. - toggle project sidebar + if ((e.metaKey || e.ctrlKey) && e.key === '.') { + e.preventDefault() + onToggleSidebar() + return + } + + // Cmd+1 - show only editor + if ((e.metaKey || e.ctrlKey) && e.key === '1') { + e.preventDefault() + if (window.innerWidth >= 768) { + setShowDesktopPreview(false) + } else { + setShowPreview(false) + } + } + // Cmd+2 - show editor and preview + else if ((e.metaKey || e.ctrlKey) && e.key === '2') { + e.preventDefault() + if (window.innerWidth >= 768) { + setShowDesktopPreview(true) + setTimeout(() => { + editorPanelRef.current?.resize(50) + previewPanelRef.current?.resize(50) + }, 0) + } else { + setShowPreview(true) + } + } + // Cmd+\ - toggle/balance panels + else if ((e.metaKey || e.ctrlKey) && e.key === '\\') { + e.preventDefault() + if (window.innerWidth >= 768) { + setShowDesktopPreview(prev => !prev) + if (showDesktopPreview) { + setTimeout(() => { + editorPanelRef.current?.resize(50) + previewPanelRef.current?.resize(50) + }, 0) + } + } else { + setShowPreview(prev => !prev) + } + } + }, [ + showCommandPalette, + showDesktopPreview, + showDocumentation, + onOpenCommandPalette, + onCloseCommandPalette, + onOpenDocumentation, + onCloseDocumentation, + onCreateNewDocument, + onCloseCurrentDocument, + onToggleSidebar, + onSetLatexCode, + setShowDesktopPreview, + setShowPreview, + editorPanelRef, + previewPanelRef, + ]) + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, [handleKeyDown]) +} diff --git a/app/src/hooks/useModals.ts b/app/src/hooks/useModals.ts new file mode 100644 index 0000000..efd3fcf --- /dev/null +++ b/app/src/hooks/useModals.ts @@ -0,0 +1,124 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import EventBus from '../services/EventBus' + +interface UseModalsReturn { + showCommandPalette: boolean + isClosingCommandPalette: boolean + showDocumentation: boolean + isClosingDocumentation: boolean + documentationOpenedFromPalette: boolean + openCommandPalette: () => void + closeCommandPalette: () => void + openDocumentation: (fromPalette?: boolean) => void + closeDocumentation: () => void + backFromDocumentation: () => void + setShowCommandPalette: React.Dispatch> +} + +export function useModals(): UseModalsReturn { + const [showCommandPalette, setShowCommandPalette] = useState(false) + const [isClosingCommandPalette, setIsClosingCommandPalette] = useState(false) + const [showDocumentation, setShowDocumentation] = useState(false) + const [isClosingDocumentation, setIsClosingDocumentation] = useState(false) + const [documentationOpenedFromPalette, setDocumentationOpenedFromPalette] = useState(false) + + // Track timeout IDs to prevent memory leaks on unmount + const timeoutRefs = useRef>>(new Set()) + + // Helper to track and manage timeouts + const safeTimeout = useCallback((callback: () => void, delay: number) => { + const id = setTimeout(() => { + timeoutRefs.current.delete(id) + callback() + }, delay) + timeoutRefs.current.add(id) + return id + }, []) + + // Cleanup all pending timeouts on unmount + useEffect(() => { + return () => { + timeoutRefs.current.forEach(id => clearTimeout(id)) + timeoutRefs.current.clear() + } + }, []) + + // Listen for command palette event from editor + useEffect(() => { + return EventBus.on('toggleCommandPalette', () => { + setShowCommandPalette(prev => !prev) + }) + }, []) + + // Listen for toggleHelp event from editor toolbar + useEffect(() => { + return EventBus.on('toggleHelp', () => { + if (showDocumentation) { + closeDocumentation() + } else { + setShowDocumentation(true) + } + }) + }, [showDocumentation]) + + const openCommandPalette = useCallback(() => { + setIsClosingCommandPalette(false) + setShowCommandPalette(true) + }, []) + + const closeCommandPalette = useCallback(() => { + setIsClosingCommandPalette(true) + safeTimeout(() => { + setIsClosingCommandPalette(false) + setShowCommandPalette(false) + }, 200) + }, [safeTimeout]) + + const openDocumentation = useCallback((fromPalette: boolean = false) => { + if (fromPalette) { + setDocumentationOpenedFromPalette(true) + // Close command palette with animation first + setIsClosingCommandPalette(true) + safeTimeout(() => { + setIsClosingCommandPalette(false) + setShowCommandPalette(false) + }, 200) + } else { + setDocumentationOpenedFromPalette(false) + } + setShowDocumentation(true) + }, [safeTimeout]) + + const closeDocumentation = useCallback(() => { + setIsClosingDocumentation(true) + safeTimeout(() => { + setIsClosingDocumentation(false) + setShowDocumentation(false) + setDocumentationOpenedFromPalette(false) + }, 200) + }, [safeTimeout]) + + const backFromDocumentation = useCallback(() => { + setIsClosingDocumentation(true) + safeTimeout(() => { + setIsClosingDocumentation(false) + setShowDocumentation(false) + setDocumentationOpenedFromPalette(false) + setShowCommandPalette(true) + }, 200) + }, [safeTimeout]) + + return { + showCommandPalette, + isClosingCommandPalette, + showDocumentation, + isClosingDocumentation, + documentationOpenedFromPalette, + openCommandPalette, + closeCommandPalette, + openDocumentation, + closeDocumentation, + backFromDocumentation, + setShowCommandPalette, + } +} diff --git a/app/src/services/CitationService.ts b/app/src/services/CitationService.ts new file mode 100644 index 0000000..a8fe98d --- /dev/null +++ b/app/src/services/CitationService.ts @@ -0,0 +1,193 @@ +/** + * CitationService - Thin wrapper for citation providers from busytex-lazy + * + * Imports the citation system from siglum-engine (via busytex-lazy) and provides + * React-friendly hooks and utilities. + */ + +import { + getZoteroProvider, + initCitations, + type ZoteroProvider, + type CitationManager, +} from 'busytex-lazy'; + +// API endpoint for Zotero proxy +const API_BASE = import.meta.env.DEV + ? 'http://localhost:8787' + : 'https://siglum-api.vtp-ips.workers.dev'; + +// Types for UI +export interface ZoteroStatus { + connected: boolean; + syncing: boolean; + itemCount: number; + userId: string | null; +} + +export interface CitationItem { + key: string; + citeKey: string; + title: string; + creatorsText: string; + year: string; + itemType: string; + provider: string; + data?: Record; +} + +export interface CitationServiceStatus { + zotero: ZoteroStatus; + connectedCount: number; + usedCitationsCount: number; +} + +class CitationService { + private manager: CitationManager; + private zotero: ZoteroProvider; + private _initialized = false; + private _statusSubscribers: Set<(status: CitationServiceStatus) => void> = new Set(); + + constructor() { + // Initialize citation system with API proxy + this.manager = initCitations({ proxyUrl: API_BASE }); + this.zotero = getZoteroProvider({ proxyUrl: API_BASE }); + + // Forward status updates + this.zotero.onStatus(() => this._notifySubscribers()); + this.manager.onStatus(() => this._notifySubscribers()); + } + + // ======================================== + // Initialization + // ======================================== + + async init(): Promise { + if (this._initialized) return true; + + try { + const connected = await this.zotero.init(); + this._initialized = true; + return connected; + } catch { + this._initialized = true; + return false; + } + } + + // ======================================== + // Zotero Connection + // ======================================== + + async connectZotero(userId: string, apiKey: string): Promise { + try { + await this.zotero.connect({ userId, apiKey }); + return true; + } catch (e) { + console.error('Zotero connect failed:', e); + throw e; + } + } + + async disconnectZotero(): Promise { + await this.zotero.disconnect(); + } + + async syncZotero(): Promise { + return this.zotero.sync(); + } + + isZoteroConnected(): boolean { + return this.zotero.isConnected(); + } + + getZoteroStatus(): ZoteroStatus { + return this.zotero.getStatus() as ZoteroStatus; + } + + // ======================================== + // Search + // ======================================== + + async search(query: string, limit = 20): Promise { + return this.manager.search(query, limit) as Promise; + } + + async searchZotero(query: string, limit = 20): Promise { + if (!this.zotero.isConnected()) return []; + return this.zotero.search(query, limit) as Promise; + } + + // ======================================== + // Citation Tracking + // ======================================== + + async trackCitation(item: CitationItem): Promise { + await this.manager.trackCitation(item); + } + + getTrackedCitations(): CitationItem[] { + return this.manager.getTrackedCitations() as CitationItem[]; + } + + // ======================================== + // Bibliography Generation + // ======================================== + + generateBibliography(): string { + return this.manager.generateBibliography(); + } + + getBibliographyForKeys(citeKeys: string[]): string { + return this.manager.getBibliographyForKeys(citeKeys); + } + + async injectBibliography(source: string): Promise { + return this.manager.injectBibliography(source); + } + + // ======================================== + // BibTeX for single item + // ======================================== + + getBibtex(item: CitationItem): string { + return this.zotero.getBibtex(item); + } + + // ======================================== + // Status Subscription + // ======================================== + + getStatus(): CitationServiceStatus { + const zoteroStatus = this.zotero.getStatus() as ZoteroStatus; + const managerStatus = this.manager.getStatus(); + + return { + zotero: zoteroStatus, + connectedCount: managerStatus.connectedCount, + usedCitationsCount: managerStatus.usedCitationsCount, + }; + } + + onStatus(callback: (status: CitationServiceStatus) => void): () => void { + this._statusSubscribers.add(callback); + // Immediately call with current status + callback(this.getStatus()); + return () => this._statusSubscribers.delete(callback); + } + + private _notifySubscribers(): void { + const status = this.getStatus(); + this._statusSubscribers.forEach(cb => cb(status)); + } +} + +// Singleton instance +const citationService = new CitationService(); + +// Initialize on module load +citationService.init().catch(() => { + // Silent fail on init - user hasn't connected yet +}); + +export default citationService; diff --git a/app/src/services/DocumentManager.ts b/app/src/services/DocumentManager.ts index c53673a..c9b849f 100644 --- a/app/src/services/DocumentManager.ts +++ b/app/src/services/DocumentManager.ts @@ -191,15 +191,21 @@ class DocumentManager { // Cache Management private addToCache(id: string, state: DocumentState): void { - // Evict oldest if full + // Evict oldest if full - O(k) single pass instead of O(k log k) sort if (this.cache.size >= MAX_CACHE_SIZE) { - const entries = [...this.cache.entries()] - .filter(([key]) => key !== this.currentDocument?.id) - .sort((a, b) => a[1].lastAccessed.getTime() - b[1].lastAccessed.getTime()) - - if (entries.length > 0) { - this.cache.delete(entries[0][0]) + let oldestKey: string | null = null + let oldestTime = Infinity + + for (const [key, entry] of this.cache) { + if (key === this.currentDocument?.id) continue + const time = entry.lastAccessed.getTime() + if (time < oldestTime) { + oldestTime = time + oldestKey = key + } } + + if (oldestKey) this.cache.delete(oldestKey) } this.cache.set(id, state) } diff --git a/app/src/services/DocumentSearcher.ts b/app/src/services/DocumentSearcher.ts index 2886358..c72a83b 100644 --- a/app/src/services/DocumentSearcher.ts +++ b/app/src/services/DocumentSearcher.ts @@ -1,5 +1,6 @@ import type { SiglumDocument, SearchResult } from '../types/Document' import DocumentService from './DocumentService' +import type { FuzzyMatchMessage, FuzzyMatchResultMessage, FuzzyMatchResult, FuzzyMatchRequest } from '../workers/search.worker' export interface SearchOptions { maxResults?: number @@ -9,9 +10,26 @@ export interface SearchOptions { class DocumentSearcher { private searchIndex: Map> = new Map() // word -> document IDs + private documentWords: Map> = new Map() // docId -> words (inverse index for O(1) removal) + private prefixToWords: Map> = new Map() // prefix -> full words (for fast fuzzy) private documentCache: Map = new Map() private isIndexed = false + // Optimization: pre-cached lowercase text for O(1) phrase search + private lowercaseCache: Map = new Map() + + // Optimization: LRU cache for Levenshtein distance calculations (max 1000 entries) + private similarityCache: Map = new Map() + private readonly SIMILARITY_CACHE_MAX = 1000 + + // Search worker for offloading fuzzy matching from main thread + private searchWorker: Worker | null = null + private workerRequestId = 0 + private pendingRequests: Map void + reject: (error: Error) => void + }> = new Map() + async initialize(): Promise { if (this.isIndexed) return @@ -23,18 +41,69 @@ class DocumentSearcher { this.isIndexed = true } + /** + * Get or create the search worker + */ + private getWorker(): Worker { + if (!this.searchWorker) { + this.searchWorker = new Worker( + new URL('../workers/search.worker.ts', import.meta.url), + { type: 'module' } + ) + this.searchWorker.onmessage = (event: MessageEvent) => { + const { id, results } = event.data + const pending = this.pendingRequests.get(id) + if (pending) { + this.pendingRequests.delete(id) + pending.resolve(results) + } + } + this.searchWorker.onerror = (error) => { + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error(`Worker error: ${error.message}`)) + this.pendingRequests.delete(id) + } + } + } + return this.searchWorker + } + + /** + * Batch fuzzy matching using the worker (off main thread) + */ + private fuzzyMatchInWorker(requests: FuzzyMatchRequest[]): Promise { + return new Promise((resolve, reject) => { + const id = ++this.workerRequestId + this.pendingRequests.set(id, { resolve, reject }) + + const message: FuzzyMatchMessage = { + type: 'fuzzyMatch', + id, + requests + } + + this.getWorker().postMessage(message) + }) + } + indexDocument(doc: SiglumDocument): void { this.documentCache.set(doc.id, doc) - - // Remove old index entries for this document + + // Cache lowercase versions for O(1) phrase search + this.lowercaseCache.set(doc.id, { + title: doc.title.toLowerCase(), + text: doc.searchableText.toLowerCase() + }) + + // Remove old index entries for this document (now O(k) where k = words in doc) this.removeFromIndex(doc.id) - + // Index title and content separately const titleWords = this.extractWords(doc.title, 2.0) // Title gets higher weight const contentWords = this.extractWords(doc.searchableText, 1.0) - + const allWords = new Map() - + // Combine words with weights titleWords.forEach((weight, word) => { allWords.set(word, (allWords.get(word) || 0) + weight) @@ -42,14 +111,21 @@ class DocumentSearcher { contentWords.forEach((weight, word) => { allWords.set(word, (allWords.get(word) || 0) + weight) }) - + + // Track words for this document (inverse index) + const docWordSet = new Set() + // Add to search index allWords.forEach((_weight, word) => { if (!this.searchIndex.has(word)) { this.searchIndex.set(word, new Set()) } this.searchIndex.get(word)!.add(doc.id) + docWordSet.add(word) }) + + // Store inverse mapping for O(1) removal + this.documentWords.set(doc.id, docWordSet) } private extractWords(text: string, weight: number = 1.0): Map { @@ -58,33 +134,48 @@ class DocumentSearcher { .replace(/[^\w\s]/g, ' ') // Replace punctuation with spaces .replace(/\s+/g, ' ') // Normalize whitespace .trim() - + if (!cleanText) return words - + const wordList = cleanText.split(' ').filter(word => word.length > 2) - + for (const word of wordList) { words.set(word, weight) - + // Also index word prefixes for partial matching if (word.length > 4) { for (let i = 3; i <= Math.min(word.length - 1, 6); i++) { const prefix = word.substring(0, i) words.set(prefix, weight * 0.5) // Lower weight for prefixes + + // Build reverse lookup: prefix -> full words (for fast fuzzy search) + if (!this.prefixToWords.has(prefix)) { + this.prefixToWords.set(prefix, new Set()) + } + this.prefixToWords.get(prefix)!.add(word) } } } - + return words } private removeFromIndex(documentId: string): void { - for (const [word, docIds] of this.searchIndex.entries()) { - docIds.delete(documentId) - if (docIds.size === 0) { - this.searchIndex.delete(word) + // Use inverse index for O(k) removal instead of O(n) + const words = this.documentWords.get(documentId) + if (!words) return + + for (const word of words) { + const docIds = this.searchIndex.get(word) + if (docIds) { + docIds.delete(documentId) + if (docIds.size === 0) { + this.searchIndex.delete(word) + } } } + + this.documentWords.delete(documentId) } async search(query: string, options: SearchOptions = {}): Promise { @@ -104,23 +195,65 @@ class DocumentSearcher { const documentScores = new Map() // Score documents based on word matches + // First pass: exact matches (synchronous) for (const [word] of queryWords) { - // Exact word matches const exactMatches = this.searchIndex.get(word) || new Set() for (const docId of exactMatches) { const currentScore = documentScores.get(docId) || 0 documentScores.set(docId, currentScore + 2.0) // Exact match gets high score } - - // Fuzzy matches if enabled - if (fuzzyMatch && word.length > 3) { - for (const [indexWord, docIds] of this.searchIndex.entries()) { - if (indexWord.includes(word) || word.includes(indexWord)) { - const similarity = this.calculateSimilarity(word, indexWord) - if (similarity > 0.6) { + } + + // Second pass: fuzzy matches (offloaded to worker) + if (fuzzyMatch) { + // Build batch of fuzzy match requests + const fuzzyRequests: FuzzyMatchRequest[] = [] + for (const [word] of queryWords) { + if (word.length > 3) { + const prefix = word.substring(0, 3) + const candidateWords = this.prefixToWords.get(prefix) + if (candidateWords) { + const candidates = Array.from(candidateWords).filter(c => c !== word) + if (candidates.length > 0) { + fuzzyRequests.push({ + word, + candidates, + threshold: 0.6 + }) + } + } + } + } + + // Process fuzzy matches in worker (off main thread) + if (fuzzyRequests.length > 0) { + try { + const fuzzyResults = await this.fuzzyMatchInWorker(fuzzyRequests) + + // Apply fuzzy match scores + for (const result of fuzzyResults) { + const docIds = this.searchIndex.get(result.candidate) + if (docIds) { for (const docId of docIds) { const currentScore = documentScores.get(docId) || 0 - documentScores.set(docId, currentScore + similarity) + documentScores.set(docId, currentScore + result.similarity) + } + } + } + } catch (error) { + // Worker failed, fall back to synchronous calculation + console.warn('Fuzzy match worker failed, using fallback:', error) + for (const request of fuzzyRequests) { + for (const candidate of request.candidates) { + const similarity = this.calculateSimilarity(request.word, candidate) + if (similarity >= request.threshold) { + const docIds = this.searchIndex.get(candidate) + if (docIds) { + for (const docId of docIds) { + const currentScore = documentScores.get(docId) || 0 + documentScores.set(docId, currentScore + similarity) + } + } } } } @@ -128,16 +261,24 @@ class DocumentSearcher { } } - // Check for phrase matches (higher score) + // Check for phrase matches (higher score) - uses pre-cached lowercase + // Only check docs that already have word matches to reduce iterations if (query.includes(' ')) { const phrase = query.toLowerCase().trim() - for (const doc of this.documentCache.values()) { - if (doc.title.toLowerCase().includes(phrase)) { - const currentScore = documentScores.get(doc.id) || 0 - documentScores.set(doc.id, currentScore + 5.0) // Phrase in title = highest score - } else if (doc.searchableText.toLowerCase().includes(phrase)) { - const currentScore = documentScores.get(doc.id) || 0 - documentScores.set(doc.id, currentScore + 3.0) // Phrase in content = high score + const docsToCheck = documentScores.size > 0 + ? documentScores.keys() + : this.documentCache.keys() + + for (const docId of docsToCheck) { + const cached = this.lowercaseCache.get(docId) + if (!cached) continue + + if (cached.title.includes(phrase)) { + const currentScore = documentScores.get(docId) || 0 + documentScores.set(docId, currentScore + 5.0) // Phrase in title = highest score + } else if (cached.text.includes(phrase)) { + const currentScore = documentScores.get(docId) || 0 + documentScores.set(docId, currentScore + 3.0) // Phrase in content = high score } } } @@ -184,13 +325,28 @@ class DocumentSearcher { } private calculateSimilarity(word1: string, word2: string): number { + // Memoization: use canonical key (sorted order) to avoid duplicate entries + const cacheKey = word1 < word2 ? `${word1}|${word2}` : `${word2}|${word1}` + + const cached = this.similarityCache.get(cacheKey) + if (cached !== undefined) return cached + const longer = word1.length > word2.length ? word1 : word2 const shorter = word1.length <= word2.length ? word1 : word2 - + if (longer.length === 0) return 1.0 - + const editDistance = this.levenshteinDistance(longer, shorter) - return (longer.length - editDistance) / longer.length + const similarity = (longer.length - editDistance) / longer.length + + // LRU eviction: remove oldest entries if cache is full + if (this.similarityCache.size >= this.SIMILARITY_CACHE_MAX) { + const firstKey = this.similarityCache.keys().next().value + if (firstKey) this.similarityCache.delete(firstKey) + } + this.similarityCache.set(cacheKey, similarity) + + return similarity } private levenshteinDistance(str1: string, str2: string): number { @@ -259,6 +415,7 @@ class DocumentSearcher { async removeDocument(docId: string): Promise { this.removeFromIndex(docId) this.documentCache.delete(docId) + this.lowercaseCache.delete(docId) } } diff --git a/app/src/services/EventBus.ts b/app/src/services/EventBus.ts new file mode 100644 index 0000000..e88c50a --- /dev/null +++ b/app/src/services/EventBus.ts @@ -0,0 +1,142 @@ +/** + * EventBus - Type-safe pub/sub for cross-module communication + * + * Replaces brittle window.dispatchEvent/addEventListener patterns with + * a strongly-typed, centralized event system. + * + * Usage: + * // Emit an event + * EventBus.emit('toggleCommandPalette') + * EventBus.emit('goToLine', { line: 42 }) + * + * // Subscribe (returns cleanup function) + * const unsub = EventBus.on('toggleCommandPalette', () => { ... }) + * + * // In React components + * useEffect(() => EventBus.on('goToLine', handler), []) + */ + +// Event definitions - single source of truth for all cross-module events +export interface EventMap { + // Modal events + toggleCommandPalette: void + toggleHelp: void + + // Editor events + goToLine: { line: number } + insertText: { text: string } + triggerCompile: void + + // Document events + documentChanged: { id: string; content: string } + documentSwitched: { id: string } + + // Project events + fileSelected: { path: string; lineNumber?: number } + filesChanged: void + + // Git events + gitStatusChanged: { behind: number; ahead: number } + gitConnected: void + gitDisconnected: void + + // UI events + sidebarToggled: { expanded: boolean } +} + +type EventCallback = T extends void ? () => void : (payload: T) => void +type Listener = { callback: Function; once: boolean } + +class EventBusService { + private listeners = new Map>() + + /** + * Subscribe to an event + * @returns Unsubscribe function (call in useEffect cleanup) + */ + on( + event: K, + callback: EventCallback + ): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + + const listener: Listener = { callback, once: false } + this.listeners.get(event)!.add(listener) + + return () => { + this.listeners.get(event)?.delete(listener) + } + } + + /** + * Subscribe to an event once (auto-unsubscribes after first call) + */ + once( + event: K, + callback: EventCallback + ): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + + const listener: Listener = { callback, once: true } + this.listeners.get(event)!.add(listener) + + return () => { + this.listeners.get(event)?.delete(listener) + } + } + + /** + * Emit an event to all subscribers + */ + emit( + event: K, + ...args: EventMap[K] extends void ? [] : [EventMap[K]] + ): void { + const listeners = this.listeners.get(event) + if (!listeners) return + + const toRemove: Listener[] = [] + + for (const listener of listeners) { + listener.callback(args[0]) + if (listener.once) { + toRemove.push(listener) + } + } + + // Clean up one-time listeners + for (const listener of toRemove) { + listeners.delete(listener) + } + } + + /** + * Remove all listeners for an event (useful for testing) + */ + off(event: K): void { + this.listeners.delete(event) + } + + /** + * Clear all listeners (useful for testing/cleanup) + */ + clear(): void { + this.listeners.clear() + } + + /** + * Get listener count for debugging + */ + listenerCount(event: K): number { + return this.listeners.get(event)?.size ?? 0 + } +} + +// Singleton instance +const EventBus = new EventBusService() + +export default EventBus diff --git a/app/src/services/ProjectSearcher.ts b/app/src/services/ProjectSearcher.ts index ddfbb4f..97bfeae 100644 --- a/app/src/services/ProjectSearcher.ts +++ b/app/src/services/ProjectSearcher.ts @@ -3,11 +3,13 @@ * * Searches file names and content in the current project. * Uses ProjectStore for file tree and GitService for reading content. + * Content search is offloaded to a Web Worker to prevent UI blocking. */ import ProjectStore from './ProjectStore' import type { FileItem } from './ProjectStore' import GitService from './GitService' +import type { SearchMessage, SearchResultMessage, ContentSearchResult, FileData } from '../workers/search.worker' export interface FileSearchResult { file: FileItem @@ -41,7 +43,89 @@ const TEXT_EXTENSIONS = new Set([ class ProjectSearcher { private contentCache: Map = new Map() private cacheTimestamp: number = 0 - private readonly CACHE_TTL = 30000 // 30 seconds + private readonly CACHE_TTL = 300000 // 5 minutes (was 30s - too short for real usage) + + // File list cache - avoid flattening tree on every search + private fileListCache: FileItem[] | null = null + private fileListTimestamp: number = 0 + + // Extension cache - avoid repeated string splitting + private extensionCache: Map = new Map() + + // Search worker for offloading content search from main thread + private searchWorker: Worker | null = null + private workerRequestId = 0 + private pendingRequests: Map void + reject: (error: Error) => void + }> = new Map() + + /** + * Get or create the search worker + */ + private getWorker(): Worker { + if (!this.searchWorker) { + this.searchWorker = new Worker( + new URL('../workers/search.worker.ts', import.meta.url), + { type: 'module' } + ) + this.searchWorker.onmessage = (event: MessageEvent) => { + const { id, results } = event.data + const pending = this.pendingRequests.get(id) + if (pending) { + this.pendingRequests.delete(id) + pending.resolve(results) + } + } + this.searchWorker.onerror = (error) => { + // Reject all pending requests on worker error + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error(`Worker error: ${error.message}`)) + this.pendingRequests.delete(id) + } + } + } + return this.searchWorker + } + + /** + * Search content using the worker (off main thread) + */ + private searchContentInWorker( + query: string, + files: FileData[], + caseSensitive: boolean, + maxResults: number + ): Promise { + return new Promise((resolve, reject) => { + const id = ++this.workerRequestId + this.pendingRequests.set(id, { resolve, reject }) + + const message: SearchMessage = { + type: 'search', + id, + query, + files, + caseSensitive, + maxResults + } + + this.getWorker().postMessage(message) + }) + } + + /** + * Get file extension with caching + */ + private getExtension(path: string): string { + if (this.extensionCache.has(path)) { + return this.extensionCache.get(path)! + } + const lastDot = path.lastIndexOf('.') + const ext = lastDot > 0 ? path.slice(lastDot + 1).toLowerCase() : '' + this.extensionCache.set(path, ext) + return ext + } /** * Search project files by name and optionally content @@ -58,15 +142,16 @@ class ProjectSearcher { return [] } - const allFiles = ProjectStore.getAllFiles() + const allFiles = this.getCachedFileList() const results: FileSearchResult[] = [] const searchQuery = caseSensitive ? query : query.toLowerCase() - // Filter files by extension if specified - const filesToSearch = extensions + // Filter files by extension if specified (use Set for O(1) lookup) + const extensionSet = extensions ? new Set(extensions) : null + const filesToSearch = extensionSet ? allFiles.filter(f => { - const ext = f.path.split('.').pop()?.toLowerCase() - return ext && extensions.includes(ext) + const ext = this.getExtension(f.path) + return ext && extensionSet.has(ext) }) : allFiles @@ -91,64 +176,88 @@ class ProjectSearcher { } } - // Search file contents if enabled + // Early exit if we have enough high-quality matches + const highScoreMatches = results.filter(r => r.score >= 50).length + if (highScoreMatches >= maxResults) { + return results + .sort((a, b) => b.score - a.score) + .slice(0, maxResults) + } + + // Search file contents if enabled - offloaded to worker if (searchContent && GitService.getStatus().isConnected) { const textFiles = filesToSearch.filter(f => { - const ext = f.path.split('.').pop()?.toLowerCase() + const ext = this.getExtension(f.path) return ext && TEXT_EXTENSIONS.has(ext) }) - for (const file of textFiles) { - // Skip if already matched by name with high score - const existingMatch = results.find(r => r.file.path === file.path) - if (existingMatch && existingMatch.score >= 50) continue + // Build set of already-matched high-score files for quick lookup + const highScorePaths = new Set( + results.filter(r => r.score >= 50).map(r => r.file.path) + ) + // Filter to only files that need content search + const filesToSearchContent = textFiles.filter(f => !highScorePaths.has(f.path)) + + // Load file contents (main thread) then search in worker + const fileDataPromises = filesToSearchContent.map(async (file): Promise => { try { const content = await this.getFileContent(file.path) - if (!content) continue - - const searchableContent = caseSensitive ? content : content.toLowerCase() - const lines = content.split('\n') - - let lineIndex = 0 - let charIndex = 0 - - // Find first match in content - const matchIndex = searchableContent.indexOf(searchQuery) - if (matchIndex !== -1) { - // Find line number - for (let i = 0; i < lines.length; i++) { - if (charIndex + lines[i].length >= matchIndex) { - lineIndex = i - break - } - charIndex += lines[i].length + 1 // +1 for newline - } + if (!content) return null + return { + path: file.path, + name: file.name, + id: file.id, + content + } + } catch { + return null + } + }) - // Create snippet with context - const startLine = Math.max(0, lineIndex - 1) - const endLine = Math.min(lines.length - 1, lineIndex + 1) - const snippet = lines.slice(startLine, endLine + 1).join('\n') - - // If already matched by name, update the match - if (existingMatch) { - existingMatch.matchType = 'content' - existingMatch.lineNumber = lineIndex + 1 - existingMatch.snippet = this.truncateSnippet(snippet, searchQuery) - existingMatch.score += 5 + const fileDataResults = await Promise.all(fileDataPromises) + const fileData = fileDataResults.filter((f): f is FileData => f !== null) + + // Offload the actual search to the worker (off main thread) + if (fileData.length > 0) { + try { + const workerResults = await this.searchContentInWorker( + query, + fileData, + caseSensitive, + maxResults * 2 - results.length + ) + + // Create a map to look up files by path + const fileMap = new Map(filesToSearchContent.map(f => [f.path, f])) + const resultsByPath = new Map(results.map(r => [r.file.path, r])) + + // Merge worker results into main results + for (const workerResult of workerResults) { + const file = fileMap.get(workerResult.filePath) + if (!file) continue + + const existing = resultsByPath.get(workerResult.filePath) + if (existing) { + existing.matchType = 'content' + existing.lineNumber = workerResult.lineNumber + existing.snippet = workerResult.snippet + existing.score += workerResult.score } else { - results.push({ + const result: FileSearchResult = { file, matchType: 'content', - lineNumber: lineIndex + 1, - snippet: this.truncateSnippet(snippet, searchQuery), - score: 5 - }) + lineNumber: workerResult.lineNumber, + snippet: workerResult.snippet, + score: workerResult.score + } + results.push(result) + resultsByPath.set(workerResult.filePath, result) } } - } catch (err) { - // Skip files that can't be read - console.warn(`[ProjectSearcher] Could not read ${file.path}:`, err) + } catch (error) { + // Worker failed, fall through without content search results + console.warn('Search worker failed:', error) } } } @@ -159,6 +268,19 @@ class ProjectSearcher { .slice(0, maxResults) } + /** + * Get file list with caching + */ + private getCachedFileList(): FileItem[] { + const now = Date.now() + if (this.fileListCache && now - this.fileListTimestamp < this.CACHE_TTL) { + return this.fileListCache + } + this.fileListCache = ProjectStore.getAllFiles() + this.fileListTimestamp = now + return this.fileListCache + } + /** * Search only file names (fast, no content reading) */ @@ -167,7 +289,7 @@ class ProjectSearcher { return [] } - const allFiles = ProjectStore.getAllFiles() + const allFiles = this.getCachedFileList() const searchQuery = query.toLowerCase() const results: FileSearchResult[] = [] @@ -219,36 +341,14 @@ class ProjectSearcher { } /** - * Truncate snippet to reasonable length - */ - private truncateSnippet(snippet: string, query: string): string { - const maxLength = 150 - if (snippet.length <= maxLength) return snippet - - // Try to center on the query - const lowerSnippet = snippet.toLowerCase() - const queryIndex = lowerSnippet.indexOf(query.toLowerCase()) - - if (queryIndex === -1) { - return snippet.substring(0, maxLength) + '...' - } - - const start = Math.max(0, queryIndex - 40) - const end = Math.min(snippet.length, queryIndex + query.length + 60) - - let result = snippet.substring(start, end) - if (start > 0) result = '...' + result - if (end < snippet.length) result = result + '...' - - return result - } - - /** - * Clear the content cache + * Clear all caches */ clearCache(): void { this.contentCache.clear() this.cacheTimestamp = 0 + this.fileListCache = null + this.fileListTimestamp = 0 + this.extensionCache.clear() } } diff --git a/app/src/services/StateStore.ts b/app/src/services/StateStore.ts new file mode 100644 index 0000000..7096df9 --- /dev/null +++ b/app/src/services/StateStore.ts @@ -0,0 +1,442 @@ +/** + * StateStore - UI state persistence in IndexedDB + * + * Stores all UI state in IndexedDB instead of localStorage for: + * - Better performance with large data + * - No storage limits (localStorage is 5-10MB) + * - Works in Web Workers (future-proofing) + * - Batched writes with debouncing for reduced I/O + * + * State includes: + * - App settings (sidebar, onboarding, etc.) + * - Document state (cursor, scroll, current doc) + * - Project files + * - Recent documents + */ + +import type { FileItem } from '../components/ProjectSidebar' + +const DB_NAME = 'siglum_state' +const DB_VERSION = 1 + +// Store names +const APP_STORE = 'app' +const DOC_STATE_STORE = 'document_state' + +// App state keys +const APP_KEYS = { + SIDEBAR_EXPANDED: 'sidebar_expanded', + ONBOARDING_COMPLETE: 'onboarding_complete', + CURRENT_DOCUMENT_ID: 'current_document_id', + RECENT_DOCUMENT_IDS: 'recent_document_ids', + PROJECT_FILES: 'project_files', + COMPILER_SETTINGS: 'compiler_settings', +} as const + +export interface DocumentState { + documentId: string + cursorStart: number + cursorEnd: number + scrollTop: number + scrollLeft: number + updatedAt: number +} + +export interface CompilerSettings { + autoCompile: boolean + compiler: 'auto' | 'pdflatex' | 'xelatex' + ctanFetch: boolean + cachePreamble: boolean + autoUnload: boolean +} + +// Pending writes for batching +interface PendingWrite { + store: string + key: string + value: unknown +} + +class StateStore { + private dbPromise: Promise + private pendingWrites = new Map() + private flushTimeout: number | null = null + private readonly FLUSH_DELAY = 100 // ms + // In-memory cache for document states to avoid async lookups + // Limited to 20 entries with LRU eviction to prevent unbounded memory growth + private docStateCache = new Map() + private readonly MAX_DOC_CACHE_SIZE = 20 + + // LRU cache helper: move key to end (most recent) and evict oldest if needed + private touchDocCache(documentId: string, state: DocumentState): void { + // Delete and re-add to move to end (Map maintains insertion order) + this.docStateCache.delete(documentId) + this.docStateCache.set(documentId, state) + + // Evict oldest entries if over limit + while (this.docStateCache.size > this.MAX_DOC_CACHE_SIZE) { + const oldestKey = this.docStateCache.keys().next().value + if (oldestKey) this.docStateCache.delete(oldestKey) + } + } + + constructor() { + this.dbPromise = this.initDB() + + // Force flush on page unload to save cursor position + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + this.flushSync() + }) + // Also flush on visibility change (tab switch, minimize) + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.flushSync() + } + }) + } + } + + // Synchronous flush for unload events (uses sendBeacon pattern) + private flushSync(): void { + if (this.flushTimeout !== null) { + clearTimeout(this.flushTimeout) + this.flushTimeout = null + } + + if (this.pendingWrites.size === 0) return + + // For unload, we need to do this synchronously + // IndexedDB transactions might not complete, so we try anyway + const writes = Array.from(this.pendingWrites.values()) + this.pendingWrites.clear() + + // Try to write synchronously (may not always complete on unload) + this.dbPromise.then(db => { + const stores = [...new Set(writes.map(w => w.store))] + const tx = db.transaction(stores, 'readwrite') + + for (const write of writes) { + const store = tx.objectStore(write.store) + if (write.store === DOC_STATE_STORE) { + store.put(write.value) + } else { + store.put(write.value, write.key) + } + } + }).catch(() => { + // Ignore errors during unload + }) + } + + private async initDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve(request.result) + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + // App settings store (key-value) + if (!db.objectStoreNames.contains(APP_STORE)) { + db.createObjectStore(APP_STORE) + } + + // Per-document state store + if (!db.objectStoreNames.contains(DOC_STATE_STORE)) { + const docStore = db.createObjectStore(DOC_STATE_STORE, { keyPath: 'documentId' }) + docStore.createIndex('updatedAt', 'updatedAt', { unique: false }) + } + } + }) + } + + // ======================================== + // Batched write system + // ======================================== + + private queueWrite(store: string, key: string, value: unknown): void { + const writeKey = `${store}:${key}` + this.pendingWrites.set(writeKey, { store, key, value }) + + if (this.flushTimeout === null) { + this.flushTimeout = window.setTimeout(() => this.flush(), this.FLUSH_DELAY) + } + } + + private async flush(): Promise { + this.flushTimeout = null + + if (this.pendingWrites.size === 0) return + + const writes = Array.from(this.pendingWrites.values()) + this.pendingWrites.clear() + + try { + const db = await this.dbPromise + const stores = [...new Set(writes.map(w => w.store))] + const tx = db.transaction(stores, 'readwrite') + + for (const write of writes) { + const store = tx.objectStore(write.store) + if (write.store === DOC_STATE_STORE) { + store.put(write.value) + } else { + store.put(write.value, write.key) + } + } + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } catch (error) { + console.error('[StateStore] Flush failed:', error) + } + } + + // Force flush (for critical writes) + async forceFlush(): Promise { + if (this.flushTimeout !== null) { + clearTimeout(this.flushTimeout) + this.flushTimeout = null + } + await this.flush() + } + + // ======================================== + // App Settings + // ======================================== + + async getSidebarExpanded(): Promise { + return (await this.getAppValue(APP_KEYS.SIDEBAR_EXPANDED)) ?? false + } + + setSidebarExpanded(expanded: boolean): void { + this.queueWrite(APP_STORE, APP_KEYS.SIDEBAR_EXPANDED, expanded) + } + + async getOnboardingComplete(): Promise { + return (await this.getAppValue(APP_KEYS.ONBOARDING_COMPLETE)) ?? false + } + + setOnboardingComplete(complete: boolean): void { + this.queueWrite(APP_STORE, APP_KEYS.ONBOARDING_COMPLETE, complete) + } + + async getCurrentDocumentId(): Promise { + return (await this.getAppValue(APP_KEYS.CURRENT_DOCUMENT_ID)) ?? null + } + + setCurrentDocumentId(id: string | null): void { + this.queueWrite(APP_STORE, APP_KEYS.CURRENT_DOCUMENT_ID, id) + } + + async getRecentDocumentIds(): Promise { + return (await this.getAppValue(APP_KEYS.RECENT_DOCUMENT_IDS)) ?? [] + } + + setRecentDocumentIds(ids: string[]): void { + this.queueWrite(APP_STORE, APP_KEYS.RECENT_DOCUMENT_IDS, ids) + } + + async getProjectFiles(): Promise { + return (await this.getAppValue(APP_KEYS.PROJECT_FILES)) ?? [] + } + + setProjectFiles(files: FileItem[]): void { + this.queueWrite(APP_STORE, APP_KEYS.PROJECT_FILES, files) + } + + async getCompilerSettings(): Promise { + return (await this.getAppValue(APP_KEYS.COMPILER_SETTINGS)) ?? null + } + + setCompilerSettings(settings: CompilerSettings): void { + this.queueWrite(APP_STORE, APP_KEYS.COMPILER_SETTINGS, settings) + } + + private async getAppValue(key: string): Promise { + const db = await this.dbPromise + + return new Promise((resolve, reject) => { + const tx = db.transaction([APP_STORE], 'readonly') + const store = tx.objectStore(APP_STORE) + const request = store.get(key) + + request.onsuccess = () => resolve(request.result as T | undefined) + request.onerror = () => reject(request.error) + }) + } + + // ======================================== + // Document State (cursor, scroll) + // ======================================== + + async getDocumentState(documentId: string): Promise { + // Check cache first + const cached = this.docStateCache.get(documentId) + if (cached) return cached + + const db = await this.dbPromise + + return new Promise((resolve, reject) => { + const tx = db.transaction([DOC_STATE_STORE], 'readonly') + const store = tx.objectStore(DOC_STATE_STORE) + const request = store.get(documentId) + + request.onsuccess = () => { + const result = request.result ?? null + // Populate cache with LRU eviction + if (result) { + this.touchDocCache(documentId, result) + } + resolve(result) + } + request.onerror = () => reject(request.error) + }) + } + + setCursorPosition(documentId: string, start: number, end: number): void { + // Use in-memory cache for synchronous access (critical for beforeunload) + const existing = this.docStateCache.get(documentId) + const state: DocumentState = { + documentId, + cursorStart: start, + cursorEnd: end, + scrollTop: existing?.scrollTop ?? 0, + scrollLeft: existing?.scrollLeft ?? 0, + updatedAt: Date.now() + } + // Update cache immediately with LRU eviction + this.touchDocCache(documentId, state) + this.queueWrite(DOC_STATE_STORE, documentId, state) + } + + setScrollPosition(documentId: string, scrollTop: number, scrollLeft: number): void { + // Use in-memory cache for synchronous access (critical for beforeunload) + const existing = this.docStateCache.get(documentId) + const state: DocumentState = { + documentId, + cursorStart: existing?.cursorStart ?? 0, + cursorEnd: existing?.cursorEnd ?? 0, + scrollTop, + scrollLeft, + updatedAt: Date.now() + } + // Update cache immediately with LRU eviction + this.touchDocCache(documentId, state) + this.queueWrite(DOC_STATE_STORE, documentId, state) + } + + async deleteDocumentState(documentId: string): Promise { + // Clear from cache + this.docStateCache.delete(documentId) + + const db = await this.dbPromise + + return new Promise((resolve, reject) => { + const tx = db.transaction([DOC_STATE_STORE], 'readwrite') + const store = tx.objectStore(DOC_STATE_STORE) + const request = store.delete(documentId) + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + } + + // Clean up old document states (keep last 50) + async pruneDocumentStates(keepCount: number = 50): Promise { + const db = await this.dbPromise + + return new Promise((resolve, reject) => { + const tx = db.transaction([DOC_STATE_STORE], 'readwrite') + const store = tx.objectStore(DOC_STATE_STORE) + const index = store.index('updatedAt') + + // Get all sorted by updatedAt (oldest first) + const request = index.openCursor() + const toDelete: string[] = [] + let count = 0 + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + count++ + const state = cursor.value as DocumentState + toDelete.push(state.documentId) + cursor.continue() + } else { + // Delete oldest entries if we have more than keepCount + const deleteCount = Math.max(0, count - keepCount) + for (let i = 0; i < deleteCount; i++) { + store.delete(toDelete[i]) + } + resolve() + } + } + + request.onerror = () => reject(request.error) + }) + } + + // ======================================== + // Migration from localStorage + // ======================================== + + async migrateFromLocalStorage(): Promise { + // Check if already migrated + const migrated = await this.getAppValue('_migrated_from_localstorage') + if (migrated) return + + console.log('[StateStore] Migrating from localStorage...') + + // Migrate sidebar state + const sidebarSaved = localStorage.getItem('siglum-sidebar-expanded') + if (sidebarSaved !== null) { + this.queueWrite(APP_STORE, APP_KEYS.SIDEBAR_EXPANDED, sidebarSaved === 'true') + } + + // Migrate onboarding state + const onboardingSaved = localStorage.getItem('siglum-onboarding-complete') + if (onboardingSaved !== null) { + this.queueWrite(APP_STORE, APP_KEYS.ONBOARDING_COMPLETE, onboardingSaved === 'true') + } + + // Migrate current document + const currentDocSaved = localStorage.getItem('siglum_current_document') + if (currentDocSaved !== null) { + this.queueWrite(APP_STORE, APP_KEYS.CURRENT_DOCUMENT_ID, currentDocSaved) + } + + // Migrate recent documents + const recentSaved = localStorage.getItem('siglum_recent_documents') + if (recentSaved !== null) { + try { + const recent = JSON.parse(recentSaved) + this.queueWrite(APP_STORE, APP_KEYS.RECENT_DOCUMENT_IDS, recent) + } catch { /* ignore */ } + } + + // Migrate project files + const projectFilesSaved = localStorage.getItem('siglum-project-files') + if (projectFilesSaved !== null) { + try { + const files = JSON.parse(projectFilesSaved) + this.queueWrite(APP_STORE, APP_KEYS.PROJECT_FILES, files) + } catch { /* ignore */ } + } + + // Mark as migrated + this.queueWrite(APP_STORE, '_migrated_from_localstorage', true) + + // Force flush to complete migration + await this.forceFlush() + + console.log('[StateStore] Migration complete') + } +} + +export default new StateStore() diff --git a/app/src/services/UndoManager.ts b/app/src/services/UndoManager.ts index 411447b..272eb41 100644 --- a/app/src/services/UndoManager.ts +++ b/app/src/services/UndoManager.ts @@ -1,20 +1,176 @@ /** - * UndoManager - In-memory undo/redo for document editing + * UndoManager - Differential undo/redo with operational transforms * - * Manages undo/redo stacks per document. State is session-only, - * not persisted across page reloads. + * Instead of storing full document copies, stores text diffs (patches) + * which reduces memory usage by ~98% for large documents. + * + * Before: 100KB doc × 100 states = 10MB per document + * After: 100KB doc + 100 × ~500 bytes avg diff = 150KB per document */ import type { UndoState } from '../types/Document' const MAX_HISTORY = 100 +// ======================================== +// Patch Types +// ======================================== + +interface Patch { + /** Position in the source string */ + pos: number + /** Number of characters to delete */ + del: number + /** Text to insert */ + ins: string +} + +interface UndoDiff { + /** Patches to apply for undo (new→old) */ + reverse: Patch[] + /** Cursor position before the change */ + cursorPosition: { start: number; end: number } + /** Timestamp of the change */ + timestamp: number +} + +interface RedoDiff { + /** Patches to apply for redo (old→new) */ + forward: Patch[] + /** Cursor position after the change */ + cursorPosition: { start: number; end: number } + /** Timestamp of the change */ + timestamp: number +} + interface DocumentUndoState { - undoStack: UndoState[] - redoStack: UndoState[] + /** Current document content (always kept up to date) */ + currentContent: string + /** Undo stack - contains diffs to revert changes */ + undoStack: UndoDiff[] + /** Redo stack - contains diffs to reapply changes */ + redoStack: RedoDiff[] + /** Content at last save point (for change detection) */ lastSavedContent: string } +// ======================================== +// Diff Algorithm (Simple LCS-based) +// ======================================== + +/** + * Compute patches to transform `oldStr` into `newStr` + * Uses a simple character-by-character diff that produces + * reasonably compact patches for typical text edits. + */ +function computeDiff(oldStr: string, newStr: string): Patch[] { + if (oldStr === newStr) return [] + + // Fast path: simple insertion at end + if (newStr.startsWith(oldStr)) { + return [{ pos: oldStr.length, del: 0, ins: newStr.slice(oldStr.length) }] + } + + // Fast path: simple deletion at end + if (oldStr.startsWith(newStr)) { + return [{ pos: newStr.length, del: oldStr.length - newStr.length, ins: '' }] + } + + // Fast path: find common prefix and suffix + let prefixLen = 0 + const minLen = Math.min(oldStr.length, newStr.length) + while (prefixLen < minLen && oldStr[prefixLen] === newStr[prefixLen]) { + prefixLen++ + } + + let suffixLen = 0 + while ( + suffixLen < minLen - prefixLen && + oldStr[oldStr.length - 1 - suffixLen] === newStr[newStr.length - 1 - suffixLen] + ) { + suffixLen++ + } + + const oldMiddle = oldStr.slice(prefixLen, oldStr.length - suffixLen) + const newMiddle = newStr.slice(prefixLen, newStr.length - suffixLen) + + // Single patch for the differing middle section + if (oldMiddle.length > 0 || newMiddle.length > 0) { + return [{ pos: prefixLen, del: oldMiddle.length, ins: newMiddle }] + } + + return [] +} + +/** + * Apply patches to a string to produce a new string + */ +function applyPatches(content: string, patches: Patch[]): string { + // Fast path: single patch (most common case) + if (patches.length === 1) { + const p = patches[0] + return content.slice(0, p.pos) + p.ins + content.slice(p.pos + p.del) + } + + if (patches.length === 0) return content + + // Multiple patches: sort by position descending to avoid offset issues + const sorted = patches.length > 1 + ? [...patches].sort((a, b) => b.pos - a.pos) + : patches + + let result = content + for (const patch of sorted) { + result = result.slice(0, patch.pos) + patch.ins + result.slice(patch.pos + patch.del) + } + + return result +} + +/** + * Invert patches to create reverse operation + * Given patches that transform A→B, returns patches that transform B→A + */ +function invertPatches(content: string, patches: Patch[]): Patch[] { + if (patches.length === 0) return [] + + // Fast path: single patch (most common case) - no sorting or offset tracking needed + if (patches.length === 1) { + const p = patches[0] + return [{ + pos: p.pos, + del: p.ins.length, + ins: content.slice(p.pos, p.pos + p.del) + }] + } + + // Multiple patches: need to track position offsets + const inverted: Patch[] = [] + let offset = 0 + + // Sort by position for consistent processing + const sorted = [...patches].sort((a, b) => a.pos - b.pos) + + for (const patch of sorted) { + const originalPos = patch.pos + offset + const deleted = content.slice(patch.pos, patch.pos + patch.del) + + inverted.push({ + pos: originalPos, + del: patch.ins.length, + ins: deleted + }) + + offset += patch.ins.length - patch.del + } + + return inverted +} + +// ======================================== +// UndoManager Class +// ======================================== + class UndoManager { private documents = new Map() private saveTimer: ReturnType | null = null @@ -26,6 +182,7 @@ class UndoManager { let state = this.documents.get(documentId) if (!state) { state = { + currentContent: initialContent, undoStack: [], redoStack: [], lastSavedContent: initialContent @@ -40,6 +197,7 @@ class UndoManager { */ init(documentId: string, content: string): void { this.documents.set(documentId, { + currentContent: content, undoStack: [], redoStack: [], lastSavedContent: content @@ -58,18 +216,29 @@ class UndoManager { } /** - * Record a state for potential undo - * Call this before content changes + * Record a state change for potential undo + * Call this with the OLD content before applying changes */ saveState( documentId: string, - content: string, + oldContent: string, + newContent: string, cursorPosition: { start: number; end: number } ): void { - const state = this.getState(documentId, content) + const state = this.getState(documentId, oldContent) + + // Compute forward patches (old→new) + const forwardPatches = computeDiff(oldContent, newContent) + + // No changes? Skip + if (forwardPatches.length === 0) return + // Compute reverse patches (new→old) + const reversePatches = invertPatches(oldContent, forwardPatches) + + // Store reverse diff for undo state.undoStack.push({ - content, + reverse: reversePatches, cursorPosition, timestamp: Date.now() }) @@ -77,17 +246,20 @@ class UndoManager { // Clear redo when new changes are made state.redoStack = [] - // Limit history size - if (state.undoStack.length > MAX_HISTORY) { - state.undoStack = state.undoStack.slice(-MAX_HISTORY) + // Update current content + state.currentContent = newContent + + // Limit history size - use shift() to avoid array reallocation + while (state.undoStack.length > MAX_HISTORY) { + state.undoStack.shift() } - state.lastSavedContent = content + state.lastSavedContent = newContent } /** * Check if content has changed enough to warrant saving undo state - * Debounces small changes, saves large changes immediately + * Saves on word boundaries, newlines, and after short pauses */ onContentChange( documentId: string, @@ -97,26 +269,84 @@ class UndoManager { onSave?: () => void ): void { const state = this.getState(documentId, oldContent) - const diff = Math.abs(newContent.length - state.lastSavedContent.length) - const isDifferent = newContent !== state.lastSavedContent - if (!isDifferent) return + // No change from last saved state + if (newContent === state.lastSavedContent) return - // Large changes (>50 chars) - save immediately - if (diff > 50) { - this.saveState(documentId, oldContent, cursorPosition) - onSave?.() - return + const lastSaved = state.lastSavedContent + const diff = newContent.length - lastSaved.length + + // Detect what was added/removed + const isInsertion = diff > 0 + const isDeletion = diff < 0 + + // Save immediately on: + // 1. Newlines (user pressed Enter) + // 2. Large deletions (>1 char, like selecting and deleting) + // 3. Large pastes (>20 chars) + // 4. Word completion (space/punctuation after typing) + + if (isInsertion) { + // Find what was inserted + const inserted = this.getInsertedText(lastSaved, newContent) + + // Newline - save immediately (natural break point) + if (inserted.includes('\n')) { + this.clearTimer() + this.saveState(documentId, lastSaved, newContent, cursorPosition) + onSave?.() + return + } + + // Large paste - save immediately + if (diff > 20) { + this.clearTimer() + this.saveState(documentId, lastSaved, newContent, cursorPosition) + onSave?.() + return + } + + // Word boundary (space or punctuation after typing) - save the word + if (/[\s.,;:!?)\]}]/.test(inserted)) { + this.clearTimer() + this.saveState(documentId, lastSaved, newContent, cursorPosition) + onSave?.() + return + } } - // Small changes - debounce - if (diff > 10 || state.lastSavedContent === '') { - this.clearTimer() - this.saveTimer = setTimeout(() => { - this.saveState(documentId, oldContent, cursorPosition) + if (isDeletion) { + // Multi-character deletion (selecting and deleting) - save immediately + if (Math.abs(diff) > 1) { + this.clearTimer() + this.saveState(documentId, lastSaved, newContent, cursorPosition) onSave?.() - }, 1000) + return + } } + + // Single character changes - debounce with short delay (300ms) + // This groups rapid typing but captures pauses + this.clearTimer() + this.saveTimer = setTimeout(() => { + this.saveState(documentId, state.lastSavedContent, newContent, cursorPosition) + onSave?.() + }, 300) + } + + /** + * Get the text that was inserted (simple heuristic for single insertions) + */ + private getInsertedText(oldStr: string, newStr: string): string { + if (newStr.length <= oldStr.length) return '' + + // Find common prefix + let i = 0 + while (i < oldStr.length && oldStr[i] === newStr[i]) i++ + + // The inserted text is between the prefix and where old string continues + const insertedLen = newStr.length - oldStr.length + return newStr.slice(i, i + insertedLen) } /** @@ -132,16 +362,29 @@ class UndoManager { if (state.undoStack.length === 0) return null - const previousState = state.undoStack.pop()! + const undoDiff = state.undoStack.pop()! + + // Apply reverse patches to get previous content + const previousContent = applyPatches(currentContent, undoDiff.reverse) + + // Compute forward patches for redo + const forwardPatches = computeDiff(previousContent, currentContent) - // Save current state to redo stack + // Save to redo stack state.redoStack.push({ - content: currentContent, + forward: forwardPatches, cursorPosition: currentCursor, timestamp: Date.now() }) - return previousState + // Update current content + state.currentContent = previousContent + + return { + content: previousContent, + cursorPosition: undoDiff.cursorPosition, + timestamp: undoDiff.timestamp + } } /** @@ -157,16 +400,29 @@ class UndoManager { if (state.redoStack.length === 0) return null - const nextState = state.redoStack.pop()! + const redoDiff = state.redoStack.pop()! + + // Apply forward patches to get next content + const nextContent = applyPatches(currentContent, redoDiff.forward) - // Save current state to undo stack + // Compute reverse patches for undo + const reversePatches = invertPatches(currentContent, redoDiff.forward) + + // Save to undo stack state.undoStack.push({ - content: currentContent, + reverse: reversePatches, cursorPosition: currentCursor, timestamp: Date.now() }) - return nextState + // Update current content + state.currentContent = nextContent + + return { + content: nextContent, + cursorPosition: redoDiff.cursorPosition, + timestamp: redoDiff.timestamp + } } /** @@ -202,10 +458,38 @@ class UndoManager { this.clearTimer() const state = this.getState(documentId) if (currentContent !== state.lastSavedContent) { - this.saveState(documentId, currentContent, cursorPosition) + this.saveState(documentId, state.lastSavedContent, currentContent, cursorPosition) } } } + + /** + * Get memory usage stats for debugging + */ + getMemoryStats(documentId: string): { undoBytes: number; redoBytes: number; totalPatches: number } { + const state = this.documents.get(documentId) + if (!state) return { undoBytes: 0, redoBytes: 0, totalPatches: 0 } + + let undoBytes = 0 + let redoBytes = 0 + let totalPatches = 0 + + for (const diff of state.undoStack) { + for (const patch of diff.reverse) { + undoBytes += patch.ins.length + 16 // rough estimate: string + overhead + totalPatches++ + } + } + + for (const diff of state.redoStack) { + for (const patch of diff.forward) { + redoBytes += patch.ins.length + 16 + totalPatches++ + } + } + + return { undoBytes, redoBytes, totalPatches } + } } export default new UndoManager() diff --git a/app/src/types/busytex-lazy.d.ts b/app/src/types/busytex-lazy.d.ts index 768c3a9..bd1fe3f 100644 --- a/app/src/types/busytex-lazy.d.ts +++ b/app/src/types/busytex-lazy.d.ts @@ -66,4 +66,55 @@ declare module 'busytex-lazy' { export function getCachedPdf(hash: string, engine: string): Promise; export function saveCachedPdf(hash: string, engine: string, data: ArrayBuffer): Promise; export function listAllCachedPackages(): Promise; + + // Citation system + export interface CitationItem { + key: string; + citeKey: string; + title: string; + creatorsText: string; + year: string; + itemType: string; + provider: string; + data?: Record; + } + + export interface ZoteroStatus { + connected: boolean; + syncing: boolean; + itemCount: number; + userId: string | null; + } + + export interface CitationManagerStatus { + connectedCount: number; + usedCitationsCount: number; + } + + export interface ZoteroProvider { + init(): Promise; + connect(config: { userId: string; apiKey: string }): Promise; + disconnect(): Promise; + isConnected(): boolean; + getStatus(): ZoteroStatus; + search(query: string, limit?: number): Promise; + getBibtex(item: CitationItem): string; + onStatus(callback: (status: ZoteroStatus) => void): () => void; + } + + export interface CitationManager { + search(query: string, limit?: number): Promise; + trackCitation(item: CitationItem): Promise; + getTrackedCitations(): CitationItem[]; + generateBibliography(): string; + getBibliographyForKeys(citeKeys: string[]): string; + injectBibliography(source: string): Promise; + getStatus(): CitationManagerStatus; + onStatus(callback: (status: CitationManagerStatus) => void): () => void; + } + + export function getZoteroProvider(config?: { proxyUrl?: string }): ZoteroProvider; + export function getCitationManager(): CitationManager; + export function initCitations(config?: { proxyUrl?: string }): CitationManager; + export const CitationProvider: unknown; } diff --git a/app/src/workers/search.worker.ts b/app/src/workers/search.worker.ts new file mode 100644 index 0000000..cf048cd --- /dev/null +++ b/app/src/workers/search.worker.ts @@ -0,0 +1,253 @@ +/** + * Search Worker - Offloads content search from main thread + * + * Handles searching through file contents in a background thread + * to prevent UI blocking on large projects. + */ + +export interface FileData { + path: string + name: string + id: string + content: string +} + +export interface ContentSearchResult { + filePath: string + fileId: string + fileName: string + lineNumber: number + snippet: string + score: number +} + +export interface SearchMessage { + type: 'search' + id: number + query: string + files: FileData[] + caseSensitive: boolean + maxResults: number +} + +export interface SearchResultMessage { + type: 'result' + id: number + results: ContentSearchResult[] +} + +// Fuzzy matching types for DocumentSearcher +export interface FuzzyMatchRequest { + word: string + candidates: string[] + threshold: number +} + +export interface FuzzyMatchResult { + word: string + candidate: string + similarity: number +} + +export interface FuzzyMatchMessage { + type: 'fuzzyMatch' + id: number + requests: FuzzyMatchRequest[] +} + +export interface FuzzyMatchResultMessage { + type: 'fuzzyMatchResult' + id: number + results: FuzzyMatchResult[] +} + +/** + * Find line number at a given character index without splitting entire string + * More memory efficient than content.split('\n') + */ +function findLineNumber(content: string, charIndex: number): number { + let lineNumber = 1 + for (let i = 0; i < charIndex && i < content.length; i++) { + if (content.charCodeAt(i) === 10) { // newline + lineNumber++ + } + } + return lineNumber +} + +/** + * Extract snippet around match without creating intermediate arrays + */ +function extractSnippet(content: string, matchIndex: number, query: string): string { + const maxLength = 150 + const contextBefore = 40 + const contextAfter = 60 + + // Find line boundaries around match + let lineStart = matchIndex + let linesBack = 1 + while (lineStart > 0 && linesBack >= 0) { + lineStart-- + if (content.charCodeAt(lineStart) === 10) { + linesBack-- + } + } + if (lineStart > 0) lineStart++ // Move past the newline + + let lineEnd = matchIndex + query.length + let linesForward = 1 + while (lineEnd < content.length && linesForward >= 0) { + if (content.charCodeAt(lineEnd) === 10) { + linesForward-- + } + lineEnd++ + } + + let snippet = content.substring(lineStart, lineEnd).trim() + + // Truncate if too long, centering on query + if (snippet.length > maxLength) { + const queryInSnippet = snippet.toLowerCase().indexOf(query.toLowerCase()) + if (queryInSnippet !== -1) { + const start = Math.max(0, queryInSnippet - contextBefore) + const end = Math.min(snippet.length, queryInSnippet + query.length + contextAfter) + snippet = (start > 0 ? '...' : '') + + snippet.substring(start, end) + + (end < snippet.length ? '...' : '') + } else { + snippet = snippet.substring(0, maxLength) + '...' + } + } + + return snippet +} + +/** + * Levenshtein distance for fuzzy matching + */ +function levenshteinDistance(str1: string, str2: string): number { + const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null)) + + for (let i = 0; i <= str1.length; i++) { + matrix[0][i] = i + } + + for (let j = 0; j <= str2.length; j++) { + matrix[j][0] = j + } + + for (let j = 1; j <= str2.length; j++) { + for (let i = 1; i <= str1.length; i++) { + if (str1[i - 1] === str2[j - 1]) { + matrix[j][i] = matrix[j - 1][i - 1] + } else { + matrix[j][i] = Math.min( + matrix[j - 1][i - 1] + 1, // substitution + matrix[j][i - 1] + 1, // insertion + matrix[j - 1][i] + 1 // deletion + ) + } + } + } + + return matrix[str2.length][str1.length] +} + +/** + * Calculate similarity between two words (0-1) + */ +function calculateSimilarity(word1: string, word2: string): number { + const longer = word1.length > word2.length ? word1 : word2 + const shorter = word1.length <= word2.length ? word1 : word2 + + if (longer.length === 0) return 1.0 + + const editDistance = levenshteinDistance(longer, shorter) + return (longer.length - editDistance) / longer.length +} + +/** + * Batch fuzzy matching - process multiple word comparisons + */ +function processFuzzyMatches(requests: FuzzyMatchRequest[]): FuzzyMatchResult[] { + const results: FuzzyMatchResult[] = [] + + for (const request of requests) { + for (const candidate of request.candidates) { + const similarity = calculateSimilarity(request.word, candidate) + if (similarity >= request.threshold) { + results.push({ + word: request.word, + candidate, + similarity + }) + } + } + } + + return results +} + +/** + * Search file content for query + */ +function searchContent( + query: string, + files: FileData[], + caseSensitive: boolean, + maxResults: number +): ContentSearchResult[] { + const results: ContentSearchResult[] = [] + const searchQuery = caseSensitive ? query : query.toLowerCase() + + for (const file of files) { + if (results.length >= maxResults) break + + const content = file.content + const searchableContent = caseSensitive ? content : content.toLowerCase() + + const matchIndex = searchableContent.indexOf(searchQuery) + if (matchIndex === -1) continue + + results.push({ + filePath: file.path, + fileId: file.id, + fileName: file.name, + lineNumber: findLineNumber(content, matchIndex), + snippet: extractSnippet(content, matchIndex, query), + score: 5 + }) + } + + return results +} + +// Handle messages from main thread +type WorkerMessage = SearchMessage | FuzzyMatchMessage + +self.onmessage = (event: MessageEvent) => { + const message = event.data + + if (message.type === 'search') { + const results = searchContent( + message.query, + message.files, + message.caseSensitive, + message.maxResults + ) + + self.postMessage({ + type: 'result', + id: message.id, + results + } satisfies SearchResultMessage) + } else if (message.type === 'fuzzyMatch') { + const results = processFuzzyMatches(message.requests) + + self.postMessage({ + type: 'fuzzyMatchResult', + id: message.id, + results + } satisfies FuzzyMatchResultMessage) + } +} diff --git a/app/vite.config.ts b/app/vite.config.ts index 2f265a7..b7621c1 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -3,7 +3,9 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + ], define: { global: 'globalThis', }, @@ -12,5 +14,34 @@ export default defineConfig({ }, build: { target: 'esnext', - } + rollupOptions: { + output: { + manualChunks: { + // Split vendor chunks for better caching + 'codemirror': [ + '@codemirror/autocomplete', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lint', + '@codemirror/state', + '@codemirror/view', + '@lezer/highlight', + 'codemirror', + 'codemirror-lang-latex', + ], + 'pdf': ['pdfjs-dist'], + 'git': ['isomorphic-git', 'buffer'], + 'react-vendor': ['react', 'react-dom', 'react-resizable-panels'], + }, + }, + }, + }, + server: { + // Enable SharedArrayBuffer for WASM workers + // (localhost is a secure context, network access needs HTTPS) + headers: { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, + }, }) From db1b0916ea4128381b5a6e44c6f1ade6c920eed0 Mon Sep 17 00:00:00 2001 From: Adam Weber Date: Wed, 14 Jan 2026 16:44:19 -0800 Subject: [PATCH 2/6] Add zero copy transfers with shared array buffers --- app/src/components/PDFViewer.tsx | 79 +++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/app/src/components/PDFViewer.tsx b/app/src/components/PDFViewer.tsx index 4db1f5a..c5835ea 100644 --- a/app/src/components/PDFViewer.tsx +++ b/app/src/components/PDFViewer.tsx @@ -31,6 +31,7 @@ const PAGE_CACHE_SIZE = 3 interface PDFViewerProps { pdfUrl?: string pdfData?: Uint8Array + pdfIsShared?: boolean // True if pdfData is backed by SharedArrayBuffer (zero-copy) onCompile?: () => void isCompiling?: boolean } @@ -56,7 +57,7 @@ const ZOOM_PRESETS = [ { label: '200%', value: 2.0 }, ] -const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, onCompile, isCompiling }) => { +const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIsShared, onCompile, isCompiling }) => { const [pdf, setPdf] = useState(null) const [currentPage, setCurrentPage] = useState(1) const [pageCount, setPageCount] = useState(0) @@ -86,6 +87,7 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, onCom const pageCacheRef = useRef>(new Map()) // LRU page cache const searchAbortRef = useRef(null) // For cancelling ongoing search const measureCanvasRef = useRef(null) // Reusable canvas for text measurement + const offscreenCanvasRef = useRef(null) // Reusable offscreen canvas for double-buffering const pixelRatioRef = useRef(window.devicePixelRatio || 1) // Cache pixel ratio (rarely changes) const resizeTimeoutRef = useRef | null>(null) // For debounced resize @@ -135,12 +137,18 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, onCom if (pdfData) { // Keep the original pdfData reference for download - // (it comes from compilation and won't be modified) pdfDataCopyRef.current = pdfData - // Create a single copy for pdf.js since it may detach the ArrayBuffer - const pdfCopy = new Uint8Array(pdfData.length) - pdfCopy.set(pdfData) - loadingTask = pdfjsLib.getDocument({ data: pdfCopy }) + + // SharedArrayBuffer can't be detached, so pass directly to pdf.js (zero-copy) + // Regular ArrayBuffer may be detached by pdf.js, so copy first + let dataForPdfJs: Uint8Array + if (pdfIsShared) { + dataForPdfJs = pdfData + } else { + dataForPdfJs = new Uint8Array(pdfData.length) + dataForPdfJs.set(pdfData) + } + loadingTask = pdfjsLib.getDocument({ data: dataForPdfJs }) } else if (pdfUrl) { loadingTask = pdfjsLib.getDocument(pdfUrl) } else { @@ -177,7 +185,7 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, onCom renderTaskRef.current.cancel() } } - }, [pdfUrl, pdfData]) + }, [pdfUrl, pdfData, pdfIsShared]) // Get page from cache or load and cache it const getPageCached = useCallback(async (pdf: pdfjsLib.PDFDocumentProxy, pageNum: number): Promise => { @@ -210,7 +218,7 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, onCom return page }, []) - // Render current page + // Render current page using double-buffering to prevent blink const renderPage = useCallback(async () => { if (!pdf || !canvasRef.current) return @@ -228,29 +236,56 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, onCom try { const page = await getPageCached(pdf, currentPage) const viewport = page.getViewport({ scale }) + const pixelRatio = pixelRatioRef.current + const requiredWidth = Math.ceil(viewport.width * pixelRatio) + const requiredHeight = Math.ceil(viewport.height * pixelRatio) - const canvas = canvasRef.current - const context = canvas.getContext('2d') - if (!context) return + // Reuse offscreen canvas for double-buffering (avoids GC pressure) + if (!offscreenCanvasRef.current) { + offscreenCanvasRef.current = document.createElement('canvas') + } + const offscreen = offscreenCanvasRef.current - // Set canvas size (use cached pixel ratio - rarely changes) - const pixelRatio = pixelRatioRef.current - canvas.width = viewport.width * pixelRatio - canvas.height = viewport.height * pixelRatio - canvas.style.width = `${viewport.width}px` - canvas.style.height = `${viewport.height}px` + // Only resize if dimensions changed (resize clears the canvas and resets context) + if (offscreen.width !== requiredWidth || offscreen.height !== requiredHeight) { + offscreen.width = requiredWidth + offscreen.height = requiredHeight + } - // Clear canvas before rendering - context.clearRect(0, 0, canvas.width, canvas.height) - context.scale(pixelRatio, pixelRatio) + const offscreenCtx = offscreen.getContext('2d') + if (!offscreenCtx) return + // Clear and set up transform (context state persists, so always reset) + offscreenCtx.setTransform(1, 0, 0, 1, 0, 0) // Reset to identity for clearRect + offscreenCtx.clearRect(0, 0, requiredWidth, requiredHeight) // Clear before reuse + offscreenCtx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0) // Scale for rendering + + // Render to offscreen canvas renderTaskRef.current = page.render({ - canvasContext: context, + canvasContext: offscreenCtx, viewport: viewport, - canvas: canvas, + canvas: offscreen, }) await renderTaskRef.current.promise + // Only update visible canvas after render completes (atomic swap) + const canvas = canvasRef.current + if (!canvas) return + + // Only resize visible canvas if dimensions changed (resizing clears content) + if (canvas.width !== requiredWidth || canvas.height !== requiredHeight) { + canvas.width = requiredWidth + canvas.height = requiredHeight + canvas.style.width = `${viewport.width}px` + canvas.style.height = `${viewport.height}px` + } + + const context = canvas.getContext('2d') + if (context) { + // Draw offscreen content to visible canvas (single blit, no clear needed) + context.drawImage(offscreen, 0, 0) + } + // Fetch text content once and reuse for text layer and search highlights const textContent = await page.getTextContent() From e87c58e4a9b83973e2e4f1c96f12dfa415c4167e Mon Sep 17 00:00:00 2001 From: Adam Weber Date: Wed, 21 Jan 2026 08:08:06 -0800 Subject: [PATCH 3/6] Copywriting --- app/src/components/DocumentationModal.css | 455 +++++++++++ app/src/components/DocumentationModal.tsx | 872 +++++++++++++--------- app/src/components/EmptyState.css | 21 +- app/src/components/EmptyState.tsx | 25 +- 4 files changed, 990 insertions(+), 383 deletions(-) diff --git a/app/src/components/DocumentationModal.css b/app/src/components/DocumentationModal.css index 636f78a..5cd1b11 100644 --- a/app/src/components/DocumentationModal.css +++ b/app/src/components/DocumentationModal.css @@ -17,6 +17,8 @@ box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5); border: 1px solid var(--color-border); max-height: calc(100vh - 16vh); + width: 680px; + max-width: 90vw; } .doc-modal-container { @@ -644,6 +646,22 @@ margin: 0; } +.faq-item p + p { + margin-top: 8px; +} + +/* Text links - inherit color with underline */ +.text-link { + color: inherit; + text-decoration: underline; + text-decoration-color: var(--color-text-tertiary); + text-underline-offset: 2px; +} + +.text-link:hover { + text-decoration-color: var(--color-text-secondary); +} + /* Help Section */ .help-section { margin-bottom: 20px; @@ -825,3 +843,440 @@ .template-card .use-template-button:hover { opacity: 0.9; } + +/* ========================================================================== + INTEGRATIONS TAB - Split layout with secondary nav + ========================================================================== */ + +.doc-content.integrations-split { + display: flex; + gap: 0; + padding: 0; + margin: -24px; /* Offset parent padding */ + width: calc(100% + 48px); + height: calc(100% + 48px); +} + +.integrations-nav { + width: 120px; + flex-shrink: 0; + padding: 16px 8px; + background: rgba(0, 0, 0, 0.15); + border-right: 1px solid var(--color-border-subtle); + display: flex; + flex-direction: column; + gap: 2px; +} + +.integrations-nav-btn { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--color-text-secondary); + font-size: 13px; + font-family: inherit; + cursor: pointer; + text-align: left; + transition: all 0.1s ease; +} + +.integrations-nav-btn:hover { + background: rgba(232, 227, 211, 0.06); + color: var(--color-text-primary); +} + +.integrations-nav-btn.active { + background: rgba(232, 227, 211, 0.1); + color: var(--color-text-primary); +} + +.integrations-nav-btn svg { + flex-shrink: 0; + opacity: 0.7; +} + +.integrations-nav-btn.active svg { + opacity: 1; +} + +.integrations-nav-btn span:first-of-type:not(.integrations-nav-dot) { + flex: 1; +} + +.integrations-nav-dot { + width: 6px; + height: 6px; + background: #6b7a5e; + border-radius: 50%; + flex-shrink: 0; +} + +.integrations-content { + flex: 1; + padding: 24px; + overflow-y: auto; +} + +.integration-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.integration-header svg { + color: var(--color-text-secondary); + flex-shrink: 0; +} + +.integration-header h2 { + font-size: 18px; + font-weight: 600; + margin: 0; + color: var(--color-text-primary); +} + +.integrations-content .doc-lead { + margin-bottom: 20px; +} + +.integration-fields { + margin-bottom: 16px; +} + +/* Status banner */ +.integration-status-banner { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + margin-bottom: 20px; +} + +.integration-status-banner.connected { + background: rgba(107, 122, 94, 0.15); + color: #8fa07e; +} + +/* Form fields */ +.integration-page-content { + display: flex; + flex-direction: column; +} + +.integration-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.integration-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.integration-field label { + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + letter-spacing: 0.01em; +} + +.integration-field input { + padding: 10px 12px; + background: rgba(0, 0, 0, 0.15); + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text-primary); + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.integration-field input::placeholder { + color: var(--color-text-tertiary); +} + +.integration-field input:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(184, 149, 110, 0.15); +} + +.integration-field-value { + font-size: 14px; + color: var(--color-text-primary); + font-family: 'SF Mono', Monaco, monospace; + padding: 10px 0; + border-bottom: 1px solid var(--color-border-subtle); +} + +.integration-field-value.editable { + padding: 6px 0; +} + +.branch-input { + width: 100%; + padding: 4px 8px; + font-size: 14px; + font-family: 'SF Mono', Monaco, monospace; + color: var(--color-text-primary); + background: rgba(232, 227, 211, 0.06); + border: 1px solid var(--color-border-subtle); + border-radius: 4px; + outline: none; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.branch-input:hover { + background: rgba(232, 227, 211, 0.08); +} + +.branch-input:focus { + border-color: var(--color-accent); + background: rgba(232, 227, 211, 0.1); +} + +.integration-field-help { + font-size: 12px; + color: var(--color-accent); + text-decoration: none; + transition: opacity 0.15s ease; +} + +.integration-field-help:hover { + opacity: 0.8; +} + +.integration-field-hint { + font-size: 11px; + color: var(--color-text-tertiary); +} + +.integration-form-error { + font-size: 13px; + color: #e07070; + margin: 0; + padding: 8px 12px; + background: rgba(224, 112, 112, 0.1); + border-radius: 6px; +} + +/* Action buttons */ +.integration-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.integration-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + cursor: pointer; + transition: all 0.15s ease; + border: none; +} + +.integration-action-btn.primary { + background: var(--color-accent); + color: var(--color-base); +} + +.integration-action-btn.primary:hover:not(:disabled) { + filter: brightness(1.1); +} + +.integration-action-btn.primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.integration-action-btn.secondary { + background: transparent; + color: var(--color-text-tertiary); + padding: 10px 14px; +} + +.integration-action-btn.secondary:hover { + color: var(--color-text-secondary); +} + +/* Spinning animation */ +.integration-action-btn .spinning { + animation: doc-spin 1s linear infinite; +} + +@keyframes doc-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Feature section within integration page */ +.integration-feature-section { + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid var(--color-border-subtle); +} + +.integration-feature-section h4 { + font-size: 13px; + font-weight: 600; + color: var(--color-text-secondary); + margin: 0 0 8px 0; +} + +.integration-feature-section p { + font-size: 12px; + line-height: 1.55; + color: var(--color-text-tertiary); + margin: 0; +} + +/* ========================================================================== + COLLABORATION TAB + ========================================================================== */ + +.collab-section { + margin-bottom: 28px; +} + +.collab-section:last-child { + margin-bottom: 0; +} + +.collab-section h3 { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 10px 0; +} + +.collab-section p { + font-size: 13px; + line-height: 1.6; + color: var(--color-text-secondary); + margin: 0 0 12px 0; +} + +.collab-steps { + margin: 0; + padding-left: 20px; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.7; +} + +.collab-steps li { + margin-bottom: 6px; +} + +.collab-steps li:last-child { + margin-bottom: 0; +} + +.collab-tips { + margin: 0; + padding: 0; + list-style: none; +} + +.collab-tips li { + position: relative; + padding-left: 16px; + margin-bottom: 12px; + font-size: 13px; + line-height: 1.6; + color: var(--color-text-secondary); +} + +.collab-tips li:last-child { + margin-bottom: 0; +} + +.collab-tips li::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + width: 4px; + height: 4px; + background: var(--color-accent); + border-radius: 50%; +} + +.collab-tips code { + font-family: 'SF Mono', Monaco, monospace; + font-size: 12px; + background: rgba(232, 227, 211, 0.08); + padding: 2px 5px; + border-radius: 3px; + color: var(--color-text-primary); +} + +.collab-comparison { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.collab-column { + background: rgba(232, 227, 211, 0.04); + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + padding: 14px; +} + +.collab-column h4 { + font-size: 12px; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 10px 0; +} + +.collab-column ul { + margin: 0; + padding: 0; + list-style: none; +} + +.collab-column ul li { + font-size: 12px; + line-height: 1.6; + color: var(--color-text-secondary); + padding-left: 14px; + position: relative; + margin-bottom: 4px; +} + +.collab-column ul li:last-child { + margin-bottom: 0; +} + +.collab-column ul li::before { + content: '•'; + position: absolute; + left: 0; + color: var(--color-text-tertiary); +} + +.collab-note { + font-size: 12px !important; + color: var(--color-text-tertiary) !important; + font-style: italic; +} diff --git a/app/src/components/DocumentationModal.tsx b/app/src/components/DocumentationModal.tsx index 50947c0..fbc7606 100644 --- a/app/src/components/DocumentationModal.tsx +++ b/app/src/components/DocumentationModal.tsx @@ -8,8 +8,15 @@ import { Github, ArrowRight, ArrowLeft, - LayoutTemplate + Check, + RefreshCw, + Plug, + Users, + Share2 } from 'lucide-react' +import ZoteroIcon from './icons/ZoteroIcon' +import GitService, { type GitStatus, type GitConfig } from '../services/GitService' +import CitationService, { type ZoteroStatus } from '../services/CitationService' import { useIsMobile } from '../hooks/useIsMobile' import './DocumentationModal.css' @@ -18,303 +25,10 @@ interface DocumentationModalProps { isClosing?: boolean onClose: () => void onBack?: () => void - onCreateFromTemplate?: (template: string) => void defaultTab?: TabId } -type TabId = 'guide' | 'templates' | 'faq' | 'support' - -const templates = [ - { - id: 'article', - title: 'Academic Article', - description: 'Research paper with abstract, sections, and bibliography', - template: `\\documentclass[11pt]{article} -\\usepackage[margin=1in]{geometry} -\\usepackage{amsmath,amsfonts,amssymb} -\\usepackage[utf8]{inputenc} -\\usepackage{cite} - -\\title{Your Research Title} -\\author{Your Name\\\\Your Institution} -\\date{\\today} - -\\begin{document} - -\\maketitle - -\\begin{abstract} -Write your abstract here. This should be a concise summary of your research, methodology, and key findings. -\\end{abstract} - -\\section{Introduction} - -Introduce your research problem and provide background context. - -\\section{Methodology} - -Describe your research methods and approach. - -\\section{Results} - -Present your findings with equations like $E = mc^2$ and references \\cite{example}. - -\\section{Conclusion} - -Summarize your contributions and future work. - -\\bibliographystyle{plain} -\\bibliography{references} - -\\end{document}` - }, - { - id: 'resume', - title: 'Professional Resume', - description: 'Clean CV template with sections for experience and education', - template: `\\documentclass[11pt,a4paper]{article} -\\usepackage[margin=0.75in]{geometry} -\\usepackage{enumitem} -\\usepackage{titlesec} -\\usepackage{hyperref} - -% Custom commands -\\newcommand{\\header}[1]{\\section*{\\large #1}\\hrule\\vspace{0.5em}} -\\newcommand{\\school}[4]{\\textbf{#1} \\hfill #2 \\\\ #3 \\hfill #4} -\\newcommand{\\employer}[4]{\\textbf{#1} \\hfill #2 \\\\ \\textit{#3} \\hfill #4} - -\\pagestyle{empty} -\\setlength{\\parindent}{0pt} - -\\begin{document} - -\\begin{center} -{\\Huge \\textbf{Your Name}}\\\\ -\\vspace{0.25em} -your.email@example.com $|$ (555) 123-4567 $|$ City, State -\\end{center} - -\\header{Education} -\\school{University Name}{City, State}{Bachelor of Science in Computer Science}{2020-2024} - -\\header{Experience} -\\employer{Software Engineer}{Company Name}{Full-time Position}{2024-Present} -\\begin{itemize}[leftmargin=1em] -\\item Developed and maintained web applications using modern technologies -\\item Collaborated with cross-functional teams to deliver high-quality software -\\item Participated in code reviews and mentored junior developers -\\end{itemize} - -\\header{Skills} -\\textbf{Programming:} Python, JavaScript, Java, C++\\\\ -\\textbf{Technologies:} React, Node.js, Docker, AWS\\\\ -\\textbf{Tools:} Git, Linux, VS Code - -\\header{Projects} -\\textbf{Project Name} - Brief description of your project and technologies used. - -\\end{document}` - }, - { - id: 'letter', - title: 'Formal Letter', - description: 'Professional letter template with proper formatting', - template: `\\documentclass[11pt]{letter} -\\usepackage[margin=1in]{geometry} - -\\address{Your Name\\\\Your Address\\\\City, State ZIP} -\\signature{Your Name} - -\\begin{document} - -\\begin{letter}{Recipient Name\\\\Recipient Title\\\\Company Name\\\\Address\\\\City, State ZIP} - -\\opening{Dear Mr./Ms. Last Name,} - -Write your letter content here. This template provides proper formatting for a professional letter with appropriate spacing and layout. - -You can include multiple paragraphs to organize your thoughts clearly. Each paragraph should focus on a specific point or topic. - -\\closing{Sincerely,} - -\\end{letter} - -\\end{document}` - }, - { - id: 'math', - title: 'Math Problem Set', - description: 'Template for mathematical assignments and homework', - template: `\\documentclass[11pt]{article} -\\usepackage[margin=1in]{geometry} -\\usepackage{amsmath,amsfonts,amssymb,amsthm} -\\usepackage{enumitem} - -\\title{Math Assignment} -\\author{Your Name} -\\date{\\today} - -\\newtheorem{problem}{Problem} - -\\begin{document} - -\\maketitle - -\\begin{problem} -Solve the following equation for $x$: -\\[2x^2 + 5x - 3 = 0\\] -\\end{problem} - -\\textbf{Solution:} Using the quadratic formula: -\\begin{align} -x &= \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\\\\ -x &= \\frac{-5 \\pm \\sqrt{25 + 24}}{4}\\\\ -x &= \\frac{-5 \\pm 7}{4} -\\end{align} - -Therefore, $x = \\frac{1}{2}$ or $x = -3$. - -\\begin{problem} -Prove that $\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$ for all positive integers $n$. -\\end{problem} - -\\textbf{Proof:} By mathematical induction... - -\\begin{problem} -Find the derivative of $f(x) = x^3 \\sin(x)$. -\\end{problem} - -\\textbf{Solution:} Using the product rule: -\\[f'(x) = 3x^2\\sin(x) + x^3\\cos(x)\\] - -\\end{document}` - }, - { - id: 'report', - title: 'Technical Report', - description: 'Structured report template with table of contents', - template: `\\documentclass[11pt]{report} -\\usepackage[margin=1in]{geometry} -\\usepackage{graphicx} -\\usepackage{hyperref} - -\\title{Technical Report Title} -\\author{Your Name\\\\Your Organization} -\\date{\\today} - -\\begin{document} - -\\maketitle - -\\tableofcontents -\\newpage - -\\chapter{Executive Summary} - -Provide a brief overview of your report's key findings and recommendations. - -\\chapter{Introduction} - -\\section{Background} -Explain the context and motivation for this report. - -\\section{Objectives} -List the main objectives and goals of your analysis. - -\\chapter{Methodology} - -Describe the methods, tools, and approaches used in your analysis. - -\\chapter{Results and Analysis} - -\\section{Key Findings} -Present your main findings with supporting data. - -\\section{Data Analysis} -Provide detailed analysis of your results. - -\\chapter{Conclusions and Recommendations} - -\\section{Conclusions} -Summarize your key conclusions. - -\\section{Recommendations} -Provide actionable recommendations based on your findings. - -\\chapter{Appendices} - -Include additional supporting materials, data, or detailed calculations. - -\\end{document}` - }, - { - id: 'presentation', - title: 'Presentation Slides', - description: 'Beamer template for academic or professional presentations', - template: `\\documentclass{beamer} -\\usetheme{Madrid} -\\usecolortheme{default} - -\\title{Your Presentation Title} -\\author{Your Name} -\\institute{Your Institution} -\\date{\\today} - -\\begin{document} - -\\frame{\\titlepage} - -\\begin{frame} -\\frametitle{Outline} -\\tableofcontents -\\end{frame} - -\\section{Introduction} -\\begin{frame} -\\frametitle{Introduction} -\\begin{itemize} -\\item Welcome to your presentation -\\item This template provides a clean, professional layout -\\item Easy to customize and extend -\\end{itemize} -\\end{frame} - -\\section{Main Content} -\\begin{frame} -\\frametitle{Key Points} -\\begin{enumerate} -\\item First important point -\\item Second key insight -\\item Supporting evidence -\\end{enumerate} -\\end{frame} - -\\begin{frame} -\\frametitle{Mathematical Content} -Here's an equation: -\\[E = mc^2\\] - -And here's a theorem: -\\begin{theorem} -For any triangle with sides $a$, $b$, and $c$: -\\[a^2 + b^2 = c^2\\] -(if it's a right triangle) -\\end{theorem} -\\end{frame} - -\\section{Conclusion} -\\begin{frame} -\\frametitle{Conclusion} -\\begin{itemize} -\\item Summarize your main points -\\item Highlight key takeaways -\\item Thank your audience -\\end{itemize} -\\end{frame} - -\\end{document}` - } -] +type TabId = 'guide' | 'collaboration' | 'sharing' | 'integrations' | 'faq' | 'support' interface Tab { id: TabId @@ -324,16 +38,46 @@ interface Tab { const tabs: Tab[] = [ { id: 'guide', label: 'Getting Started', icon: }, - { id: 'templates', label: 'Templates', icon: }, + { id: 'collaboration', label: 'Collaboration', icon: }, + { id: 'sharing', label: 'Sharing', icon: }, + { id: 'integrations', label: 'Integrations', icon: }, { id: 'faq', label: 'FAQ', icon: }, { id: 'support', label: 'Support', icon: } ] -const DocumentationModal: React.FC = ({ isOpen, isClosing = false, onClose, onBack, onCreateFromTemplate, defaultTab = 'guide' }) => { +const DocumentationModal: React.FC = ({ isOpen, isClosing = false, onClose, onBack, defaultTab = 'guide' }) => { const [activeTab, setActiveTab] = useState(defaultTab) - const [selectedTemplate, setSelectedTemplate] = useState(null) + const [selectedIntegration, setSelectedIntegration] = useState(null) const isMobile = useIsMobile() + // Git state + const [gitStatus, setGitStatus] = useState(GitService.getStatus()) + const [gitConfig, setGitConfig] = useState>(() => GitService.getConfig() || { + provider: 'github', + repoUrl: '', + branch: 'main', + token: '', + syncInterval: '15min' + }) + const [isGitConnecting, setIsGitConnecting] = useState(false) + const [gitError, setGitError] = useState(null) + + // Zotero state + const [zoteroStatus, setZoteroStatus] = useState(CitationService.getZoteroStatus()) + const [zoteroUserId, setZoteroUserId] = useState('') + const [zoteroApiKey, setZoteroApiKey] = useState('') + const [isZoteroConnecting, setIsZoteroConnecting] = useState(false) + + // Subscribe to status updates + React.useEffect(() => { + const unsubGit = GitService.subscribeStatus(setGitStatus) + const unsubZotero = CitationService.onStatus((status) => setZoteroStatus(status.zotero)) + return () => { + unsubGit() + unsubZotero() + } + }, []) + // Update active tab when defaultTab changes (e.g., opening from different links) React.useEffect(() => { if (isOpen) { @@ -341,14 +85,57 @@ const DocumentationModal: React.FC = ({ isOpen, isClosi } }, [isOpen, defaultTab]) - const handleSelectTemplate = (templateId: string) => { - setSelectedTemplate(selectedTemplate === templateId ? null : templateId) + // Git handlers + const handleGitConnect = async () => { + if (!gitConfig.repoUrl?.trim() || !gitConfig.token?.trim()) return + setIsGitConnecting(true) + setGitError(null) + try { + await GitService.connect(gitConfig as GitConfig) + } catch (e) { + setGitError(e instanceof Error ? e.message : 'Connection failed') + } finally { + setIsGitConnecting(false) + } + } + + const handleGitDisconnect = async () => { + await GitService.disconnect() + setGitConfig({ provider: 'github', repoUrl: '', branch: 'main', token: '', syncInterval: '15min' }) + } + + const handleGitSync = async () => { + try { + await GitService.sync() + } catch (e) { + console.error('Git sync failed:', e) + } } - const handleUseTemplate = (template: string) => { - if (onCreateFromTemplate) { - onCreateFromTemplate(template) - onClose() + // Zotero handlers + const handleZoteroConnect = async () => { + if (!zoteroUserId.trim() || !zoteroApiKey.trim()) return + setIsZoteroConnecting(true) + try { + await CitationService.connectZotero(zoteroUserId.trim(), zoteroApiKey.trim()) + setZoteroUserId('') + setZoteroApiKey('') + } catch (e) { + console.error('Zotero connect failed:', e) + } finally { + setIsZoteroConnecting(false) + } + } + + const handleZoteroDisconnect = async () => { + await CitationService.disconnectZotero() + } + + const handleZoteroSync = async () => { + try { + await CitationService.syncZotero() + } catch (e) { + console.error('Zotero sync failed:', e) } } @@ -460,6 +247,12 @@ const DocumentationModal: React.FC = ({ isOpen, isClosi Compile document +
+
+ +
+ Jump to next section (wraps around) +
N @@ -523,36 +316,389 @@ const DocumentationModal: React.FC = ({ isOpen, isClosi
) - case 'templates': + case 'collaboration': return (
-

Templates

-

Start with a professional LaTeX template.

- -
- {templates.map((t) => ( -
handleSelectTemplate(t.id)} - > -
-

{t.title}

-

{t.description}

+

Working Together

+

+ Siglum uses GitHub for collaboration. Each person works independently + and changes sync automatically through Git. +

+ +
+

How it works

+

+ Unlike real-time editors where you see every keystroke, Siglum lets you + focus without distraction. You work at your own pace and sync changes + when you're ready. +

+
    +
  1. Connect GitHub by linking a repository in Integrations
  2. +
  3. Work independently and edit without interruption
  4. +
  5. Sync when ready to push your changes and pull others'
  6. +
+
+ +
+

Avoiding conflicts

+

+ Git tracks changes line-by-line, so conflicts happen when two people + edit the same lines. A few habits that help: +

+
    +
  • + Split into sections early. Create separate files for + chapters or sections before you start writing so each person can work + in different files. +
  • +
  • + Use \input{'{'}file{'}'} to include section + files in your main document. Edits to different sections won't conflict. +
  • +
  • + Sync often. Small, frequent syncs are easier to merge + than large batches of changes. +
  • +
  • + Communicate. A quick message about which section you're + working on goes a long way. +
  • +
+
+ +
+

What Siglum handles

+
+
+

Siglum

+
    +
  • LaTeX editing and compilation
  • +
  • File management
  • +
  • Syncing with GitHub
  • +
  • Conflict detection
  • +
+
+
+

GitHub

+
    +
  • Version history
  • +
  • Branch management
  • +
  • Access control
  • +
  • Diff viewing
  • +
+
+
+
+ +
+

Sharing citations

+

+ When you insert a citation, the BibTeX entry is added to + your references.bib file. This syncs through GitHub like + everything else, so all collaborators share the same bibliography. +

+
+
+ ) + + case 'sharing': + return ( +
+

Getting Feedback

+

+ Send someone a link. They comment on your PDF. No accounts, no friction. +

+ +
+

How it works

+

+ Click in + the PDF viewer to create a snapshot. A link is copied to your clipboard + that you can send to your advisor, collaborator, or anyone else who + needs to review it. They can leave comments directly on the document. +

+
+ +
+

Versions

+

+ Each share is a snapshot, and comments stay attached to the version + they were made on. If your reviewer says "fix the typo on page 3," + you'll always know which page 3 they meant. +

+

+ When you make changes and want fresh feedback, create a new share. + Previous versions stick around so you can compare. +

+
+ +
+

Privacy

+

+ Shares are private and only visible to people you send the link to. + There's no public directory or way to discover them. +

+

+ Creating a share requires signing in with GitHub so we can associate + it with your account. Leaving a comment only requires an email, which + is not an account and won't be used to contact you. We ask for it solely + so you can request deletion of + your data later. +

+

+ Siglum is fully open source if + you want to see how any of this works. +

+
+ +
+

When to use this vs. GitHub

+

+ Sharing is for feedback: one person writes, others comment. It's great + for getting a draft reviewed by an advisor or colleague. +

+

+ GitHub is for co-authoring: multiple people editing the same project. + Use that when you're writing together. +

+
+
+ ) + + case 'integrations': + return ( +
+
+ + +
+ +
+ {(selectedIntegration === 'github' || !selectedIntegration) && ( + <> +
+ +

GitHub

+
+

Sync your documents to a Git repository for version control and backup.

+ + {gitStatus.isConnected ? ( + <> +
+ + Connected +
+ +
+
+ +
{gitConfig.repoUrl}
+
+
+ +
+ setGitConfig({ ...gitConfig, branch: e.target.value })} + onBlur={() => { + if (gitConfig.branch) { + GitService.setBranch(gitConfig.branch) + } + }} + className="branch-input" + /> +
+
+ {gitStatus.lastSync && ( +
+ +
+ {new Date(gitStatus.lastSync).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })} +
+
+ )} +
+ +
+ + +
+ +
+

Collaboration

+

+ Anyone with access to this repository can collaborate. Each person gets their own + branch automatically. Changes sync in the background. +

+
+ + ) : ( + <> +
+
+ + setGitConfig({ ...gitConfig, repoUrl: e.target.value })} + /> +
+
+ + setGitConfig({ ...gitConfig, token: e.target.value })} + /> + + Create a token on GitHub → + +
+ {gitError &&

{gitError}

} +
+ +
+
+ +
+

Includes collaboration

+

+ Connect a repository and anyone with access can collaborate. Each person gets + their own branch automatically. +

+
+ + )} + + )} + + {selectedIntegration === 'zotero' && ( + <> +
+ +

Zotero

- {selectedTemplate === t.id && ( - +

Import citations from your Zotero library into the shared bibliography.

+ + {zoteroStatus.connected ? ( + <> +
+ + Connected +
+ +
+
+ +
{zoteroStatus.userId}
+
+
+ +
{zoteroStatus.itemCount} items cached
+
+
+ +
+ + +
+ + ) : ( +
+
+ + setZoteroUserId(e.target.value)} + /> + Find this in Zotero settings under "Feeds/API" +
+
+ + setZoteroApiKey(e.target.value)} + /> + + Create an API key on Zotero → + +
+
+ +
+ +
+

How it works

+

+ When you insert a citation, the BibTeX entry is added to references.bib + automatically. If you're using GitHub, this file is shared with collaborators. +

+
+
)} -
- ))} + + )}
) @@ -567,70 +713,94 @@ const DocumentationModal: React.FC = ({ isOpen, isClosi

Do you store my documents on your server?

- No. Your documents stay in your browser - they don't touch our server. Your browser will reach out to our server to download the TeX engine and any packages needed. Then, everything runs locally on your machine. + Your source files stay in your browser and never touch our servers. + Compilation happens locally on your machine. The only time we store + anything is when you create a share, which uploads a snapshot of your + PDF so reviewers can see it. You can delete a share at any time and + the PDF is immediately queued for deletion.

Is my work saved?

- Yes, automatically. Everything saves to your browser's local storage. If you've connected GitHub and enabled auto-push, it will also sync there. + Yes, automatically. Everything saves to your browser's local storage, + and if you've connected GitHub it will sync there too.

Can I use [obscure package]?

- Probably! We bundle the most common packages, and anything else gets fetched from CTAN automatically. + Probably. We bundle the most common packages, and anything else gets + fetched from CTAN automatically the first time you use it.

Can I use custom fonts?

- Yes. Drop a .ttf or .otf file in your project and use fontspec. We include Latin Modern, TeX Gyre, and STIX2 by default. Note: Custom fonts require XeLaTeX. + Yes. Drop a .ttf or .otf file in your project and use fontspec. We + include Latin Modern, TeX Gyre, and STIX2 by default. Custom fonts + require XeLaTeX.

How is this different from Overleaf?

- Overleaf sends your documents to their servers to compile. Siglum compiles everything locally—nothing leaves your machine. It's usually faster (no network round-trip), works offline, and your documents stay private. + Overleaf sends your documents to their servers to compile. Siglum + compiles everything locally, so nothing leaves your machine. It's + usually faster since there's no network round-trip, works offline, + and your documents stay private.

Is this free?

- Yes. Local editing, compiling, and saving are free forever - no account needed. If we add cloud sync or collaboration features someday, those might have a small fee, but the core editor will always be free. + Yes, and it will stay that way. We've put a lot of thought into keeping + costs low. You can read about the architecture if + you're curious, and we publish what it costs each month to + run this. +

+

+ If Siglum is useful to you and you want to help keep it running, + you can sponsor the project on GitHub. + No pressure though.

What engines are supported?

- pdfLaTeX and XeLaTeX. Pick whichever you prefer. XeLaTeX is better for Unicode and custom fonts. + pdfLaTeX and XeLaTeX. XeLaTeX is better for Unicode and custom fonts, + but pdfLaTeX works fine for most documents.

Why is the first compile slow?

- The first compilation downloads the TeX engine (~15MB), then additional packages load on-demand. After that, compiles are fast—usually under a second. + The first compilation downloads the TeX engine (~15MB) and any packages + you need. After that, compiles are fast since everything is cached locally.

Does it work offline?

- Yes. Downloaded bundles cache to your browser's storage. Once loaded, Siglum works without an internet connection. Write on a plane, in a cabin, wherever. + Yes. For the best offline experience, we recommend the Siglum desktop app for + Windows, Mac, and Linux. It's small (~5MB) and efficient, and if you + sign in with GitHub your work syncs automatically when you're back online.

Is this open source?

- Yes. The editor is MIT licensed. The underlying TeX distribution is a mix of LPPL, GPL, and public domain (same as any TeX installation). + Yes. The editor is MIT licensed and the underlying TeX distribution is + a mix of LPPL, GPL, and public domain, same as any TeX installation.

@@ -646,7 +816,8 @@ const DocumentationModal: React.FC = ({ isOpen, isClosi

Found a bug?

- Open an issue on GitHub. Tell us what you did, what happened, and what you expected. Screenshots help a lot. + Open an issue on GitHub with what you did, what happened, and what + you expected. Screenshots help a lot.

= ({ isOpen, isClosi

Something not working?

- The usual fixes: + A few things to try before opening an issue:

  • Hard refresh (Cmd+Shift+R or Ctrl+Shift+R)
  • -
  • Try incognito/private mode
  • +
  • Try incognito or private browsing mode
  • Make sure your browser is up to date
@@ -675,7 +846,7 @@ const DocumentationModal: React.FC = ({ isOpen, isClosi

View the source

- Siglum is open source. Poke around, fork it, submit a PR. + Siglum is fully open source. Poke around, fork it, or submit a PR.

= ({ isOpen, isClosi

Privacy

- Your documents stay in your browser unless you explicitly share them. We can't see them. + Your documents stay in your browser unless you explicitly create a + share. We can't see your source files.

- We use Cloudflare Analytics to see basic usage stats (page views, not people). Unlike Google Analytics, Cloudflare doesn't use cookies, doesn't fingerprint you, doesn't build ad profiles, and doesn't track you across the web. They just count visits. + We use Cloudflare Analytics to see basic usage stats like page views. + Unlike Google Analytics, Cloudflare doesn't use cookies, doesn't + fingerprint you, and doesn't track you across the web. They just + count visits.

@@ -724,7 +899,10 @@ const DocumentationModal: React.FC = ({ isOpen, isClosi - ·
@@ -109,8 +90,6 @@ $E = mc^2$ setShowDocumentation(false)} - onCreateFromTemplate={handleCreateFromTemplate} - defaultTab={defaultTab} />
) From 756cd8637a1e25d1a1e99f068a5c3d2c93b71ae7 Mon Sep 17 00:00:00 2001 From: Adam Weber Date: Sun, 25 Jan 2026 21:28:36 -0800 Subject: [PATCH 4/6] Fix pinch zoom and doc resize --- app/src/components/PDFViewer.css | 148 ++++- app/src/components/PDFViewer.tsx | 1002 +++++++++++++++++++++++++----- 2 files changed, 984 insertions(+), 166 deletions(-) diff --git a/app/src/components/PDFViewer.css b/app/src/components/PDFViewer.css index bf3c748..be5ff0d 100644 --- a/app/src/components/PDFViewer.css +++ b/app/src/components/PDFViewer.css @@ -5,24 +5,24 @@ position: relative; overflow: hidden; transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1); + outline: none; } .pdf-document { width: 100%; height: calc(100% - 32px); margin-top: 32px; - display: flex; - justify-content: center; - align-items: flex-start; - padding: 24px; - overflow: auto; + /* Apple-style: no scroll, all positioning via transform */ + overflow: hidden; box-sizing: border-box; position: relative; z-index: 1; background: transparent; - container-type: inline-size; - -webkit-overflow-scrolling: touch; - overscroll-behavior: contain; + /* Prevent all browser gestures - we handle everything */ + touch-action: none; + /* Prevent text selection during gestures */ + user-select: none; + -webkit-user-select: none; } @media (min-width: 1200px) { @@ -32,16 +32,19 @@ } .pdf-page-wrapper { - position: relative; - margin: 0 auto; - display: inline-block; + /* Apple-style: absolute positioning, all movement via transform */ + position: absolute; + top: 0; + left: 0; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 6px 16px rgba(0, 0, 0, 0.25); - overflow: hidden; line-height: 0; background: #2a2520; - max-width: 100%; + /* Transform origin at top-left for predictable math */ + transform-origin: 0 0; + /* Enable GPU acceleration */ + will-change: transform; } .pdf-viewer.pdf-dark-mode .pdf-page-wrapper { @@ -412,17 +415,88 @@ pointer-events: none; } +/* Search match highlighting - other matches (subtle gold, matches editor) */ .search-highlight { - background: rgba(255, 200, 0, 0.35); + background: rgba(212, 166, 86, 0.2); border-radius: 2px; /* No transition on non-active highlights - saves CPU on 100+ elements */ } +/* Current/active search match - bright orange background (matches editor) */ .search-highlight.active { - background: rgba(255, 140, 0, 0.6); - box-shadow: 0 0 0 2px rgba(255, 140, 0, 0.4); + background: rgba(255, 120, 0, 0.7); /* Only animate the active highlight */ - transition: background 0.15s ease, box-shadow 0.15s ease; + transition: background 0.15s ease; +} + +/* Search Dropdown (matches editor pattern) */ +.pdf-search-dropdown { + position: absolute; + top: 32px; + right: 12px; + height: 32px; + background: var(--color-base-darker); + border: 1px solid var(--color-border); + border-top: none; + border-radius: 0 0 6px 6px; + display: flex; + align-items: center; + gap: 4px; + padding: 0 8px; + z-index: 15; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.pdf-search-input { + width: 180px; + height: 24px; + background: var(--color-base); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 0 8px; + font-size: 12px; + color: var(--color-text); + outline: none; +} + +.pdf-search-input:focus { + border-color: var(--color-accent); +} + +.pdf-search-input::placeholder { + color: var(--color-text-tertiary); +} + +.pdf-search-count { + font-size: 11px; + color: var(--color-text-secondary); + white-space: nowrap; + min-width: 60px; + text-align: center; +} + +.pdf-search-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + color: var(--color-text-secondary); + transition: background 0.15s ease; +} + +.pdf-search-btn:hover { + background: rgba(232, 227, 211, 0.08); + color: var(--color-text); +} + +.pdf-search-btn:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: -2px; } /* Outline/TOC Panel */ @@ -604,3 +678,43 @@ max-height: 50%; } } + +/* SyncTeX line indicator - points to the line in the PDF */ +.pdf-sync-indicator { + position: absolute; + left: 0; + right: 0; + height: 3px; + background: orange; + pointer-events: none; + z-index: 1000; + box-shadow: 0 0 10px orange, 0 0 20px orange; +} + +.pdf-sync-indicator::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + border-left: 16px solid orange; +} + +@keyframes syncHighlightPulse { + 0% { + opacity: 1; + box-shadow: 0 0 16px var(--color-accent); + } + 50% { + opacity: 1; + box-shadow: 0 0 8px var(--color-accent); + } + 100% { + opacity: 0.5; + box-shadow: none; + } +} diff --git a/app/src/components/PDFViewer.tsx b/app/src/components/PDFViewer.tsx index c5835ea..5078e73 100644 --- a/app/src/components/PDFViewer.tsx +++ b/app/src/components/PDFViewer.tsx @@ -13,10 +13,13 @@ * - Page cache with cleanup for off-screen pages */ -import React, { useState, useRef, useEffect, useCallback } from 'react' -import { ChevronLeft, ChevronRight, Search, Sun, Moon, ChevronUp, ChevronDown, X, Download } from 'lucide-react' +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { ChevronLeft, ChevronRight, Search, Sun, Moon, ChevronUp, ChevronDown, X, Download, Link2, Unlink, MessageSquare } from 'lucide-react' import * as pdfjsLib from 'pdfjs-dist' import type { TextItem, TextContent } from 'pdfjs-dist/types/src/display/api' +import { parseSyncTeX, inverseSync, forwardSync, type SyncTexData } from '../utils/synctex' +import EventBus from '../services/EventBus' +import StateStore from '../services/StateStore' import './PDFViewer.css' // Set worker path @@ -32,8 +35,11 @@ interface PDFViewerProps { pdfUrl?: string pdfData?: Uint8Array pdfIsShared?: boolean // True if pdfData is backed by SharedArrayBuffer (zero-copy) + syncTexData?: string // Raw SyncTeX data for bidirectional sync onCompile?: () => void isCompiling?: boolean + onOpenShare?: () => void + documentId?: string // For persisting viewer state (zoom, position, page) } interface SearchMatch { @@ -57,39 +63,305 @@ const ZOOM_PRESETS = [ { label: '200%', value: 2.0 }, ] -const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIsShared, onCompile, isCompiling }) => { +const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIsShared, syncTexData, onCompile, isCompiling, onOpenShare, documentId }) => { const [pdf, setPdf] = useState(null) const [currentPage, setCurrentPage] = useState(1) const [pageCount, setPageCount] = useState(0) - const [scale, setScale] = useState(1) + // renderScale: the scale at which we render the canvas (for sharpness) + const [renderScale, setRenderScale] = useState(1) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [isLightMode, setIsLightMode] = useState(false) const [fitWidth, setFitWidth] = useState(true) const [showZoomPresets, setShowZoomPresets] = useState(false) + // Apple-style transform state (the source of truth for viewport) + // visualScale: total visual zoom (renderScale * transformScale) + // translateX/Y: position of content in viewport + const transformRef = useRef({ + x: 0, + y: 0, + scale: 1, // multiplier on top of renderScale + }) + // Skip auto-centering after gesture-triggered re-renders + const skipCenterRef = useRef(false) + // For triggering re-renders when transform changes significantly + const [, forceUpdate] = useState(0) + // Search state const [searchQuery, setSearchQuery] = useState('') const [searchMatches, setSearchMatches] = useState([]) const [currentMatchIndex, setCurrentMatchIndex] = useState(0) const [showSearch, setShowSearch] = useState(false) + // SyncTeX mode state - when enabled, clicks trigger sync instead of text selection + const [syncMode, setSyncMode] = useState(false) + // Sync highlight state - Y position and key to force animation restart + const [syncHighlight, setSyncHighlight] = useState<{ y: number; key: number } | null>(null) + const syncHighlightTimeoutRef = useRef(null) + const syncHighlightKeyRef = useRef(0) + const syncIndicatorRef = useRef(null) + const canvasRef = useRef(null) const containerRef = useRef(null) const documentRef = useRef(null) const renderTaskRef = useRef(null) + const renderIdRef = useRef(0) // Incremented each render to detect stale renders + // Ref to track showSearch for stable event handler (avoids listener churn) + const showSearchRef = useRef(showSearch) + // Keep ref in sync with state (ref allows stable listener that doesn't need recreation) + useEffect(() => { + showSearchRef.current = showSearch + }, [showSearch]) const searchInputRef = useRef(null) const textLayerRef = useRef(null) const highlightLayerRef = useRef(null) const zoomButtonRef = useRef(null) const pdfDataCopyRef = useRef(null) // Keep a copy for download (pdf.js may detach the buffer) const pdfRef = useRef(null) // Track current PDF for cleanup + const loadingTaskRef = useRef(null) // Track loading task for cancellation const pageCacheRef = useRef>(new Map()) // LRU page cache const searchAbortRef = useRef(null) // For cancelling ongoing search const measureCanvasRef = useRef(null) // Reusable canvas for text measurement const offscreenCanvasRef = useRef(null) // Reusable offscreen canvas for double-buffering const pixelRatioRef = useRef(window.devicePixelRatio || 1) // Cache pixel ratio (rarely changes) - const resizeTimeoutRef = useRef | null>(null) // For debounced resize + const stateRestoredRef = useRef(false) // Track if state has been restored for this document + const saveStateTimeoutRef = useRef | null>(null) // Debounce state saves + + // Parse SyncTeX data when it changes + const parsedSyncTex = useMemo(() => { + if (!syncTexData) return null + return parseSyncTeX(syncTexData) + }, [syncTexData]) + + // Toggle sync mode handler + const toggleSyncMode = useCallback(() => { + const newMode = !syncMode + setSyncMode(newMode) + EventBus.emit('syncModeChanged', { enabled: newMode }) + }, [syncMode]) + + // Handle click on PDF for inverse sync (PDF → editor) + // When syncMode is enabled, any click triggers sync; otherwise requires Ctrl/Cmd+Click + const handlePdfClick = useCallback((e: React.MouseEvent) => { + // Only trigger if syncMode is on, or Ctrl/Cmd+Click + if (!syncMode && !(e.ctrlKey || e.metaKey)) return + if (!parsedSyncTex || !canvasRef.current) return + + const canvas = canvasRef.current + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + const pageHeight = rect.height + + const visualScale = renderScale * transformRef.current.scale + const result = inverseSync(parsedSyncTex, currentPage, x, y, pageHeight, 72 * visualScale) + if (result) { + // Emit event to editor to go to the source line + EventBus.emit('goToLine', { line: result.line }) + } + }, [syncMode, parsedSyncTex, currentPage, renderScale]) + + // Listen for forward sync events from editor (editor → PDF) + // Use refs for values that change frequently to avoid recreating the handler + const parsedSyncTexRef = useRef(parsedSyncTex) + const renderScaleRef = useRef(renderScale) + const currentPageRef = useRef(currentPage) + + useEffect(() => { + parsedSyncTexRef.current = parsedSyncTex + }, [parsedSyncTex]) + + useEffect(() => { + renderScaleRef.current = renderScale + }, [renderScale]) + + // Compute total visual scale (for external use like SyncTeX) + const getVisualScale = useCallback(() => { + return renderScaleRef.current * transformRef.current.scale + }, []) + + // Apply transform to the page wrapper element + const applyTransform = useCallback(() => { + const wrapper = documentRef.current?.querySelector('.pdf-page-wrapper') as HTMLElement + if (!wrapper) return + + const { x, y, scale } = transformRef.current + wrapper.style.transform = `translate(${x}px, ${y}px) scale(${scale})` + }, []) + + // Save PDF viewer state to StateStore (debounced) + const saveViewerState = useCallback(() => { + if (!documentId) return + + // Clear any pending save + if (saveStateTimeoutRef.current) { + clearTimeout(saveStateTimeoutRef.current) + } + + // Debounce saves to avoid excessive writes during gestures + saveStateTimeoutRef.current = setTimeout(() => { + const t = transformRef.current + StateStore.setPdfViewerState( + documentId, + renderScale, + t.x, + t.y, + t.scale, + currentPage + ) + }, 300) + }, [documentId, renderScale, currentPage]) + + // Restore PDF viewer state from StateStore when document changes + useEffect(() => { + if (!documentId) return + stateRestoredRef.current = false + + StateStore.getDocumentState(documentId).then(state => { + if (state && state.pdfRenderScale !== undefined) { + // Restore transform state first + transformRef.current = { + x: state.pdfTransformX ?? 0, + y: state.pdfTransformY ?? 0, + scale: state.pdfTransformScale ?? 1, + } + // Skip auto-centering since we're restoring a saved position + skipCenterRef.current = true + stateRestoredRef.current = true + // Restore scale and page (this triggers re-render) + setRenderScale(state.pdfRenderScale) + setCurrentPage(state.pdfCurrentPage ?? 1) + setFitWidth(false) // Disable fit-width when restoring custom zoom + } + }) + }, [documentId]) + + // Save state when relevant values change + useEffect(() => { + // Don't save during initial restore + if (!stateRestoredRef.current && !pdf) return + saveViewerState() + }, [renderScale, currentPage, saveViewerState, pdf]) + + // Center the content in the viewport (called after render or reset) + // IMPORTANT: This must produce the same result as clampTransform for small content + const centerContent = useCallback(() => { + const container = documentRef.current + const wrapper = container?.querySelector('.pdf-page-wrapper') as HTMLElement + const canvas = canvasRef.current + if (!container || !wrapper || !canvas) return + + // Use clientWidth/clientHeight for consistency with clampTransform + // These reflect the actual rendered CSS size + const containerRect = container.getBoundingClientRect() + const canvasWidth = canvas.clientWidth + const canvasHeight = canvas.clientHeight + + // If canvas hasn't been sized yet, skip centering (will be called again after render) + if (canvasWidth === 0 || canvasHeight === 0) return + + const scale = transformRef.current.scale + + // Content size at current transform scale + const contentWidth = canvasWidth * scale + const contentHeight = canvasHeight * scale + + // Use same logic as clampTransform to avoid jump on first gesture + let x: number, y: number + if (contentWidth <= containerRect.width) { + x = (containerRect.width - contentWidth) / 2 + } else { + x = 0 + } + if (contentHeight <= containerRect.height) { + const padding = Math.min(24, (containerRect.height - contentHeight) / 4) + y = padding + } else { + y = 0 + } + + transformRef.current.x = x + transformRef.current.y = y + applyTransform() + }, [applyTransform]) + + useEffect(() => { + currentPageRef.current = currentPage + }, [currentPage]) + + // Directly update indicator position via DOM (bypass React reconciliation issues) + useEffect(() => { + if (syncIndicatorRef.current) { + if (syncHighlight) { + syncIndicatorRef.current.style.top = `${syncHighlight.y}px` + syncIndicatorRef.current.style.display = 'block' + console.log('[SyncTeX] DOM update - setting top to:', syncHighlight.y) + } else { + syncIndicatorRef.current.style.display = 'none' + } + } + }, [syncHighlight]) + + useEffect(() => { + const handleForwardSync = ({ line }: { line: number }) => { + const syncTex = parsedSyncTexRef.current + const currentRenderScale = renderScaleRef.current + const visualScale = currentRenderScale * transformRef.current.scale + + if (!syncTex || !canvasRef.current) { + return + } + + // Clear any existing timeout + if (syncHighlightTimeoutRef.current) { + clearTimeout(syncHighlightTimeoutRef.current) + } + + const canvas = canvasRef.current + const pageHeight = canvas.getBoundingClientRect().height + const result = forwardSync(syncTex, line, undefined, pageHeight, 72 * visualScale) + + if (result) { + const showHighlight = () => { + // Recalculate Y based on current canvas (may have changed after page navigation) + const currentCanvas = canvasRef.current + if (!currentCanvas) return + + const currentPageHeight = currentCanvas.getBoundingClientRect().height + const recalcVisualScale = renderScaleRef.current * transformRef.current.scale + const recalculatedResult = forwardSync(syncTex!, line, undefined, currentPageHeight, 72 * recalcVisualScale) + const y = recalculatedResult?.y ?? result.y + + // Clamp Y to canvas bounds + const clampedY = Math.max(0, Math.min(y, currentPageHeight - 10)) + + console.log('[SyncTeX] pageHeight:', currentPageHeight, 'y:', y, 'clampedY:', clampedY) + + // Increment key and set highlight + syncHighlightKeyRef.current += 1 + setSyncHighlight({ y: clampedY, key: syncHighlightKeyRef.current }) + + // Auto-hide after 2 seconds + syncHighlightTimeoutRef.current = window.setTimeout(() => { + setSyncHighlight(null) + }, 2000) + } + + // Navigate to the correct page if needed + if (result.page !== currentPageRef.current) { + setCurrentPage(result.page) + // Wait for page to render before showing highlight + setTimeout(showHighlight, 150) + } else { + showHighlight() + } + } + } + + const unsubscribe = EventBus.on('forwardSync', handleForwardSync) + return unsubscribe + }, []) // Empty deps - handler is stable, uses refs for changing values // Cleanup PDF on unmount useEffect(() => { @@ -98,6 +370,16 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs if (searchAbortRef.current) { searchAbortRef.current.abort() } + // Cancel any ongoing loading task + if (loadingTaskRef.current) { + loadingTaskRef.current.destroy() + loadingTaskRef.current = null + } + // Cancel any ongoing render task + if (renderTaskRef.current) { + renderTaskRef.current.cancel() + renderTaskRef.current = null + } // Cleanup all cached pages for (const page of pageCacheRef.current.values()) { page.cleanup() @@ -115,6 +397,11 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs useEffect(() => { // No source provided - show empty state if (!pdfData && !pdfUrl) { + // Cancel any ongoing loading task first + if (loadingTaskRef.current) { + loadingTaskRef.current.destroy() + loadingTaskRef.current = null + } // Destroy previous PDF before clearing if (pdfRef.current) { pdfRef.current.destroy() @@ -125,7 +412,17 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs return } + // Track if this effect has been superseded (prevents stale state updates) + let cancelled = false + const loadPdf = async () => { + // Cancel any ongoing loading task before starting a new one + // This prevents "Transport destroyed" errors from in-flight operations + if (loadingTaskRef.current) { + loadingTaskRef.current.destroy() + loadingTaskRef.current = null + } + try { // Only show loading on initial load, not on updates (to prevent flash) if (!pdf) { @@ -155,8 +452,20 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs return } + // Track the loading task for cancellation + loadingTaskRef.current = loadingTask + const pdfDoc = await loadingTask.promise + // Clear ref after successful load (task is complete) + loadingTaskRef.current = null + + // Check if cancelled before updating state (prevents race condition) + if (cancelled) { + pdfDoc.destroy() + return + } + // Destroy previous PDF before setting new one (frees worker) if (pdfRef.current) { // Clean up all cached pages first @@ -171,7 +480,20 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs setPdf(pdfDoc) setPageCount(pdfDoc.numPages) setIsLoading(false) - } catch (err) { + } catch (err: unknown) { + // Only clear ref if this is still the current task (avoid clobbering newer task) + if (loadingTaskRef.current === loadingTask) { + loadingTaskRef.current = null + } + + // Ignore cancellation errors (expected when superseded) + if (err instanceof Error && err.message?.includes('destroyed')) { + return + } + + // Don't update state if cancelled + if (cancelled) return + console.error('Failed to load PDF:', err) setError('Failed to load PDF') setIsLoading(false) @@ -181,9 +503,19 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs loadPdf() return () => { + // Mark as cancelled to prevent stale state updates + cancelled = true + + // Cancel render task if (renderTaskRef.current) { renderTaskRef.current.cancel() } + + // Cancel loading task if still in progress + if (loadingTaskRef.current) { + loadingTaskRef.current.destroy() + loadingTaskRef.current = null + } } }, [pdfUrl, pdfData, pdfIsShared]) @@ -222,11 +554,13 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs const renderPage = useCallback(async () => { if (!pdf || !canvasRef.current) return - // Cancel any ongoing render and wait for it + // Increment render ID to track this render + const thisRenderId = ++renderIdRef.current + + // Cancel any ongoing render if (renderTaskRef.current) { try { renderTaskRef.current.cancel() - await renderTaskRef.current.promise } catch { // Ignore cancellation errors } @@ -235,49 +569,52 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs try { const page = await getPageCached(pdf, currentPage) - const viewport = page.getViewport({ scale }) + + // Check if a newer render was started while we were waiting + if (thisRenderId !== renderIdRef.current) return + + const viewport = page.getViewport({ scale: renderScale }) const pixelRatio = pixelRatioRef.current const requiredWidth = Math.ceil(viewport.width * pixelRatio) const requiredHeight = Math.ceil(viewport.height * pixelRatio) - // Reuse offscreen canvas for double-buffering (avoids GC pressure) - if (!offscreenCanvasRef.current) { - offscreenCanvasRef.current = document.createElement('canvas') - } - const offscreen = offscreenCanvasRef.current + const canvas = canvasRef.current + if (!canvas) return - // Only resize if dimensions changed (resize clears the canvas and resets context) - if (offscreen.width !== requiredWidth || offscreen.height !== requiredHeight) { - offscreen.width = requiredWidth - offscreen.height = requiredHeight - } + // Immediately update CSS dimensions so existing content scales visually + // This prevents blurriness while the high-res render happens + canvas.style.width = `${viewport.width}px` + canvas.style.height = `${viewport.height}px` + + // Create a fresh offscreen canvas for this render to avoid PDF.js conflicts + // when cancelling renders (PDF.js doesn't allow canvas reuse during active renders) + const offscreen = document.createElement('canvas') + offscreen.width = requiredWidth + offscreen.height = requiredHeight const offscreenCtx = offscreen.getContext('2d') if (!offscreenCtx) return - // Clear and set up transform (context state persists, so always reset) - offscreenCtx.setTransform(1, 0, 0, 1, 0, 0) // Reset to identity for clearRect - offscreenCtx.clearRect(0, 0, requiredWidth, requiredHeight) // Clear before reuse - offscreenCtx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0) // Scale for rendering + // Set up transform for high-DPI rendering + offscreenCtx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0) + + // Check again before starting expensive render + if (thisRenderId !== renderIdRef.current) return // Render to offscreen canvas renderTaskRef.current = page.render({ canvasContext: offscreenCtx, viewport: viewport, - canvas: offscreen, }) await renderTaskRef.current.promise - // Only update visible canvas after render completes (atomic swap) - const canvas = canvasRef.current - if (!canvas) return + // Check if this render is still current before blitting + if (thisRenderId !== renderIdRef.current) return - // Only resize visible canvas if dimensions changed (resizing clears content) + // Update canvas buffer dimensions and blit the new content if (canvas.width !== requiredWidth || canvas.height !== requiredHeight) { canvas.width = requiredWidth canvas.height = requiredHeight - canvas.style.width = `${viewport.width}px` - canvas.style.height = `${viewport.height}px` } const context = canvas.getContext('2d') @@ -286,9 +623,15 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs context.drawImage(offscreen, 0, 0) } + // Store for reuse by subsequent same-scale renders + offscreenCanvasRef.current = offscreen + // Fetch text content once and reuse for text layer and search highlights const textContent = await page.getTextContent() + // Final staleness check + if (thisRenderId !== renderIdRef.current) return + // Render text layer for selection await renderTextLayer(page, viewport, textContent) @@ -302,7 +645,30 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs console.error('Render error:', err) } } - }, [pdf, currentPage, scale, searchMatches, getPageCached]) + }, [pdf, currentPage, renderScale, searchMatches, getPageCached]) + + // Center content after render completes (but not after gesture-triggered re-renders) + useEffect(() => { + if (pdf && canvasRef.current) { + // Skip centering if this was a gesture-triggered scale change + if (skipCenterRef.current) { + skipCenterRef.current = false + // Still need to apply transform after canvas resizes + requestAnimationFrame(() => { + requestAnimationFrame(() => { + applyTransform() + }) + }) + return + } + // Wait for canvas to be sized + requestAnimationFrame(() => { + requestAnimationFrame(() => { + centerContent() + }) + }) + } + }, [pdf, renderScale, currentPage, centerContent, applyTransform]) // Get or create the measurement canvas (reused to avoid GC pressure) const getMeasureContext = useCallback((): CanvasRenderingContext2D | null => { @@ -336,13 +702,13 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs const span = document.createElement('span') span.textContent = textItem.str - const tx = textItem.transform[4] * scale - const ty = textItem.transform[5] * scale - const fontSize = Math.sqrt(textItem.transform[0] ** 2 + textItem.transform[1] ** 2) * scale + const tx = textItem.transform[4] * renderScale + const ty = textItem.transform[5] * renderScale + const fontSize = Math.sqrt(textItem.transform[0] ** 2 + textItem.transform[1] ** 2) * renderScale const fontFamily = 'sans-serif' // Get the width from PDF (if available) or estimate from transform - const pdfWidth = (textItem.width || 0) * scale + const pdfWidth = (textItem.width || 0) * renderScale // Measure the text as it will render in the browser ctx.font = `${fontSize}px ${fontFamily}` @@ -404,10 +770,10 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs // Approximate position and width const charWidth = (textItem.width || fontSize * 0.6) / textItem.str.length - const left = (tx + match.startIndex * charWidth) * scale - const top = (viewport.height / scale - ty) * scale - fontSize * scale - const width = (match.endIndex - match.startIndex) * charWidth * scale - const height = fontSize * scale * 1.2 + const left = (tx + match.startIndex * charWidth) * renderScale + const top = (viewport.height / renderScale - ty) * renderScale - fontSize * renderScale + const width = (match.endIndex - match.startIndex) * charWidth * renderScale + const height = fontSize * renderScale * 1.2 // Single cssText assignment instead of 5 individual style properties highlight.style.cssText = `position:absolute;left:${left}px;top:${top}px;width:${width}px;height:${height}px` @@ -442,44 +808,349 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs // Wait a frame for container to be sized await new Promise(resolve => requestAnimationFrame(resolve)) const optimalScale = await calculateFitWidth() - if (optimalScale) setScale(optimalScale) + if (optimalScale) { + // Reset transform - centering will happen via the renderScale change effect + transformRef.current = { x: 0, y: 0, scale: 1 } + setRenderScale(optimalScale) + // Don't call centerContent here - let the effect handle it after render + } } doFitWidth() }, [pdf, fitWidth, calculateFitWidth]) - // Resize handler - always fit to container width (debounced to avoid layout thrashing) + // Resize handler - only adjust scale when fitWidth is enabled useEffect(() => { - if (!pdf || !containerRef.current) return + if (!pdf || !containerRef.current || !fitWidth) return - const resizeObserver = new ResizeObserver(() => { - // Debounce resize handling to avoid recalculating on every animation frame - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current) - } - - resizeTimeoutRef.current = setTimeout(async () => { - const optimalScale = await calculateFitWidth() - if (optimalScale) { - // If fitWidth is enabled, always use optimal scale - // If not, cap the current scale to not exceed container - if (fitWidth) { - setScale(optimalScale) - } else if (scale > optimalScale) { - setScale(optimalScale) - } - } - }, 100) // 100ms debounce + const resizeObserver = new ResizeObserver(async () => { + const optimalScale = await calculateFitWidth() + if (optimalScale) { + transformRef.current = { x: 0, y: 0, scale: 1 } + setRenderScale(optimalScale) + // Centering will happen via the renderScale change effect + } }) resizeObserver.observe(containerRef.current) + return () => resizeObserver.disconnect() + }, [pdf, fitWidth, calculateFitWidth]) + + // Apple-style pinch-to-zoom: Pure transform, no scroll + // All positioning is done via CSS transform on the page wrapper + // Render scale only changes when we need sharper content + const gestureRef = useRef({ + active: false, + pointers: new Map(), + prevMidX: 0, + prevMidY: 0, + prevDist: 0, + // Cached container rect to avoid getBoundingClientRect() on every move + containerRect: null as DOMRect | null, + // Timeout for re-rendering at higher resolution after gesture + renderTimeout: null as ReturnType | null, + }) + + // Apply current transform to DOM + const updateTransform = useCallback(() => { + const wrapper = documentRef.current?.querySelector('.pdf-page-wrapper') as HTMLElement + if (!wrapper) return + const { x, y, scale } = transformRef.current + wrapper.style.transform = `translate(${x}px, ${y}px) scale(${scale})` + }, []) + + // Clamp transform to keep content in reasonable bounds + // Accepts optional cached containerRect to avoid layout thrashing during gestures + const clampTransform = useCallback((cachedRect?: DOMRect | null) => { + const container = documentRef.current + const canvas = canvasRef.current + if (!container || !canvas) return + + const containerRect = cachedRect || container.getBoundingClientRect() + const t = transformRef.current + + // Content size at current scale + const contentWidth = canvas.clientWidth * t.scale + const contentHeight = canvas.clientHeight * t.scale + + // If content is smaller than container, center it + // If content is larger, allow panning but keep content covering the viewport + if (contentWidth <= containerRect.width) { + // Center horizontally + t.x = (containerRect.width - contentWidth) / 2 + } else { + // Content wider than viewport - clamp to keep edges visible + const minX = containerRect.width - contentWidth + const maxX = 0 + t.x = Math.max(minX, Math.min(maxX, t.x)) + } + + if (contentHeight <= containerRect.height) { + // Center vertically (with slight top bias for document feel) + const padding = Math.min(24, (containerRect.height - contentHeight) / 4) + t.y = padding + } else { + // Content taller than viewport - clamp to keep edges visible + const minY = containerRect.height - contentHeight + const maxY = 0 + t.y = Math.max(minY, Math.min(maxY, t.y)) + } + + // Clamp scale + const totalScale = renderScale * t.scale + if (totalScale < 0.25) t.scale = 0.25 / renderScale + if (totalScale > 3.5) t.scale = 3.5 / renderScale + }, [renderScale]) + + // Schedule a re-render at appropriate resolution after gesture ends + const scheduleRender = useCallback(() => { + if (gestureRef.current.renderTimeout) { + clearTimeout(gestureRef.current.renderTimeout) + } + + gestureRef.current.renderTimeout = setTimeout(() => { + const t = transformRef.current + const totalScale = renderScale * t.scale + + // If zoomed significantly, re-render at higher resolution + // Reset transform scale to 1 and update renderScale + if (Math.abs(t.scale - 1) > 0.1) { + // Calculate new render scale (this is what we'll render the canvas at) + const newRenderScale = Math.max(0.25, Math.min(3.5, totalScale)) + + // The canvas will change size: newCanvasSize = oldCanvasSize * (newRenderScale / renderScale) + // To keep the same visual, we need: newTransformScale * newCanvasSize = oldTransformScale * oldCanvasSize + // So: newTransformScale = oldTransformScale * oldCanvasSize / newCanvasSize + // = oldTransformScale * renderScale / newRenderScale + // = totalScale / newRenderScale + const newTransformScale = totalScale / newRenderScale + + // The canvas top-left stays at (t.x, t.y) in screen coords + // But the canvas size changes, so we need to adjust x,y to keep the same content visible + // + // Before: a point at canvas pixel (px, py) appears at screen position (t.x + px * t.scale, t.y + py * t.scale) + // After: the same content is at new canvas pixel (px * newRenderScale / renderScale, py * newRenderScale / renderScale) + // and appears at screen position (newX + newPx * newTransformScale, newY + newPy * newTransformScale) + // + // For the visual to stay the same, pick any point - let's use (0,0) which is simple: + // Before: screen pos = (t.x, t.y) + // After: screen pos = (newX, newY) + // So: newX = t.x, newY = t.y (the top-left corner stays in place!) + // + // But wait - we also need the CENTER to stay in place for a good feel. + // Let's keep the center of the viewport showing the same content. + const container = documentRef.current + const canvas = canvasRef.current + if (container && canvas) { + const containerRect = container.getBoundingClientRect() + const viewCenterX = containerRect.width / 2 + const viewCenterY = containerRect.height / 2 + + // Content point at view center (in current canvas pixels): + // screenPos = t.x + canvasPx * t.scale => canvasPx = (screenPos - t.x) / t.scale + const contentPxX = (viewCenterX - t.x) / t.scale + const contentPxY = (viewCenterY - t.y) / t.scale + + // After re-render, this content is at new canvas pixel: + const sizeRatio = newRenderScale / renderScale + const newContentPxX = contentPxX * sizeRatio + const newContentPxY = contentPxY * sizeRatio + + // We want this new pixel to appear at view center: + // viewCenterX = newX + newContentPxX * newTransformScale + // newX = viewCenterX - newContentPxX * newTransformScale + t.x = viewCenterX - newContentPxX * newTransformScale + t.y = viewCenterY - newContentPxY * newTransformScale + } + + t.scale = newTransformScale + skipCenterRef.current = true // Don't auto-center after this re-render + setRenderScale(newRenderScale) + setFitWidth(false) + } else { + // No re-render needed, but still save state after gesture + saveViewerState() + } + + updateTransform() + }, 150) + }, [renderScale, updateTransform, saveViewerState]) + + useEffect(() => { + const container = documentRef.current + if (!container || !pdf) return + + const getPointerMid = () => { + const pointers = Array.from(gestureRef.current.pointers.values()) + if (pointers.length < 2) return null + return { + x: (pointers[0].x + pointers[1].x) / 2, + y: (pointers[0].y + pointers[1].y) / 2, + dist: Math.hypot(pointers[1].x - pointers[0].x, pointers[1].y - pointers[0].y) + } + } + + const handlePointerDown = (e: PointerEvent) => { + container.setPointerCapture(e.pointerId) + gestureRef.current.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) + + // Cache container rect on gesture start (avoids getBoundingClientRect per move) + if (gestureRef.current.pointers.size === 1) { + gestureRef.current.containerRect = container.getBoundingClientRect() + } + + if (gestureRef.current.pointers.size === 2) { + // Starting pinch gesture + gestureRef.current.active = true + const mid = getPointerMid()! + gestureRef.current.prevMidX = mid.x + gestureRef.current.prevMidY = mid.y + gestureRef.current.prevDist = mid.dist + + // Cancel any pending render + if (gestureRef.current.renderTimeout) { + clearTimeout(gestureRef.current.renderTimeout) + gestureRef.current.renderTimeout = null + } + } + } + + const handlePointerMove = (e: PointerEvent) => { + if (!gestureRef.current.pointers.has(e.pointerId)) return + gestureRef.current.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) + + const g = gestureRef.current + const t = transformRef.current + + if (g.pointers.size === 2 && g.active) { + // Pinch gesture + const mid = getPointerMid()! + + let scaleDiff = g.prevDist > 0 ? mid.dist / g.prevDist : 1 + const panX = mid.x - g.prevMidX + const panY = mid.y - g.prevMidY + + // Clamp scaleDiff to respect zoom limits BEFORE calculating position + // This prevents position drift when at min/max zoom + const currentTotal = renderScale * t.scale + const proposedTotal = currentTotal * scaleDiff + if (proposedTotal > 3.5) { + scaleDiff = 3.5 / currentTotal + } else if (proposedTotal < 0.25) { + scaleDiff = 0.25 / currentTotal + } + + // Get origin relative to container (use cached rect for performance) + const containerRect = g.containerRect! + const originX = g.prevMidX - containerRect.left + const originY = g.prevMidY - containerRect.top + + // Apply scale around origin, then pan + // The key formula: scale around a point, then translate + const newX = (t.x - originX) * scaleDiff + originX + panX + const newY = (t.y - originY) * scaleDiff + originY + panY + const newScale = t.scale * scaleDiff + + t.x = newX + t.y = newY + t.scale = newScale + + clampTransform(containerRect) + updateTransform() + + g.prevMidX = mid.x + g.prevMidY = mid.y + g.prevDist = mid.dist + } else if (g.pointers.size === 1) { + // Single finger pan + const pointer = g.pointers.values().next().value + if (pointer) { + const panX = e.clientX - pointer.x + const panY = e.clientY - pointer.y + + t.x += panX + t.y += panY + + clampTransform(g.containerRect) + updateTransform() + + g.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) + } + } + } + + const handlePointerUp = (e: PointerEvent) => { + gestureRef.current.pointers.delete(e.pointerId) + try { + container.releasePointerCapture(e.pointerId) + } catch { + // Ignore if capture wasn't held + } + + if (gestureRef.current.pointers.size < 2) { + gestureRef.current.active = false + } + + if (gestureRef.current.pointers.size === 0) { + // Clear cached rect when gesture ends + gestureRef.current.containerRect = null + // All fingers lifted - schedule re-render at appropriate resolution + scheduleRender() + } + } + + const handleWheel = (e: WheelEvent) => { + e.preventDefault() + const t = transformRef.current + + if (e.ctrlKey || e.metaKey) { + // Zoom + let scaleDiff = 1 - e.deltaY * 0.01 + + // Clamp scaleDiff to respect zoom limits BEFORE calculating position + const currentTotal = renderScale * t.scale + const proposedTotal = currentTotal * scaleDiff + if (proposedTotal > 3.5) { + scaleDiff = 3.5 / currentTotal + } else if (proposedTotal < 0.25) { + scaleDiff = 0.25 / currentTotal + } + + const containerRect = container.getBoundingClientRect() + const originX = e.clientX - containerRect.left + const originY = e.clientY - containerRect.top + + t.x = (t.x - originX) * scaleDiff + originX + t.y = (t.y - originY) * scaleDiff + originY + t.scale *= scaleDiff + } else { + // Pan + t.x -= e.deltaX + t.y -= e.deltaY + } + + clampTransform() + updateTransform() + scheduleRender() + } + + container.addEventListener('pointerdown', handlePointerDown) + container.addEventListener('pointermove', handlePointerMove) + container.addEventListener('pointerup', handlePointerUp) + container.addEventListener('pointercancel', handlePointerUp) + container.addEventListener('wheel', handleWheel, { passive: false }) + return () => { - resizeObserver.disconnect() - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current) + container.removeEventListener('pointerdown', handlePointerDown) + container.removeEventListener('pointermove', handlePointerMove) + container.removeEventListener('pointerup', handlePointerUp) + container.removeEventListener('pointercancel', handlePointerUp) + container.removeEventListener('wheel', handleWheel) + if (gestureRef.current.renderTimeout) { + clearTimeout(gestureRef.current.renderTimeout) } } - }, [pdf, fitWidth, scale, calculateFitWidth]) + }, [pdf, clampTransform, updateTransform, scheduleRender]) // Search functionality - streaming approach for memory efficiency // Processes pages one at a time and cleans up after each to avoid loading all text content at once @@ -563,7 +1234,7 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs } }, [pdf, searchQuery]) - // Handle search on Enter + // Handle search on Enter/Escape (Cmd+F handled by capture-phase global handler) const handleSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { if (e.shiftKey) { @@ -603,37 +1274,62 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs const setZoomPreset = (value: number) => { setFitWidth(false) - setScale(value) + transformRef.current = { x: 0, y: 0, scale: 1 } + setRenderScale(value) + // Centering will happen via the renderScale change effect setShowZoomPresets(false) } const toggleFitWidth = async () => { setFitWidth(true) const optimalScale = await calculateFitWidth() - if (optimalScale) setScale(optimalScale) + if (optimalScale) { + transformRef.current = { x: 0, y: 0, scale: 1 } + setRenderScale(optimalScale) + // Centering will happen via the renderScale change effect + } setShowZoomPresets(false) } - // Keyboard navigation + // Keyboard navigation - use capture phase to intercept Cmd+F before browser default handling useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Cmd/Ctrl + F to open search + // Cmd/Ctrl + F to toggle search if ((e.metaKey || e.ctrlKey) && e.key === 'f') { - e.preventDefault() + // If search is already open, ALWAYS handle Cmd+F to close it + // (The user opened it via PDF, so we should close it regardless of current focus) + if (showSearchRef.current) { + e.preventDefault() + e.stopPropagation() + setShowSearch(false) + setSearchQuery('') + setSearchMatches([]) + containerRef.current?.focus() // Return focus to PDF container + return + } + + // Search is closed - only open if PDF viewer has focus + if (!containerRef.current?.contains(document.activeElement)) { + return // Let other handlers (like editor) handle this + } + e.preventDefault() // Block Chrome's default find + e.stopPropagation() // Stop event from reaching other handlers setShowSearch(true) setTimeout(() => searchInputRef.current?.focus(), 0) return } - // Don't handle if search input is focused + // Don't handle arrow keys if search input is focused if (document.activeElement === searchInputRef.current) return if (e.key === 'ArrowLeft') goToPrev() if (e.key === 'ArrowRight') goToNext() } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) + // Use capture: true to intercept before browser's default Cmd+F handling + // Note: showSearch is accessed via ref to avoid recreating this listener on every state change + window.addEventListener('keydown', handleKeyDown, true) + return () => window.removeEventListener('keydown', handleKeyDown, true) }, [pageCount]) // Close zoom presets when clicking outside @@ -651,67 +1347,29 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs }, [showZoomPresets]) return ( -
+
{/* Top toolbar */}
- {showSearch ? ( -
- setSearchQuery(e.target.value)} - onKeyDown={handleSearchKeyDown} - autoFocus - /> - {searchMatches.length > 0 && ( - <> - - {currentMatchIndex + 1}/{searchMatches.length} - - - - - )} - -
- ) : ( - - )} +
@@ -746,7 +1404,7 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs onClick={() => setShowZoomPresets(!showZoomPresets)} title="Zoom presets" > - {fitWidth ? 'Fit' : `${Math.round(scale * 100)}%`} + {fitWidth ? 'Fit' : `${Math.round(renderScale * transformRef.current.scale * 100)}%`} {showZoomPresets && ( @@ -761,7 +1419,7 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs {ZOOM_PRESETS.map((preset) => ( + +
+
+ {/* Search dropdown - appears below toolbar */} + {showSearch && ( +
+ setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + autoFocus + /> + + {searchMatches.length > 0 ? `${currentMatchIndex + 1} of ${searchMatches.length}` : 'No results'} + + + + +
+ )} + {/* PDF content */}
{isLoading ? ( @@ -829,10 +1517,26 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs ) : (
- +
+ {/* SyncTeX line indicator - always rendered, visibility controlled by state */} +
)}
From 264a831784fcf7e4e48720ce1b72fbaea9449bd0 Mon Sep 17 00:00:00 2001 From: Adam Weber Date: Sun, 25 Jan 2026 21:30:59 -0800 Subject: [PATCH 5/6] Perf. optimizations --- app/src/App.tsx | 240 ++++++++++++++++++++++++------- app/src/components/PDFViewer.tsx | 14 +- app/src/services/StateStore.ts | 62 ++++++++ 3 files changed, 263 insertions(+), 53 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 294e552..7af0bb0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -12,6 +12,8 @@ const PDFViewer = lazy(() => import('./components/PDFViewer')) const DocumentationModal = lazy(() => import('./components/DocumentationModal')) const ProjectSidebar = lazy(() => import('./components/ProjectSidebar')) const Onboarding = lazy(() => import('./components/Onboarding/Onboarding')) +const ShareModal = lazy(() => import('./components/ShareModal')) +const ShareViewer = lazy(() => import('./components/ShareViewer')) import { useDocument } from './hooks/useDocument' import { useAutoSave } from './hooks/useAutoSave' import { useCompilation } from './hooks/useCompilation' @@ -19,6 +21,7 @@ import { useModals } from './hooks/useModals' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import DocumentManager from './services/DocumentManager' import GitService from './services/GitService' +import ShareService from './services/ShareService' import EventBus from './services/EventBus' import { useEventBus } from './hooks/useEventBus' import './App.css' @@ -50,12 +53,20 @@ const App: React.FC = () => { }) const [triggerGitConnect, setTriggerGitConnect] = useState(false) const [showConflictModal, setShowConflictModal] = useState(false) + const [showShareModal, setShowShareModal] = useState(false) + const [isClosingShareModal, setIsClosingShareModal] = useState(false) + const [viewingShareId, setViewingShareId] = useState(() => { + // Check URL for share link on initial load + const match = window.location.pathname.match(/^\/share\/([a-z0-9]+)$/i) + return match ? match[1] : null + }) const editorPanelRef = useRef(null) const previewPanelRef = useRef(null) // Compilation state from hook const { pdfData, + syncTexData, compileStatus, compileTimeMs, compilerSettings, @@ -73,6 +84,7 @@ const App: React.FC = () => { showDocumentation, isClosingDocumentation, documentationOpenedFromPalette, + documentationDefaultTab, openCommandPalette, closeCommandPalette, openDocumentation, @@ -178,22 +190,31 @@ const App: React.FC = () => { localStorage.setItem('siglum-sidebar-expanded', String(sidebarExpanded)) }, [sidebarExpanded]) - // Subscribe to git status for remote change detection - const hasShownRemoteModal = useRef(false) + // Subscribe to git status - only show conflict modal for actual conflicts + const lastSyncTimeRef = useRef(null) useEffect(() => { - const unsubscribe = GitService.subscribeStatus((status) => { - // Show modal when remote has changes and we haven't shown it yet this session - if (status.behind > 0 && !showConflictModal && !hasShownRemoteModal.current) { - hasShownRemoteModal.current = true + const unsubscribe = GitService.subscribeStatus(async (status) => { + // Only show modal for actual conflicts (same files edited locally and remotely) + if (status.hasConflict && status.conflictingFiles && status.conflictingFiles.length > 0) { setShowConflictModal(true) } - // Reset the flag when we're caught up - if (status.behind === 0) { - hasShownRemoteModal.current = false + + // If sync happened and current file might have changed, reload it + if (status.lastSync && lastSyncTimeRef.current !== status.lastSync && selectedFilePath) { + lastSyncTimeRef.current = status.lastSync + // Reload the current file in case it was updated from remote + try { + const content = await GitService.readFile(selectedFilePath) + if (content !== null && content !== latexCode) { + setLatexCode(content) + } + } catch { + // File might not exist, ignore + } } }) return unsubscribe - }, [showConflictModal]) + }, [selectedFilePath, latexCode]) const handleUseRemote = useCallback(async () => { await GitService.forcePull() @@ -344,15 +365,51 @@ const App: React.FC = () => { openDocumentation(showCommandPalette) }, [openDocumentation, showCommandPalette]) - const handleCreateFromTemplate = useCallback(async (template: string) => { - const DocumentManager = (await import('./services/DocumentManager')).default - await DocumentManager.createNew(template) - // Reload the current document to reflect the new creation - const currentDoc = DocumentManager.getCurrentDocument() - if (currentDoc) { - await loadDocument(currentDoc.id) + const handleOpenIntegrations = useCallback(() => { + openDocumentation(false, 'integrations') + }, [openDocumentation]) + + // Handle citation insertion - add bibtex entry to references.bib + const handleCitationInsert = useCallback(async (citeKey: string, bibtex: string) => { + const bibPath = '/references.bib' + + // Read existing content or start fresh + let existingContent = '' + if (GitService.getStatus().isConnected) { + const content = await GitService.readFile(bibPath) + if (content) { + existingContent = content + } } - }, [loadDocument]) + + // Check if this citation already exists (by citeKey) + if (existingContent.includes(`@`) && existingContent.includes(`{${citeKey},`)) { + // Citation already exists, don't duplicate + return + } + + // Append the new bibtex entry + const newContent = existingContent + ? `${existingContent.trimEnd()}\n\n${bibtex}` + : bibtex + + // Write to GitService filesystem + if (GitService.getStatus().isConnected) { + await GitService.writeFile(bibPath, newContent) + } + + // Also ensure references.bib is in the project file tree + const bibFileExists = projectFiles.some(f => f.name === 'references.bib' || f.path === bibPath) + if (!bibFileExists) { + const newBibFile: FileItem = { + id: `bib-${Date.now()}`, + name: 'references.bib', + type: 'file', + path: bibPath + } + setProjectFiles(prev => [...prev, newBibFile]) + } + }, [projectFiles]) // Loading state with improved design if (isLoading) { @@ -456,7 +513,7 @@ const App: React.FC = () => { isClosing={isClosingDocumentation} onClose={closeDocumentation} onBack={documentationOpenedFromPalette ? backFromDocumentation : undefined} - onCreateFromTemplate={handleCreateFromTemplate} + defaultTab={documentationDefaultTab} /> )} @@ -496,6 +553,7 @@ const App: React.FC = () => { selectedFileId={selectedFileId} triggerGitConnect={triggerGitConnect} onGitConnectTriggered={handleGitConnectTriggered} + onOpenIntegrations={handleOpenIntegrations} /> { compileStatus={compileStatus} compileTimeMs={compileTimeMs} onCompile={compile} + onCitationInsert={handleCitationInsert} />
@@ -519,7 +578,7 @@ const App: React.FC = () => {
Loading preview...
}> - + setShowShareModal(true)} documentId={currentDocument?.id} />
@@ -540,6 +599,7 @@ const App: React.FC = () => { selectedFileId={selectedFileId} triggerGitConnect={triggerGitConnect} onGitConnectTriggered={handleGitConnectTriggered} + onOpenIntegrations={handleOpenIntegrations} /> {sidebarExpanded && ( @@ -560,11 +620,12 @@ const App: React.FC = () => { compileStatus={compileStatus} compileTimeMs={compileTimeMs} onCompile={compile} + onCitationInsert={handleCitationInsert} /> ) : (
Loading preview...
}> - + setShowShareModal(true)} documentId={currentDocument?.id} />
)} @@ -578,7 +639,7 @@ const App: React.FC = () => { isClosing={isClosingDocumentation} onClose={closeDocumentation} onBack={documentationOpenedFromPalette ? backFromDocumentation : undefined} - onCreateFromTemplate={handleCreateFromTemplate} + defaultTab={documentationDefaultTab} /> )} @@ -593,34 +654,117 @@ const App: React.FC = () => { )} - {showConflictModal && ( -
-
-

Remote Changes Detected

-
- {GitService.getConfig()?.repoUrl} - -

- The remote repository has been updated since you started editing. - You can pull the remote changes (your local edits will be lost) or - continue editing (your changes will overwrite remote on next sync). -

-
- - + {showShareModal && ( + + { + setIsClosingShareModal(true) + setTimeout(() => { + setIsClosingShareModal(false) + setShowShareModal(false) + }, 200) + }} + onCreateShare={() => { + if (!pdfData) { + console.warn('No PDF data to share') + return null + } + const name = currentDocument?.title || 'Untitled' + const share = ShareService.createShare(name, pdfData.buffer) + // Copy link to clipboard + const url = ShareService.getShareUrl(share.id) + navigator.clipboard.writeText(url) + return share.id + }} + onViewShare={(shareId) => { + // Mark as viewed and open viewer + ShareService.markAsViewed(shareId) + setShowShareModal(false) + setViewingShareId(shareId) + }} + /> + + )} + + {viewingShareId && ( + + { + setViewingShareId(null) + // Clear the /share/... URL path + if (window.location.pathname.startsWith('/share/')) { + window.history.replaceState({}, '', '/') + } + }} + /> + + )} + + {showConflictModal && (() => { + const config = GitService.getConfig() + const status = GitService.getStatus() + const repoPath = config?.repoUrl?.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '') + const currentBranch = config?.branch + const isSameBranch = status.conflictSource === 'same-branch' + const otherBranch = !isSameBranch ? status.conflictSource : undefined + const canShowCompare = config?.provider === 'github' && repoPath && otherBranch && currentBranch + + return ( +
+
+

Conflicting Changes

+ {repoPath && ( + + {repoPath} + + )} + {status.conflictingFiles && ( +
    + {status.conflictingFiles.map(file => ( +
  • + {file.path} + {file.linesChanged !== undefined && ( + + {file.linesChanged} {file.linesChanged === 1 ? 'line' : 'lines'} different + + )} +
  • + ))} +
+ )} +

+ This version and the GitHub version are different. +

+ {canShowCompare && ( + + Compare on GitHub → + + )} +
+ + +
-
- )} + ) + })()}
) } diff --git a/app/src/components/PDFViewer.tsx b/app/src/components/PDFViewer.tsx index 5078e73..2b391e7 100644 --- a/app/src/components/PDFViewer.tsx +++ b/app/src/components/PDFViewer.tsx @@ -127,6 +127,8 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs const pixelRatioRef = useRef(window.devicePixelRatio || 1) // Cache pixel ratio (rarely changes) const stateRestoredRef = useRef(false) // Track if state has been restored for this document const saveStateTimeoutRef = useRef | null>(null) // Debounce state saves + const documentIdRef = useRef(documentId) // Ref for stable callback + documentIdRef.current = documentId // Parse SyncTeX data when it changes const parsedSyncTex = useMemo(() => { @@ -191,8 +193,10 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs }, []) // Save PDF viewer state to StateStore (debounced) + // Uses refs to avoid recreating callback on every state change const saveViewerState = useCallback(() => { - if (!documentId) return + const docId = documentIdRef.current + if (!docId) return // Clear any pending save if (saveStateTimeoutRef.current) { @@ -203,15 +207,15 @@ const PDFViewer: React.FC = React.memo(({ pdfUrl, pdfData, pdfIs saveStateTimeoutRef.current = setTimeout(() => { const t = transformRef.current StateStore.setPdfViewerState( - documentId, - renderScale, + docId, + renderScaleRef.current, t.x, t.y, t.scale, - currentPage + currentPageRef.current ) }, 300) - }, [documentId, renderScale, currentPage]) + }, []) // Stable callback - uses refs for changing values // Restore PDF viewer state from StateStore when document changes useEffect(() => { diff --git a/app/src/services/StateStore.ts b/app/src/services/StateStore.ts index 7096df9..8326bab 100644 --- a/app/src/services/StateStore.ts +++ b/app/src/services/StateStore.ts @@ -31,6 +31,8 @@ const APP_KEYS = { RECENT_DOCUMENT_IDS: 'recent_document_ids', PROJECT_FILES: 'project_files', COMPILER_SETTINGS: 'compiler_settings', + SHOW_WORD_COUNT: 'show_word_count', + SHOW_DEBUG_INFO: 'show_debug_info', } as const export interface DocumentState { @@ -39,6 +41,12 @@ export interface DocumentState { cursorEnd: number scrollTop: number scrollLeft: number + // PDF viewer state + pdfRenderScale?: number + pdfTransformX?: number + pdfTransformY?: number + pdfTransformScale?: number + pdfCurrentPage?: number updatedAt: number } @@ -258,6 +266,22 @@ class StateStore { this.queueWrite(APP_STORE, APP_KEYS.COMPILER_SETTINGS, settings) } + async getShowWordCount(): Promise { + return (await this.getAppValue(APP_KEYS.SHOW_WORD_COUNT)) ?? false + } + + setShowWordCount(show: boolean): void { + this.queueWrite(APP_STORE, APP_KEYS.SHOW_WORD_COUNT, show) + } + + async getShowDebugInfo(): Promise { + return (await this.getAppValue(APP_KEYS.SHOW_DEBUG_INFO)) ?? false + } + + setShowDebugInfo(show: boolean): void { + this.queueWrite(APP_STORE, APP_KEYS.SHOW_DEBUG_INFO, show) + } + private async getAppValue(key: string): Promise { const db = await this.dbPromise @@ -308,6 +332,11 @@ class StateStore { cursorEnd: end, scrollTop: existing?.scrollTop ?? 0, scrollLeft: existing?.scrollLeft ?? 0, + pdfRenderScale: existing?.pdfRenderScale, + pdfTransformX: existing?.pdfTransformX, + pdfTransformY: existing?.pdfTransformY, + pdfTransformScale: existing?.pdfTransformScale, + pdfCurrentPage: existing?.pdfCurrentPage, updatedAt: Date.now() } // Update cache immediately with LRU eviction @@ -324,6 +353,39 @@ class StateStore { cursorEnd: existing?.cursorEnd ?? 0, scrollTop, scrollLeft, + pdfRenderScale: existing?.pdfRenderScale, + pdfTransformX: existing?.pdfTransformX, + pdfTransformY: existing?.pdfTransformY, + pdfTransformScale: existing?.pdfTransformScale, + pdfCurrentPage: existing?.pdfCurrentPage, + updatedAt: Date.now() + } + // Update cache immediately with LRU eviction + this.touchDocCache(documentId, state) + this.queueWrite(DOC_STATE_STORE, documentId, state) + } + + setPdfViewerState( + documentId: string, + renderScale: number, + transformX: number, + transformY: number, + transformScale: number, + currentPage: number + ): void { + // Use in-memory cache for synchronous access (critical for beforeunload) + const existing = this.docStateCache.get(documentId) + const state: DocumentState = { + documentId, + cursorStart: existing?.cursorStart ?? 0, + cursorEnd: existing?.cursorEnd ?? 0, + scrollTop: existing?.scrollTop ?? 0, + scrollLeft: existing?.scrollLeft ?? 0, + pdfRenderScale: renderScale, + pdfTransformX: transformX, + pdfTransformY: transformY, + pdfTransformScale: transformScale, + pdfCurrentPage: currentPage, updatedAt: Date.now() } // Update cache immediately with LRU eviction From 7ba5989292e61f2dd8772f77a27f5006c2625f4c Mon Sep 17 00:00:00 2001 From: Adam Weber Date: Mon, 26 Jan 2026 09:20:33 -0800 Subject: [PATCH 6/6] Add log parser and word counter --- app/src/hooks/useWordCount.ts | 64 +++ app/src/utils/texLogParser.ts | 481 +++++++++++++++++++++ app/src/utils/texcount.ts | 776 ++++++++++++++++++++++++++++++++++ 3 files changed, 1321 insertions(+) create mode 100644 app/src/hooks/useWordCount.ts create mode 100644 app/src/utils/texLogParser.ts create mode 100644 app/src/utils/texcount.ts diff --git a/app/src/hooks/useWordCount.ts b/app/src/hooks/useWordCount.ts new file mode 100644 index 0000000..1d6503c --- /dev/null +++ b/app/src/hooks/useWordCount.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useRef } from 'react' +import { countWords } from '../utils/texcount' + +interface WordCountState { + count: number | null + isLoading: boolean + error: string | null +} + +// Debounce for word count - no need for instant updates +const DEBOUNCE_MS = 500 + +export function useWordCount( + latexCode: string, + enabled: boolean +): WordCountState { + const [state, setState] = useState({ + count: null, + isLoading: false, + error: null, + }) + + const lastCodeRef = useRef('') + const debounceRef = useRef(null) + + // Count words when code changes (debounced) + useEffect(() => { + if (!enabled) return + + // Skip if code hasn't changed + if (lastCodeRef.current === latexCode) return + lastCodeRef.current = latexCode + + // Clear pending debounce + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + + // Debounce - wait for user to stop typing + debounceRef.current = window.setTimeout(() => { + if (!latexCode.trim()) { + setState({ count: 0, isLoading: false, error: null }) + return + } + + try { + // Count words using TeXcount-based algorithm + const count = countWords(latexCode) + setState({ count, isLoading: false, error: null }) + } catch (err) { + console.warn('[WordCount] Count failed:', err) + // Keep previous count on error + } + }, DEBOUNCE_MS) + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current) + } + } + }, [latexCode, enabled]) + + return state +} diff --git a/app/src/utils/texLogParser.ts b/app/src/utils/texLogParser.ts new file mode 100644 index 0000000..14a389d --- /dev/null +++ b/app/src/utils/texLogParser.ts @@ -0,0 +1,481 @@ +/** + * TeX Log Parser + * + * Parses TeX/LaTeX compiler output to extract meaningful error messages, + * warnings, and diagnostics. Inspired by LaTeX-Workshop's log parser. + * + * Optimized for incremental parsing - O(n) instead of O(n²). + */ + +export type DiagnosticType = 'error' | 'warning' | 'info' | 'typesetting' + +export interface TexDiagnostic { + type: DiagnosticType + message: string + line?: number + file?: string + context?: string +} + +export interface ParsedLog { + diagnostics: TexDiagnostic[] +} + +// Pre-compiled regex patterns (created once, reused) +const PATTERNS = { + error: /^(?:(.+?):(\d+):?\s*)?!\s*(?:(.+?)\s+[Ee]rror:\s*)?(.+)$/, + errorLine: /^l\.(\d+)\s+(.*)$/, + errorContext: /^\s{15,}(.*)$/, + latexWarning: /^((?:Class|Package|Module)\s+\S+|LaTeX(?:\s+\S+)?)\s+Warning:\s*(.+?)(?:\s+on(?:\s+input)?\s+line\s+(\d+))?\.?$/, + overfullBox: /^(Overfull\s+\\[hv]box\s+\([^)]+\))\s+(?:in\s+paragraph\s+at\s+lines?\s+(\d+)(?:--(\d+))?|detected\s+at\s+line\s+(\d+)|has\s+occurred\s+while\s+\\output\s+is\s+active)/, + underfullBox: /^(Underfull\s+\\[hv]box\s+\([^)]+\))\s+(?:in\s+paragraph\s+at\s+lines?\s+(\d+)(?:--(\d+))?|detected\s+at\s+line\s+(\d+)|has\s+occurred\s+while\s+\\output\s+is\s+active)/, + missingChar: /^\s*(Missing\s+character:\s*.+?!)/, + undefinedRef: /^LaTeX\s+Warning:\s+(Reference|Citation)\s+`([^']+)'\s+on\s+page\s+\d+\s+undefined\s+on\s+input\s+line\s+(\d+)/, + fileOpen: /^\(([^()]+\.[a-z]{2,4})/i, + emergencyStop: /^!\s+Emergency\s+stop/, + fatalError: /^!\s+==>?\s+Fatal\s+error/i, +} as const + +// Fast prefix checks - avoid regex for common cases +const IGNORE_PREFIXES = [ + 'This is ', + 'Output written', + 'Transcript written', + '***', + 'entering extended', + 'restricted \\write18', + '%&-line', + 'Document Class:', + 'File:', + 'Package:', + 'Tab ', + 'Idle timer', + 'Compiler ', + 'Auto-unload', + 'Format ', + 'Fetching ', + 'CTAN:', + 'Bundle', + 'Loading', + 'Cache', + 'Deferred resolve', + 'Range coalescing', + 'Fetched coalesced', + 'Stored file', + 'Loaded file', + 'Retry #', + 'Initializing WASM', + 'WASM ready', + 'VFS:', + 'Mounted ', + 'Registered ', + 'Rewrote pdftex', + 'Using custom', + 'Total time:', + 'Generating format', + 'Compilation successful', + 'pdfTeX', +] as const + +// Patterns that need regex (less common) +const IGNORE_PATTERNS_REGEX = [ + /^\s*\[\d+\]/, // Page numbers [1] [2] + /^\s*$/, // Empty lines + /^[()]+$/, // Just parens + /program exited.*keepRuntimeAlive/i, +] as const + +/** + * Fast ignore check - prefix matching first, then regex fallback + */ +function shouldIgnore(line: string): boolean { + // Check single-char cases first + if (line.length === 0 || line === '(' || line === ')') return true + + // Prefix matching is O(1) per prefix vs O(n) for regex + const lineLower = line.slice(0, 30).toLowerCase() // Only check first 30 chars + for (let i = 0; i < IGNORE_PREFIXES.length; i++) { + const prefix = IGNORE_PREFIXES[i] + if (line.length >= prefix.length) { + // Case-insensitive prefix check + if (lineLower.startsWith(prefix.toLowerCase())) return true + } + } + + // Fallback to regex for complex patterns + for (let i = 0; i < IGNORE_PATTERNS_REGEX.length; i++) { + if (IGNORE_PATTERNS_REGEX[i].test(line)) return true + } + + return false +} + +/** + * Count occurrences of char in string (no array allocation) + */ +function countChar(str: string, char: string): number { + let count = 0 + for (let i = 0; i < str.length; i++) { + if (str[i] === char) count++ + } + return count +} + +interface ParserState { + currentError: Partial | null + expectingErrorLine: boolean + expectingContext: boolean + fileStack: string[] + contextParts: string[] // Collect context parts, join once at end +} + +function createParserState(): ParserState { + return { + currentError: null, + expectingErrorLine: false, + expectingContext: false, + fileStack: [], + contextParts: [], + } +} + +/** + * Finalize current error and add to diagnostics + */ +function finalizeError(state: ParserState, diagnostics: TexDiagnostic[]): void { + if (state.currentError?.message) { + if (state.contextParts.length > 0) { + state.currentError.context = state.contextParts.join('') + state.contextParts = [] + } + diagnostics.push(state.currentError as TexDiagnostic) + } + state.currentError = null + state.expectingErrorLine = false + state.expectingContext = false +} + +/** + * Parse a single line incrementally + */ +function parseLine( + line: string, + state: ParserState, + diagnostics: TexDiagnostic[] +): void { + // Track file stack + const fileOpenMatch = line.match(PATTERNS.fileOpen) + if (fileOpenMatch) { + state.fileStack.push(fileOpenMatch[1]) + } + + // Count closing parens without creating array + if (line.includes(')') && state.fileStack.length > 0) { + const closes = countChar(line, ')') + for (let i = 0; i < closes && state.fileStack.length > 0; i++) { + state.fileStack.pop() + } + } + + // Fast ignore check + if (shouldIgnore(line)) return + + const currentFile = state.fileStack[state.fileStack.length - 1] + + // Handle error line context + if (state.expectingErrorLine) { + const errorLineMatch = line.match(PATTERNS.errorLine) + if (errorLineMatch) { + if (state.currentError) { + state.currentError.line = parseInt(errorLineMatch[1], 10) + state.contextParts.push(errorLineMatch[2]) + } + state.expectingErrorLine = false + state.expectingContext = true + return + } + } + + // Handle context continuation + if (state.expectingContext) { + const contextMatch = line.match(PATTERNS.errorContext) + if (contextMatch && state.currentError) { + state.contextParts.push(contextMatch[1]) + return + } + // Context ended + finalizeError(state, diagnostics) + } + + // Check for errors (must start with !) + if (line[0] === '!') { + finalizeError(state, diagnostics) + + const errorMatch = line.match(PATTERNS.error) + if (errorMatch) { + const errorType = errorMatch[3] + const errorMsg = errorMatch[4] + + state.currentError = { + type: 'error', + message: errorType ? `${errorType}: ${errorMsg}` : errorMsg, + file: errorMatch[1] || currentFile, + line: errorMatch[2] ? parseInt(errorMatch[2], 10) : undefined, + } + state.expectingErrorLine = true + return + } + + // Check emergency stop / fatal + if (PATTERNS.emergencyStop.test(line)) { + diagnostics.push({ + type: 'error', + message: 'Emergency stop - compilation aborted', + file: currentFile, + }) + return + } + if (PATTERNS.fatalError.test(line)) { + diagnostics.push({ + type: 'error', + message: 'Fatal error occurred', + file: currentFile, + }) + return + } + return + } + + // Only check warning patterns if line starts with expected prefixes + const firstWord = line.slice(0, 10) + + // Undefined reference/citation (starts with "LaTeX") + if (firstWord.startsWith('LaTeX')) { + const undefRefMatch = line.match(PATTERNS.undefinedRef) + if (undefRefMatch) { + diagnostics.push({ + type: 'warning', + message: `Undefined ${undefRefMatch[1].toLowerCase()}: '${undefRefMatch[2]}'`, + line: parseInt(undefRefMatch[3], 10), + file: currentFile, + }) + return + } + } + + // LaTeX/Package warnings + if (firstWord.startsWith('LaTeX') || firstWord.startsWith('Class') || + firstWord.startsWith('Package') || firstWord.startsWith('Module')) { + const warningMatch = line.match(PATTERNS.latexWarning) + if (warningMatch) { + diagnostics.push({ + type: 'warning', + message: `${warningMatch[1]}: ${warningMatch[2]}`, + line: warningMatch[3] ? parseInt(warningMatch[3], 10) : undefined, + file: currentFile, + }) + return + } + } + + // Box warnings (start with "Overfull" or "Underfull") + if (firstWord.startsWith('Overful')) { + const overfullMatch = line.match(PATTERNS.overfullBox) + if (overfullMatch) { + const lineNum = overfullMatch[2] || overfullMatch[4] + diagnostics.push({ + type: 'typesetting', + message: overfullMatch[1], + line: lineNum ? parseInt(lineNum, 10) : undefined, + file: currentFile, + }) + return + } + } + + if (firstWord.startsWith('Underfu')) { + const underfullMatch = line.match(PATTERNS.underfullBox) + if (underfullMatch) { + const lineNum = underfullMatch[2] || underfullMatch[4] + diagnostics.push({ + type: 'typesetting', + message: underfullMatch[1], + line: lineNum ? parseInt(lineNum, 10) : undefined, + file: currentFile, + }) + return + } + } + + // Missing character (starts with whitespace then "Missing") + if (line.length > 2 && line.trimStart().startsWith('Missing')) { + const missingCharMatch = line.match(PATTERNS.missingChar) + if (missingCharMatch) { + diagnostics.push({ + type: 'warning', + message: missingCharMatch[1], + file: currentFile, + }) + } + } +} + +/** + * Parse TeX log output into structured diagnostics + */ +export function parseTexLog(log: string): ParsedLog { + const lines = log.split('\n') + const diagnostics: TexDiagnostic[] = [] + const state = createParserState() + + for (let i = 0; i < lines.length; i++) { + parseLine(lines[i], state, diagnostics) + } + + // Finalize any pending error + finalizeError(state, diagnostics) + + // Deduplicate using object keys (faster than Set for small n) + const seen: Record = {} + const deduped: TexDiagnostic[] = [] + for (let i = 0; i < diagnostics.length; i++) { + const d = diagnostics[i] + const key = `${d.type}:${d.message}:${d.line ?? ''}` + if (!seen[key]) { + seen[key] = true + deduped.push(d) + } + } + + return { diagnostics: deduped } +} + +/** + * Format diagnostics for display + */ +export function formatDiagnostics(diagnostics: TexDiagnostic[]): string { + if (diagnostics.length === 0) return '' + + const parts: string[] = [] + let errors = 0, warnings = 0, typesetting = 0 + + // Single pass categorization and formatting + for (let i = 0; i < diagnostics.length; i++) { + const d = diagnostics[i] + if (d.type === 'error') { + errors++ + parts.push(formatSingleDiagnostic(d)) + } + } + + for (let i = 0; i < diagnostics.length; i++) { + const d = diagnostics[i] + if (d.type === 'warning') { + warnings++ + parts.push(formatSingleDiagnostic(d)) + } + } + + // Limit typesetting warnings + for (let i = 0; i < diagnostics.length && typesetting < 3; i++) { + const d = diagnostics[i] + if (d.type === 'typesetting') { + typesetting++ + parts.push(formatSingleDiagnostic(d)) + } + } + + const totalTypesetting = diagnostics.filter(d => d.type === 'typesetting').length + if (totalTypesetting > 3) { + parts.push(` ... and ${totalTypesetting - 3} more typesetting warnings`) + } + + return parts.join('\n') +} + +function formatSingleDiagnostic(d: TexDiagnostic): string { + const prefix = d.type === 'error' ? 'Error' : d.type === 'warning' ? 'Warning' : 'Info' + const lineInfo = d.line ? ` (line ${d.line})` : '' + return d.context + ? `${prefix}: ${d.message}${lineInfo}\n ${d.context.trim()}` + : `${prefix}: ${d.message}${lineInfo}` +} + +/** + * Streaming log parser - truly incremental O(n) + * Parses each line once as it arrives + */ +export class StreamingLogParser { + private state: ParserState = createParserState() + private diagnostics: TexDiagnostic[] = [] + private seenKeys: Record = {} + private callbacks: Set<(diagnostics: TexDiagnostic[]) => void> = new Set() + private lineCount = 0 + + // Memory limit: max lines to keep in state + private static readonly MAX_FILE_STACK = 50 + + addLine(line: string): void { + this.lineCount++ + + // Strip [TeX] prefix if present + const cleanLine = line.startsWith('[TeX]') + ? line.slice(5).trimStart() + : line + + const prevCount = this.diagnostics.length + parseLine(cleanLine, this.state, this.diagnostics) + + // Deduplicate new diagnostics + if (this.diagnostics.length > prevCount) { + const newDiags: TexDiagnostic[] = [] + for (let i = prevCount; i < this.diagnostics.length; i++) { + const d = this.diagnostics[i] + const key = `${d.type}:${d.message}:${d.line ?? ''}` + if (!this.seenKeys[key]) { + this.seenKeys[key] = true + newDiags.push(d) + } + } + // Replace with deduped + this.diagnostics.length = prevCount + this.diagnostics.push(...newDiags) + + if (newDiags.length > 0) { + this.notifyCallbacks() + } + } + + // Prevent file stack from growing unbounded + if (this.state.fileStack.length > StreamingLogParser.MAX_FILE_STACK) { + this.state.fileStack = this.state.fileStack.slice(-StreamingLogParser.MAX_FILE_STACK) + } + } + + onDiagnostics(callback: (diagnostics: TexDiagnostic[]) => void): () => void { + this.callbacks.add(callback) + callback(this.diagnostics) + return () => this.callbacks.delete(callback) + } + + getDiagnostics(): TexDiagnostic[] { + return this.diagnostics + } + + getFormatted(): string { + return formatDiagnostics(this.diagnostics) + } + + clear(): void { + this.state = createParserState() + this.diagnostics = [] + this.seenKeys = {} + this.lineCount = 0 + this.notifyCallbacks() + } + + private notifyCallbacks(): void { + const diags = this.diagnostics + this.callbacks.forEach(cb => cb(diags)) + } +} diff --git a/app/src/utils/texcount.ts b/app/src/utils/texcount.ts new file mode 100644 index 0000000..f485980 --- /dev/null +++ b/app/src/utils/texcount.ts @@ -0,0 +1,776 @@ +/** + * TeXcount - LaTeX Word Counter (Optimized) + * + * TypeScript port of TeXcount (https://app.uio.no/ifi/texcount/) + * Original Perl script by Einar Andreas Rødland + * + * Optimized for CPU and memory efficiency: + * - Single-pass streaming parser (no tokenization array) + * - Numeric enums for fast comparisons + * - Char code comparisons instead of regex in hot paths + * - Minimal string allocations + * - Inline word counting (no recursive sub-counters) + */ + +// ============================================================================ +// Types - Use numeric enums for performance +// ============================================================================ + +/** Parser states (numeric for fast comparison) */ +const enum State { + PREAMBLE = 0, + TEXT = 1, + HEADER = 2, + CAPTION = 3, + SKIP = 4, // Skip content (math, verbatim, etc.) +} + +/** Macro rules (numeric) */ +const enum Rule { + IGNORE = 0, + COUNT = 1, + COUNT_HEADER = 2, + COUNT_CAPTION = 3, + SKIP_ENV = 4, +} + +/** Counter indices for different word categories */ +export interface WordCounts { + textWords: number + headerWords: number + captionWords: number + inlineMath: number + displayMath: number + headers: number + floats: number + mathInlines: number + mathDisplays: number + files: number +} + +/** Options for the counter */ +export interface TeXCountOptions { + countHeaders?: boolean + countCaptions?: boolean + countInlineMath?: boolean + countDisplayMath?: boolean +} + +// ============================================================================ +// Character classification (avoid regex in hot paths) +// ============================================================================ + +// Character codes for fast comparison +const CHAR_BACKSLASH = 92 // \ +const CHAR_PERCENT = 37 // % +const CHAR_DOLLAR = 36 // $ +const CHAR_OPEN_BRACE = 123 // { +const CHAR_CLOSE_BRACE = 125 // } +const CHAR_OPEN_BRACKET = 91 // [ +const CHAR_CLOSE_BRACKET = 93 // ] +const CHAR_NEWLINE = 10 // \n +const CHAR_CR = 13 // \r +const CHAR_SPACE = 32 // space +const CHAR_TAB = 9 // \t +const CHAR_STAR = 42 // * +const CHAR_AT = 64 // @ +const CHAR_UNDERSCORE = 95 // _ +const CHAR_CARET = 94 // ^ +const CHAR_AMPERSAND = 38 // & +const CHAR_HASH = 35 // # +const CHAR_TILDE = 126 // ~ + +/** Check if char is a letter (a-z, A-Z) */ +function isLetter(c: number): boolean { + return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) +} + +/** Check if char is alphanumeric or @ (for command names) */ +function isCommandChar(c: number): boolean { + return isLetter(c) || c === CHAR_AT +} + +/** Check if char is a word character (letter, digit, apostrophe, hyphen, or unicode letter) */ +function isWordChar(c: number): boolean { + // ASCII letters + if ((c >= 65 && c <= 90) || (c >= 97 && c <= 122)) return true + // Digits + if (c >= 48 && c <= 57) return true + // Apostrophe, hyphen + if (c === 39 || c === 45) return true + // Extended Latin and other Unicode letters (common accented chars) + if (c >= 192 && c <= 687) return true // Latin Extended + if (c >= 880 && c <= 1023) return true // Greek + return false +} + +/** Check if char starts a word (letter or unicode letter, not digit/punctuation) */ +function isWordStart(c: number): boolean { + if ((c >= 65 && c <= 90) || (c >= 97 && c <= 122)) return true + if (c >= 192 && c <= 687) return true + if (c >= 880 && c <= 1023) return true + return false +} + +/** Check if char is whitespace */ +function isWhitespace(c: number): boolean { + return c === CHAR_SPACE || c === CHAR_TAB || c === CHAR_NEWLINE || c === CHAR_CR +} + +// ============================================================================ +// Macro and Environment Rules (compact representation) +// ============================================================================ + +// Macro rules: [rule, argPattern] +// argPattern: string where '[' = optional, '{' = required, '*' = optional star +type MacroEntry = [Rule, string] + +const MACROS: Record = { + // Sectioning - count as headers + 'part': [Rule.COUNT_HEADER, '*[]{}'], + 'chapter': [Rule.COUNT_HEADER, '*[]{}'], + 'section': [Rule.COUNT_HEADER, '*[]{}'], + 'subsection': [Rule.COUNT_HEADER, '*[]{}'], + 'subsubsection': [Rule.COUNT_HEADER, '*[]{}'], + 'paragraph': [Rule.COUNT_HEADER, '*[]{}'], + 'subparagraph': [Rule.COUNT_HEADER, '*[]{}'], + + // Caption + 'caption': [Rule.COUNT_CAPTION, '[]{}'], + 'captionof': [Rule.COUNT_CAPTION, '{}[]{}'], + + // Text formatting - count content + 'textbf': [Rule.COUNT, '{}'], + 'textit': [Rule.COUNT, '{}'], + 'textsl': [Rule.COUNT, '{}'], + 'textsc': [Rule.COUNT, '{}'], + 'textsf': [Rule.COUNT, '{}'], + 'texttt': [Rule.COUNT, '{}'], + 'textrm': [Rule.COUNT, '{}'], + 'emph': [Rule.COUNT, '{}'], + 'underline': [Rule.COUNT, '{}'], + 'mbox': [Rule.COUNT, '{}'], + 'fbox': [Rule.COUNT, '{}'], + 'text': [Rule.COUNT, '{}'], + 'intertext': [Rule.COUNT, '{}'], + 'footnote': [Rule.COUNT, '[]{}'], + 'footnotetext': [Rule.COUNT, '[]{}'], + 'title': [Rule.COUNT, '[]{}'], + 'author': [Rule.COUNT, '[]{}'], + 'href': [Rule.COUNT, '{}{}'], + 'hyperref': [Rule.COUNT, '[]{}'], + + // Ignore + 'documentclass': [Rule.IGNORE, '[]{}'], + 'usepackage': [Rule.IGNORE, '[]{}'], + 'RequirePackage': [Rule.IGNORE, '[]{}'], + 'newcommand': [Rule.IGNORE, '{}[][]{}'], + 'renewcommand': [Rule.IGNORE, '{}[][]{}'], + 'providecommand': [Rule.IGNORE, '{}[][]{}'], + 'DeclareRobustCommand': [Rule.IGNORE, '{}[][]{}'], + 'newenvironment': [Rule.IGNORE, '{}[][]{}{}'], + 'renewenvironment': [Rule.IGNORE, '{}[][]{}{}'], + 'setcounter': [Rule.IGNORE, '{}{}'], + 'addtocounter': [Rule.IGNORE, '{}{}'], + 'setlength': [Rule.IGNORE, '{}{}'], + 'addtolength': [Rule.IGNORE, '{}{}'], + 'ref': [Rule.IGNORE, '{}'], + 'eqref': [Rule.IGNORE, '{}'], + 'pageref': [Rule.IGNORE, '{}'], + 'cite': [Rule.IGNORE, '[][]{}'], + 'citep': [Rule.IGNORE, '[][]{}'], + 'citet': [Rule.IGNORE, '[][]{}'], + 'citeauthor': [Rule.IGNORE, '[]{}'], + 'citeyear': [Rule.IGNORE, '[]{}'], + 'nocite': [Rule.IGNORE, '{}'], + 'label': [Rule.IGNORE, '{}'], + 'bibliography': [Rule.IGNORE, '{}'], + 'bibliographystyle': [Rule.IGNORE, '{}'], + 'input': [Rule.IGNORE, '{}'], + 'include': [Rule.IGNORE, '{}'], + 'includegraphics': [Rule.IGNORE, '[]{}'], + 'graphicspath': [Rule.IGNORE, '{}'], + 'vspace': [Rule.IGNORE, '*{}'], + 'hspace': [Rule.IGNORE, '*{}'], + 'footnotemark': [Rule.IGNORE, '[]'], + 'date': [Rule.IGNORE, '{}'], + 'thanks': [Rule.IGNORE, '{}'], + 'url': [Rule.IGNORE, '{}'], + 'pagebreak': [Rule.IGNORE, '[]'], + 'linebreak': [Rule.IGNORE, '[]'], + 'printbibliography': [Rule.IGNORE, '[]'], +} + +// Environments that skip content entirely +const SKIP_ENVS = new Set([ + 'equation', 'equation*', 'align', 'align*', 'alignat', 'alignat*', + 'gather', 'gather*', 'multline', 'multline*', 'eqnarray', 'eqnarray*', + 'displaymath', 'math', 'verbatim', 'verbatim*', 'lstlisting', 'minted', + 'alltt', 'thebibliography', 'tikzpicture', 'pgfpicture', 'array', +]) + +// Environments that are floats +const FLOAT_ENVS = new Set(['figure', 'figure*', 'table', 'table*']) + +// Math environments (for counting) +const MATH_ENVS = new Set([ + 'equation', 'equation*', 'align', 'align*', 'alignat', 'alignat*', + 'gather', 'gather*', 'multline', 'multline*', 'eqnarray', 'eqnarray*', + 'displaymath', 'math', +]) + +// ============================================================================ +// Streaming Parser +// ============================================================================ + +/** + * Optimized single-pass LaTeX word counter + */ +class TeXCounterOptimized { + private src: string = '' + private len: number = 0 + private pos: number = 0 + private state: State = State.PREAMBLE + private stateStack: State[] = [] + + // Counts + private textWords: number = 0 + private headerWords: number = 0 + private captionWords: number = 0 + private mathInlines: number = 0 + private mathDisplays: number = 0 + private headers: number = 0 + private floats: number = 0 + + // Options + private countHeaders: boolean + private countCaptions: boolean + private countInlineMath: boolean + private countDisplayMath: boolean + + constructor(options: TeXCountOptions = {}) { + this.countHeaders = options.countHeaders !== false + this.countCaptions = options.countCaptions !== false + this.countInlineMath = options.countInlineMath === true + this.countDisplayMath = options.countDisplayMath === true + } + + count(content: string): WordCounts { + // Reset + this.src = content + this.len = content.length + this.pos = 0 + this.state = State.PREAMBLE + this.stateStack.length = 0 + this.textWords = 0 + this.headerWords = 0 + this.captionWords = 0 + this.mathInlines = 0 + this.mathDisplays = 0 + this.headers = 0 + this.floats = 0 + + this.parse() + + return { + textWords: this.textWords, + headerWords: this.headerWords, + captionWords: this.captionWords, + inlineMath: this.countInlineMath ? this.mathInlines : 0, + displayMath: this.countDisplayMath ? this.mathDisplays : 0, + headers: this.headers, + floats: this.floats, + mathInlines: this.mathInlines, + mathDisplays: this.mathDisplays, + files: 0, + } + } + + getTotalWords(): number { + let total = this.textWords + if (this.countHeaders) total += this.headerWords + if (this.countCaptions) total += this.captionWords + if (this.countInlineMath) total += this.mathInlines + if (this.countDisplayMath) total += this.mathDisplays + return total + } + + private parse(): void { + while (this.pos < this.len) { + const c = this.src.charCodeAt(this.pos) + + // Comment - skip to end of line + if (c === CHAR_PERCENT) { + this.skipComment() + continue + } + + // Command + if (c === CHAR_BACKSLASH) { + this.handleCommand() + continue + } + + // Inline math $ + if (c === CHAR_DOLLAR) { + this.handleDollarMath() + continue + } + + // Word + if (isWordStart(c)) { + this.handleWord() + continue + } + + // Skip other characters + this.pos++ + } + } + + private skipComment(): void { + while (this.pos < this.len) { + const c = this.src.charCodeAt(this.pos) + this.pos++ + if (c === CHAR_NEWLINE || c === CHAR_CR) break + } + } + + private skipWhitespace(): void { + while (this.pos < this.len && isWhitespace(this.src.charCodeAt(this.pos))) { + this.pos++ + } + } + + private skipWhitespaceAndComments(): void { + while (this.pos < this.len) { + const c = this.src.charCodeAt(this.pos) + if (isWhitespace(c)) { + this.pos++ + } else if (c === CHAR_PERCENT) { + this.skipComment() + } else { + break + } + } + } + + private readCommandName(): string { + const start = this.pos + while (this.pos < this.len && isCommandChar(this.src.charCodeAt(this.pos))) { + this.pos++ + } + // Check for trailing * + if (this.pos < this.len && this.src.charCodeAt(this.pos) === CHAR_STAR) { + this.pos++ + } + return this.src.slice(start, this.pos) + } + + private readBraceArg(): string { + this.skipWhitespaceAndComments() + if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== CHAR_OPEN_BRACE) { + return '' + } + this.pos++ // skip { + const start = this.pos + let depth = 1 + while (this.pos < this.len && depth > 0) { + const c = this.src.charCodeAt(this.pos) + if (c === CHAR_OPEN_BRACE) depth++ + else if (c === CHAR_CLOSE_BRACE) depth-- + if (depth > 0) this.pos++ + } + const content = this.src.slice(start, this.pos) + if (this.pos < this.len) this.pos++ // skip } + return content + } + + private skipBraceArg(): void { + this.skipWhitespaceAndComments() + if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== CHAR_OPEN_BRACE) { + return + } + this.pos++ // skip { + let depth = 1 + while (this.pos < this.len && depth > 0) { + const c = this.src.charCodeAt(this.pos) + if (c === CHAR_OPEN_BRACE) depth++ + else if (c === CHAR_CLOSE_BRACE) depth-- + this.pos++ + } + } + + private skipBracketArg(): void { + this.skipWhitespaceAndComments() + if (this.pos >= this.len || this.src.charCodeAt(this.pos) !== CHAR_OPEN_BRACKET) { + return + } + this.pos++ // skip [ + let depth = 1 + while (this.pos < this.len && depth > 0) { + const c = this.src.charCodeAt(this.pos) + if (c === CHAR_OPEN_BRACKET) depth++ + else if (c === CHAR_CLOSE_BRACKET) depth-- + this.pos++ + } + } + + private parseArgs(pattern: string): void { + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i] + if (ch === '*') { + this.skipWhitespaceAndComments() + if (this.pos < this.len && this.src.charCodeAt(this.pos) === CHAR_STAR) { + this.pos++ + } + } else if (ch === '[') { + this.skipBracketArg() + if (pattern[i + 1] === ']') i++ + } else if (ch === '{') { + this.skipBraceArg() + if (pattern[i + 1] === '}') i++ + } + } + } + + private countWordsInString(content: string, target: State): void { + let i = 0 + const len = content.length + while (i < len) { + const c = content.charCodeAt(i) + + // Skip comments + if (c === CHAR_PERCENT) { + while (i < len && content.charCodeAt(i) !== CHAR_NEWLINE) i++ + continue + } + + // Skip math + if (c === CHAR_DOLLAR) { + i++ + if (i < len && content.charCodeAt(i) === CHAR_DOLLAR) { + i++ + while (i < len - 1 && !(content.charCodeAt(i) === CHAR_DOLLAR && content.charCodeAt(i + 1) === CHAR_DOLLAR)) i++ + i += 2 + } else { + while (i < len && content.charCodeAt(i) !== CHAR_DOLLAR) i++ + i++ + } + continue + } + + // Skip commands (don't count nested formatting) + if (c === CHAR_BACKSLASH) { + i++ + while (i < len && isCommandChar(content.charCodeAt(i))) i++ + if (i < len && content.charCodeAt(i) === CHAR_STAR) i++ + continue + } + + // Count word + if (isWordStart(c)) { + while (i < len && isWordChar(content.charCodeAt(i))) i++ + if (target === State.HEADER) this.headerWords++ + else if (target === State.CAPTION) this.captionWords++ + else this.textWords++ + continue + } + + i++ + } + } + + private handleWord(): void { + // Consume word + while (this.pos < this.len && isWordChar(this.src.charCodeAt(this.pos))) { + this.pos++ + } + + // Count based on state + if (this.state === State.TEXT) { + this.textWords++ + } else if (this.state === State.HEADER) { + this.headerWords++ + } else if (this.state === State.CAPTION) { + this.captionWords++ + } + // PREAMBLE and SKIP: don't count + } + + private handleDollarMath(): void { + this.pos++ // skip $ + if (this.pos < this.len && this.src.charCodeAt(this.pos) === CHAR_DOLLAR) { + // Display math $$...$$ + this.pos++ + while (this.pos < this.len - 1) { + if (this.src.charCodeAt(this.pos) === CHAR_DOLLAR && + this.src.charCodeAt(this.pos + 1) === CHAR_DOLLAR) { + this.pos += 2 + break + } + this.pos++ + } + if (this.state !== State.PREAMBLE) this.mathDisplays++ + } else { + // Inline math $...$ + while (this.pos < this.len && this.src.charCodeAt(this.pos) !== CHAR_DOLLAR) { + this.pos++ + } + if (this.pos < this.len) this.pos++ // skip closing $ + if (this.state !== State.PREAMBLE) this.mathInlines++ + } + } + + private handleCommand(): void { + this.pos++ // skip \ + if (this.pos >= this.len) return + + const c = this.src.charCodeAt(this.pos) + + // \[ display math + if (c === CHAR_OPEN_BRACKET) { + this.pos++ + this.skipToCommand('\\]') + if (this.state !== State.PREAMBLE) this.mathDisplays++ + return + } + + // \] end display math (shouldn't happen outside \[) + if (c === CHAR_CLOSE_BRACKET) { + this.pos++ + return + } + + // \( inline math + if (c === 40) { // ( + this.pos++ + this.skipToCommand('\\)') + if (this.state !== State.PREAMBLE) this.mathInlines++ + return + } + + // \) end inline math + if (c === 41) { // ) + this.pos++ + return + } + + // Special escaped char + if (!isLetter(c)) { + this.pos++ + return + } + + // Read command name + const cmdName = this.readCommandName() + + // \begin + if (cmdName === 'begin') { + this.handleBegin() + return + } + + // \end + if (cmdName === 'end') { + this.handleEnd() + return + } + + // Look up macro + const macro = MACROS[cmdName] + if (macro) { + const [rule, pattern] = macro + + // In preamble, just skip args + if (this.state === State.PREAMBLE) { + this.parseArgs(pattern) + return + } + + if (rule === Rule.IGNORE) { + this.parseArgs(pattern) + } else if (rule === Rule.COUNT) { + // Count words in brace args + this.countArgsAsText(pattern) + } else if (rule === Rule.COUNT_HEADER) { + this.headers++ + this.countArgsAs(pattern, State.HEADER) + } else if (rule === Rule.COUNT_CAPTION) { + this.countArgsAs(pattern, State.CAPTION) + } + } + // Unknown command - just skip it + } + + private countArgsAsText(pattern: string): void { + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i] + if (ch === '*') { + this.skipWhitespaceAndComments() + if (this.pos < this.len && this.src.charCodeAt(this.pos) === CHAR_STAR) { + this.pos++ + } + } else if (ch === '[') { + this.skipBracketArg() // Skip optional args + if (pattern[i + 1] === ']') i++ + } else if (ch === '{') { + const content = this.readBraceArg() + if (content) this.countWordsInString(content, State.TEXT) + if (pattern[i + 1] === '}') i++ + } + } + } + + private countArgsAs(pattern: string, target: State): void { + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i] + if (ch === '*') { + this.skipWhitespaceAndComments() + if (this.pos < this.len && this.src.charCodeAt(this.pos) === CHAR_STAR) { + this.pos++ + } + } else if (ch === '[') { + this.skipBracketArg() // Skip optional args + if (pattern[i + 1] === ']') i++ + } else if (ch === '{') { + const content = this.readBraceArg() + if (content) this.countWordsInString(content, target) + if (pattern[i + 1] === '}') i++ + } + } + } + + private handleBegin(): void { + const envName = this.readBraceArg() + + // \begin{document} + if (envName === 'document') { + this.state = State.TEXT + return + } + + // Float environments + if (FLOAT_ENVS.has(envName)) { + this.floats++ + this.stateStack.push(this.state) + return + } + + // Skip environments + if (SKIP_ENVS.has(envName)) { + this.skipToEndEnv(envName) + if (MATH_ENVS.has(envName) && this.state !== State.PREAMBLE) { + this.mathDisplays++ + } + return + } + + // Other environments - continue parsing + this.stateStack.push(this.state) + } + + private handleEnd(): void { + const envName = this.readBraceArg() + + if (envName === 'document') { + // Stop parsing + this.pos = this.len + return + } + + // Pop state + if (this.stateStack.length > 0) { + this.state = this.stateStack.pop()! + } + } + + private skipToEndEnv(envName: string): void { + let depth = 1 + const beginStr = '\\begin{' + envName + '}' + const endStr = '\\end{' + envName + '}' + + while (this.pos < this.len && depth > 0) { + // Quick scan for backslash + const idx = this.src.indexOf('\\', this.pos) + if (idx === -1) { + this.pos = this.len + break + } + this.pos = idx + + // Check for \begin{envName} or \end{envName} + if (this.src.startsWith(beginStr, this.pos)) { + depth++ + this.pos += beginStr.length + } else if (this.src.startsWith(endStr, this.pos)) { + depth-- + this.pos += endStr.length + } else { + this.pos++ + } + } + } + + private skipToCommand(cmd: string): void { + while (this.pos < this.len) { + const idx = this.src.indexOf(cmd, this.pos) + if (idx === -1) { + this.pos = this.len + return + } + this.pos = idx + cmd.length + return + } + } +} + +// ============================================================================ +// Public API +// ============================================================================ + +// Reusable counter instance for simple calls +let sharedCounter: TeXCounterOptimized | null = null + +/** + * Count words in a LaTeX document (optimized) + */ +export function countWords(content: string, options?: TeXCountOptions): number { + // Reuse counter instance if no custom options + if (!options) { + if (!sharedCounter) sharedCounter = new TeXCounterOptimized() + sharedCounter.count(content) + return sharedCounter.getTotalWords() + } + const counter = new TeXCounterOptimized(options) + counter.count(content) + return counter.getTotalWords() +} + +/** + * Get detailed word counts for a LaTeX document + */ +export function getDetailedCounts(content: string, options?: TeXCountOptions): WordCounts { + const counter = new TeXCounterOptimized(options) + return counter.count(content) +} + +/** + * Create a reusable counter instance + */ +export function createCounter(options?: TeXCountOptions): { count: (content: string) => WordCounts; getTotalWords: () => number } { + const counter = new TeXCounterOptimized(options) + return { + count: (content: string) => counter.count(content), + getTotalWords: () => counter.getTotalWords(), + } +} + +// Export class for advanced usage +export { TeXCounterOptimized as TeXCounter } + +export default { + countWords, + getDetailedCounts, + createCounter, +}