summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/main/fileSyncBridge.ts13
-rw-r--r--src/main/index.ts72
-rw-r--r--src/preload/index.ts10
-rw-r--r--src/renderer/src/components/Editor.tsx24
-rw-r--r--src/renderer/src/components/PdfViewer.tsx10
-rw-r--r--src/renderer/src/components/ProjectList.tsx1
7 files changed, 103 insertions, 29 deletions
diff --git a/package.json b/package.json
index b415762..088310f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lattex",
- "version": "0.2.5",
+ "version": "0.3.0",
"description": "LaTeX editor with real-time Overleaf sync",
"license": "AGPL-3.0",
"author": "Yuren Hao",
diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts
index fb45208..7d7274a 100644
--- a/src/main/fileSyncBridge.ts
+++ b/src/main/fileSyncBridge.ts
@@ -138,7 +138,8 @@ export class FileSyncBridge {
atomic: true,
ignored: [
/(^|[/\\])\../, // dotfiles
- /\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/ // LaTeX output files
+ /\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/, // LaTeX output files
+ /(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/ // App-generated config files
]
})
@@ -449,6 +450,10 @@ export class FileSyncBridge {
private onFileChanged(relPath: string): void {
if (this.stopped) return
+ // Skip app-generated config files that should not be synced to Overleaf
+ const basename = relPath.split('/').pop() || relPath
+ if (basename === 'CLAUDE.md' || basename === '.mcp.json') return
+
// Layer 1: Skip if bridge is currently writing this file
if (this.writesInProgress.has(relPath)) {
bridgeLog(`[FileSyncBridge] skipping ${relPath} (write in progress)`)
@@ -831,9 +836,10 @@ export class FileSyncBridge {
for (const relPath of allFiles) {
if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) continue
- // Skip LaTeX output files
+ // Skip LaTeX output files and app-generated config files
if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex|synctex)/.test(relPath)) continue
if (/(^|[/\\])\./.test(relPath)) continue
+ if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) continue
bridgeLog(`[FileSyncBridge] orphaned file found: ${relPath}`)
this.onNewLocalFile(relPath)
@@ -850,9 +856,10 @@ export class FileSyncBridge {
if (this.stopped) return
if (this.writesInProgress.has(relPath)) return
- // Skip LaTeX output files and dotfiles (same as chokidar ignored)
+ // Skip LaTeX output files, dotfiles, and app-generated config files (same as chokidar ignored)
if (/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb|pdf|pdfxref|stderr|stdout|chktex)$/.test(relPath)) return
if (/(^|[/\\])\./.test(relPath)) return
+ if (/(?:^|[/\\])(?:CLAUDE\.md|\.mcp\.json)$/.test(relPath)) return
// Debounce 1s to let the tool finish writing
const key = 'new:' + relPath
diff --git a/src/main/index.ts b/src/main/index.ts
index 2be5ab8..fa5c4b1 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -53,6 +53,9 @@ function createWindow(): void {
}
})
+ // Disable Electron's built-in pinch/Ctrl+wheel zoom so editor can handle it
+ mainWindow.webContents.setVisualZoomLevelLimits(1, 1)
+
if (process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
@@ -92,33 +95,44 @@ for (const p of texPaths) {
ipcMain.handle('synctex:editFromPdf', async (_e, pdfPath: string, page: number, x: number, y: number) => {
return new Promise<{ file: string; line: number } | null>((resolve) => {
const pdfDir = pdfPath.substring(0, pdfPath.lastIndexOf('/'))
+ console.log(`[synctex] edit -o ${page}:${x}:${y}:${pdfPath} (cwd: ${pdfDir})`)
const proc = spawn('synctex', ['edit', '-o', `${page}:${x}:${y}:${pdfPath}`], {
env: process.env,
cwd: pdfDir
})
- let out = ''
- proc.stdout?.on('data', (d) => { out += d.toString() })
- proc.stderr?.on('data', (d) => { out += d.toString() })
- proc.on('close', () => {
+ let stdout = ''
+ let stderr = ''
+ proc.stdout?.on('data', (d) => { stdout += d.toString() })
+ proc.stderr?.on('data', (d) => { stderr += d.toString() })
+ proc.on('close', (code) => {
+ console.log(`[synctex] exit=${code} stdout=${stdout.slice(0, 300)} stderr=${stderr.slice(0, 200)}`)
// Parse output: Input:filename\nLine:123\n...
- const fileMatch = out.match(/Input:(.+)/)
- const lineMatch = out.match(/Line:(\d+)/)
+ const fileMatch = stdout.match(/Input:(.+)/)
+ const lineMatch = stdout.match(/Line:(\d+)/)
if (fileMatch && lineMatch) {
let filePath = fileMatch[1].trim()
- // Convert absolute path to relative (strip tmpDir prefix)
+ // Strip CLSI compilation prefix (server compile uses /compile/ as cwd)
+ if (filePath.startsWith('/compile/')) {
+ filePath = filePath.slice('/compile/'.length)
+ }
+ // Convert absolute path to relative (strip tmpDir prefix for local compile)
const syncDir = compilationManager?.dir
if (syncDir && filePath.startsWith(syncDir)) {
filePath = filePath.slice(syncDir.length).replace(/^\//, '')
}
// Strip leading ./
if (filePath.startsWith('./')) filePath = filePath.slice(2)
+ console.log(`[synctex] resolved: file=${filePath} line=${lineMatch[1]}`)
resolve({ file: filePath, line: parseInt(lineMatch[1]) })
} else {
- console.log('[synctex] no result:', out.slice(0, 200))
+ console.log('[synctex] no match in output')
resolve(null)
}
})
- proc.on('error', () => resolve(null))
+ proc.on('error', (err) => {
+ console.log(`[synctex] spawn error: ${err.message}`)
+ resolve(null)
+ })
})
})
@@ -648,8 +662,11 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => {
}
}
}, null, 2)).catch(() => {})
- // Write CLAUDE.md with project context
- writeFile(join(tmpDir, 'CLAUDE.md'), `# LatteX Project — Overleaf Integration
+ // Clean up old root-level CLAUDE.md (was incorrectly placed there before)
+ require('fs').unlink(join(tmpDir, 'CLAUDE.md'), () => {})
+ // Write .claude/ dir with CLAUDE.md + settings (dotfile dir = excluded from sync)
+ mkdirAsync(join(tmpDir, '.claude'), { recursive: true }).then(async () => {
+ await writeFile(join(tmpDir, '.claude', 'CLAUDE.md'), `# LatteX Project — Overleaf Integration
This is a LaTeX project synced from Overleaf via LatteX. Files here are bidirectionally synced — edits you make will appear on Overleaf.
@@ -690,10 +707,8 @@ You have MCP tools to interact with Overleaf. Use them proactively.
2. Use \`compile_latex\` to compile
3. If errors: use \`get_compile_errors\` for details, fix them, recompile
4. If warnings: use \`get_compile_warnings\` to review
-`).catch(() => {})
- // Write .claude/settings.json to auto-allow MCP tools
- mkdirAsync(join(tmpDir, '.claude'), { recursive: true }).then(() =>
- writeFile(join(tmpDir, '.claude', 'settings.json'), JSON.stringify({
+`)
+ await writeFile(join(tmpDir, '.claude', 'settings.json'), JSON.stringify({
permissions: {
allow: [
'mcp__lattex__get_comments',
@@ -711,7 +726,7 @@ You have MCP tools to interact with Overleaf. Use them proactively.
]
}
}, null, 2))
- ).catch(() => {})
+ }).catch(() => {})
// Fetch resolved thread IDs immediately (fast REST call) so editor highlights
// don't flash resolved comments while waiting for background fetch
@@ -749,6 +764,15 @@ You have MCP tools to interact with Overleaf. Use them proactively.
sendToRenderer('comments:initContexts', { contexts })
}, 3000)
+ // Check for cached PDF from previous compile
+ const buildDir = join(tmpDir, '.build')
+ const cachedPdf = join(buildDir, 'output.pdf')
+ let cachedPdfPath: string | undefined
+ try {
+ const stat = await require('fs').promises.stat(cachedPdf)
+ if (stat.size > 0) cachedPdfPath = cachedPdf
+ } catch { /* no cached PDF */ }
+
return {
success: true,
files,
@@ -760,7 +784,8 @@ You have MCP tools to interact with Overleaf. Use them proactively.
pathDocMap,
fileRefs,
rootFolderId,
- syncDir: tmpDir
+ syncDir: tmpDir,
+ cachedPdfPath
}
} catch (e) {
console.log('[ot:connect] error:', e)
@@ -1262,13 +1287,20 @@ ipcMain.handle('overleaf:serverCompile', async (_e, rootDocId?: string) => {
}
}
- // Grab synctex.gz
+ // Grab synctex.gz (needed for PDF↔source navigation)
const synctexFile = (data.outputFiles || []).find((f: any) => f.path === 'output.synctex.gz')
if (synctexFile) {
try {
- const d = await fetchBinary(buildOutputUrl(synctexFile))
+ const synctexUrl = buildOutputUrl(synctexFile)
+ console.log(`[compile] downloading synctex.gz from ${synctexUrl.slice(0, 80)}...`)
+ const d = await fetchBinary(synctexUrl)
await writeFile(join(buildDir, 'output.synctex.gz'), Buffer.from(d))
- } catch { /* optional */ }
+ console.log(`[compile] synctex.gz saved (${d.byteLength} bytes)`)
+ } catch (e) {
+ console.log(`[compile] synctex.gz download failed: ${e}`)
+ }
+ } else {
+ console.log('[compile] no synctex.gz in compile output')
}
// Download PDF — first check outputFiles, then try direct URL from build ID
diff --git a/src/preload/index.ts b/src/preload/index.ts
index b2984b5..649c3aa 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1,9 +1,15 @@
// Copyright (c) 2026 Yuren Hao
// Licensed under AGPL-3.0 - see LICENSE file
-import { contextBridge, ipcRenderer, webUtils } from 'electron'
+import { contextBridge, ipcRenderer, webUtils, webFrame } from 'electron'
import { createHash } from 'crypto'
+// Prevent Electron's built-in Ctrl+wheel zoom so editor can handle font scaling
+webFrame.setVisualZoomLevelLimits(1, 1)
+window.addEventListener('wheel', (e) => {
+ if (e.ctrlKey || e.metaKey) e.preventDefault()
+}, { passive: false })
+
const api = {
// File system
readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path),
@@ -66,6 +72,8 @@ const api = {
pathDocMap?: Record<string, string>
fileRefs?: Array<{ id: string; path: string }>
rootFolderId?: string
+ syncDir?: string
+ cachedPdfPath?: string
message?: string
}>,
otDisconnect: () => ipcRenderer.invoke('ot:disconnect'),
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
index 4edb945..8319e93 100644
--- a/src/renderer/src/components/Editor.tsx
+++ b/src/renderer/src/components/Editor.tsx
@@ -88,6 +88,7 @@ export default function Editor() {
const docSyncRef = useRef<OverleafDocSync | null>(null)
const cursorThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+ const [editorFontSize, setEditorFontSize] = useState(13.5)
// Add comment state
const [newComment, setNewComment] = useState<{ from: number; to: number; text: string } | null>(null)
@@ -358,6 +359,29 @@ export default function Editor() {
viewRef.current.dispatch({ effects: highlightThreadEffect.of(hoveredThreadId) })
}, [hoveredThreadId])
+ // Ctrl+wheel / pinch zoom on editor (capture phase to beat CodeMirror)
+ useEffect(() => {
+ const el = editorRef.current
+ if (!el) return
+ const handleWheel = (e: WheelEvent) => {
+ if (!(e.ctrlKey || e.metaKey)) return
+ e.preventDefault()
+ e.stopPropagation()
+ const delta = e.deltaY > 0 ? -1 : 1
+ setEditorFontSize((s) => Math.min(28, Math.max(8, +(s + delta * 0.5).toFixed(1))))
+ }
+ el.addEventListener('wheel', handleWheel, { passive: false, capture: true })
+ return () => el.removeEventListener('wheel', handleWheel, { capture: true })
+ }, [])
+
+ // Apply font size to editor
+ useEffect(() => {
+ if (!viewRef.current) return
+ const wrapper = viewRef.current.dom
+ wrapper.style.fontSize = `${editorFontSize}px`
+ viewRef.current.requestMeasure()
+ }, [editorFontSize])
+
if (!activeTab) {
return (
<div className="editor-empty">
diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx
index c5fe8c4..01896d7 100644
--- a/src/renderer/src/components/PdfViewer.tsx
+++ b/src/renderer/src/components/PdfViewer.tsx
@@ -214,9 +214,9 @@ export default function PdfViewer() {
// SyncTeX: double-click PDF → jump to source
const handlePdfDoubleClick = useCallback(async (e: MouseEvent) => {
- if (!pdfPath) return
+ if (!pdfPath) { console.log('[synctex-ui] no pdfPath'); return }
const canvas = (e.target as HTMLElement).closest('canvas.pdf-page') as HTMLCanvasElement | null
- if (!canvas) return
+ if (!canvas) { console.log('[synctex-ui] no canvas target'); return }
const container = containerRef.current
if (!container) return
@@ -234,12 +234,14 @@ export default function PdfViewer() {
// Convert to PDF points (72 DPI coordinate system, origin bottom-left)
const vpInfo = pageViewportsRef.current.get(pageNum)
- if (!vpInfo) return
+ if (!vpInfo) { console.log('[synctex-ui] no viewport info for page', pageNum); return }
const pdfX = (clickX / rect.width) * vpInfo.width
const pdfY = vpInfo.height - (clickY / rect.height) * vpInfo.height
+ console.log(`[synctex-ui] dblclick page=${pageNum} pdfX=${pdfX.toFixed(1)} pdfY=${pdfY.toFixed(1)} path=${pdfPath}`)
const result = await window.api.synctexEdit(pdfPath, pageNum, pdfX, pdfY)
- if (!result) return
+ if (!result) { console.log('[synctex-ui] synctex returned null'); return }
+ console.log(`[synctex-ui] result: file=${result.file} line=${result.line}`)
// Navigate to source — synctex returns relative path (e.g. "latex/main.tex")
const store = useAppStore.getState()
diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx
index 58a6bb0..2fe23de 100644
--- a/src/renderer/src/components/ProjectList.tsx
+++ b/src/renderer/src/components/ProjectList.tsx
@@ -69,6 +69,7 @@ export default function ProjectList({ onOpenProject }: Props) {
store.setOverleafProjectId(pid)
store.setConnectionState('connected')
if (result.syncDir) store.setSyncDir(result.syncDir)
+ if (result.cachedPdfPath) store.setPdfPath(result.cachedPdfPath)
setStatusMessage('Connected')
onOpenProject(pid)
} else {