summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package-lock.json39
-rw-r--r--package.json4
-rw-r--r--src/main/fileSyncBridge.ts132
-rw-r--r--src/main/index.ts58
-rw-r--r--src/main/overleafSocket.ts10
-rw-r--r--src/preload/index.ts22
-rw-r--r--src/renderer/src/App.css29
-rw-r--r--src/renderer/src/components/Editor.tsx43
-rw-r--r--src/renderer/src/components/Terminal.tsx142
-rw-r--r--src/renderer/src/data/latexCommands.ts175
-rw-r--r--src/renderer/src/extensions/latexAutocomplete.ts5
-rw-r--r--src/renderer/src/extensions/mathHighlight.ts146
-rw-r--r--src/renderer/src/extensions/mathPreview.ts45
-rw-r--r--src/renderer/src/ot/overleafSync.ts22
14 files changed, 589 insertions, 283 deletions
diff --git a/package-lock.json b/package-lock.json
index 4a4b460..6324729 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,13 +1,14 @@
{
"name": "lattex",
- "version": "0.1.0",
+ "version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lattex",
- "version": "0.1.0",
+ "version": "0.2.2",
"hasInstallScript": true,
+ "license": "AGPL-3.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.6.0",
@@ -20,6 +21,7 @@
"@xterm/xterm": "^5.5.0",
"chokidar": "^3.6.0",
"diff-match-patch": "^1.0.5",
+ "katex": "^0.16.38",
"node-pty": "^1.0.0",
"pdfjs-dist": "^4.9.155",
"react": "^18.3.1",
@@ -30,6 +32,7 @@
},
"devDependencies": {
"@types/diff-match-patch": "^1.0.36",
+ "@types/katex": "^0.16.8",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/ws": "^8.18.1",
@@ -2258,6 +2261,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/katex": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/keyv": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
@@ -5381,6 +5391,31 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/katex": {
+ "version": "0.16.38",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz",
+ "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/katex/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
diff --git a/package.json b/package.json
index f08e85e..4b108e8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lattex",
- "version": "0.2.0",
+ "version": "0.2.3",
"description": "LaTeX editor with real-time Overleaf sync",
"license": "AGPL-3.0",
"author": "Yuren Hao",
@@ -25,6 +25,7 @@
"@xterm/xterm": "^5.5.0",
"chokidar": "^3.6.0",
"diff-match-patch": "^1.0.5",
+ "katex": "^0.16.38",
"node-pty": "^1.0.0",
"pdfjs-dist": "^4.9.155",
"react": "^18.3.1",
@@ -35,6 +36,7 @@
},
"devDependencies": {
"@types/diff-match-patch": "^1.0.36",
+ "@types/katex": "^0.16.8",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/ws": "^8.18.1",
diff --git a/src/main/fileSyncBridge.ts b/src/main/fileSyncBridge.ts
index e49b86d..6593297 100644
--- a/src/main/fileSyncBridge.ts
+++ b/src/main/fileSyncBridge.ts
@@ -3,7 +3,7 @@
// Bidirectional file sync bridge: temp dir ↔ Overleaf via OT (text) + REST (binary)
import { join, dirname } from 'path'
-import { readFile, writeFile, mkdir, unlink, rename as fsRename } from 'fs/promises'
+import { readFile, writeFile, mkdir, unlink, rename as fsRename, appendFile } from 'fs/promises'
import { createHash } from 'crypto'
import * as chokidar from 'chokidar'
import { diff_match_patch } from 'diff-match-patch'
@@ -15,6 +15,12 @@ import type { OtOp } from './otTypes'
import { isInsert, isDelete } from './otTypes'
const dmp = new diff_match_patch()
+const LOG_FILE = '/tmp/lattex-bridge.log'
+function bridgeLog(msg: string) {
+ const line = `[${new Date().toISOString()}] ${msg}`
+ console.log(line)
+ appendFile(LOG_FILE, line + '\n').catch(() => {})
+}
export class FileSyncBridge {
private lastKnownContent = new Map<string, string>() // relPath → content (text docs)
@@ -75,24 +81,7 @@ export class FileSyncBridge {
const docIds = Object.keys(this.docPathMap)
for (const docId of docIds) {
const relPath = this.docPathMap[docId]
- try {
- const result = await this.socket.joinDoc(docId)
- const content = (result.docLines || []).join('\n')
- this.lastKnownContent.set(relPath, content)
-
- // Create OtClient for this doc (bridge owns it initially)
- const otClient = new OtClient(
- result.version,
- (ops, version) => this.sendOps(docId, ops, version),
- (ops) => this.onRemoteApply(docId, ops)
- )
- this.otClients.set(docId, otClient)
-
- // Write to disk
- await this.writeToDisk(relPath, content)
- } catch (e) {
- console.log(`[FileSyncBridge] failed to join doc ${relPath}:`, e)
- }
+ await this.joinAndSyncDoc(docId, relPath, 3)
}
// Download all binary files
@@ -102,7 +91,7 @@ export class FileSyncBridge {
try {
await this.downloadBinary(fileRefId, relPath)
} catch (e) {
- console.log(`[FileSyncBridge] failed to download ${relPath}:`, e)
+ bridgeLog(`[FileSyncBridge] failed to download ${relPath}:`, e)
}
}
@@ -123,29 +112,45 @@ export class FileSyncBridge {
this.socket.on('serverEvent', this.serverEventHandler)
// Start watching the temp dir
+ // usePolling: FSEvents is unreliable in macOS temp dirs (/var/folders/...)
+ // atomic: Claude Code and other editors use atomic writes (write temp + rename)
+ // which macOS FSEvents doesn't detect as 'change' by default
this.watcher = chokidar.watch(this.tmpDir, {
ignoreInitial: true,
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
+ usePolling: true,
+ interval: 500,
+ atomic: true,
ignored: [
/(^|[/\\])\../, // dotfiles
/\.(aux|log|fls|fdb_latexmk|synctex\.gz|bbl|blg|out|toc|lof|lot|nav|snm|vrb)$/ // LaTeX output files (not pdf!)
]
})
+ this.watcher.on('ready', () => {
+ bridgeLog(`[FileSyncBridge] chokidar ready, watching ${this.tmpDir}`)
+ })
+
this.watcher.on('change', (absPath: string) => {
const relPath = absPath.replace(this.tmpDir + '/', '')
+ bridgeLog(`[FileSyncBridge] chokidar change: ${relPath}`)
this.onFileChanged(relPath)
})
this.watcher.on('add', (absPath: string) => {
const relPath = absPath.replace(this.tmpDir + '/', '')
+ bridgeLog(`[FileSyncBridge] chokidar add: ${relPath}`)
// Process if it's a known doc or fileRef
if (this.pathDocMap[relPath] || this.pathFileRefMap[relPath]) {
this.onFileChanged(relPath)
}
})
- console.log(`[FileSyncBridge] started, watching ${this.tmpDir}, ${docIds.length} docs + ${fileRefIds.length} files synced`)
+ this.watcher.on('unlink', (absPath: string) => {
+ const relPath = absPath.replace(this.tmpDir + '/', '')
+ bridgeLog(`[FileSyncBridge] chokidar unlink: ${relPath}`)
+ })
+
+ bridgeLog(`[FileSyncBridge] started, watching ${this.tmpDir}, ${docIds.length} docs + ${fileRefIds.length} files synced`)
}
async stop(): Promise<void> {
@@ -178,6 +183,37 @@ export class FileSyncBridge {
console.log('[FileSyncBridge] stopped')
}
+ /** Join a doc with retry logic for transient errors like joinLeaveEpoch mismatch */
+ private async joinAndSyncDoc(docId: string, relPath: string, retries: number): Promise<void> {
+ for (let attempt = 0; attempt <= retries; attempt++) {
+ try {
+ if (attempt > 0) {
+ await new Promise(r => setTimeout(r, 300 * attempt))
+ }
+ const result = await this.socket.joinDoc(docId)
+ const content = (result.docLines || []).join('\n')
+ this.lastKnownContent.set(relPath, content)
+
+ const otClient = new OtClient(
+ result.version,
+ (ops, version) => this.sendOps(docId, ops, version),
+ (ops) => this.onRemoteApply(docId, ops)
+ )
+ this.otClients.set(docId, otClient)
+
+ await this.writeToDisk(relPath, content)
+ return
+ } catch (e) {
+ const msg = String(e)
+ if (msg.includes('joinLeaveEpoch') && attempt < retries) {
+ bridgeLog(`[FileSyncBridge] joinDoc retry ${attempt + 1}/${retries} for ${relPath}: ${msg}`)
+ continue
+ }
+ bridgeLog(`[FileSyncBridge] failed to join doc ${relPath}:`, e)
+ }
+ }
+ }
+
// ── OT update handler ─────────────────────────────────────
private handleOtUpdate(args: unknown[]): void {
@@ -215,7 +251,7 @@ export class FileSyncBridge {
const folderPath = this.findFolderPath(folderId)
const relPath = folderPath + fileRef.name
- console.log(`[FileSyncBridge] remote new file: ${relPath} (${fileRef._id})`)
+ bridgeLog(`[FileSyncBridge] remote new file: ${relPath} (${fileRef._id})`)
// Register in maps
this.fileRefPathMap[fileRef._id] = relPath
@@ -223,7 +259,7 @@ export class FileSyncBridge {
// Download to disk
this.downloadBinary(fileRef._id, relPath).catch((e) => {
- console.log(`[FileSyncBridge] failed to download new file ${relPath}:`, e)
+ bridgeLog(`[FileSyncBridge] failed to download new file ${relPath}:`, e)
})
}
@@ -237,7 +273,7 @@ export class FileSyncBridge {
const folderPath = this.findFolderPath(folderId)
const relPath = folderPath + doc.name
- console.log(`[FileSyncBridge] remote new doc: ${relPath} (${doc._id})`)
+ bridgeLog(`[FileSyncBridge] remote new doc: ${relPath} (${doc._id})`)
// Register in maps
this.docPathMap[doc._id] = relPath
@@ -257,7 +293,7 @@ export class FileSyncBridge {
this.writeToDisk(relPath, content)
}).catch((e) => {
- console.log(`[FileSyncBridge] failed to join new doc ${relPath}:`, e)
+ bridgeLog(`[FileSyncBridge] failed to join new doc ${relPath}:`, e)
})
}
@@ -269,7 +305,7 @@ export class FileSyncBridge {
// Check if it's a doc
const docPath = this.docPathMap[entityId]
if (docPath) {
- console.log(`[FileSyncBridge] remote remove doc: ${docPath}`)
+ bridgeLog(`[FileSyncBridge] remote remove doc: ${docPath}`)
delete this.docPathMap[entityId]
delete this.pathDocMap[docPath]
this.lastKnownContent.delete(docPath)
@@ -281,7 +317,7 @@ export class FileSyncBridge {
// Check if it's a fileRef
const filePath = this.fileRefPathMap[entityId]
if (filePath) {
- console.log(`[FileSyncBridge] remote remove file: ${filePath}`)
+ bridgeLog(`[FileSyncBridge] remote remove file: ${filePath}`)
delete this.fileRefPathMap[entityId]
delete this.pathFileRefMap[filePath]
this.binaryHashes.delete(filePath)
@@ -299,7 +335,7 @@ export class FileSyncBridge {
const oldDocPath = this.docPathMap[entityId]
if (oldDocPath) {
const newPath = dirname(oldDocPath) === '.' ? newName : dirname(oldDocPath) + '/' + newName
- console.log(`[FileSyncBridge] remote rename doc: ${oldDocPath} → ${newPath}`)
+ bridgeLog(`[FileSyncBridge] remote rename doc: ${oldDocPath} → ${newPath}`)
// Update maps
this.docPathMap[entityId] = newPath
@@ -322,7 +358,7 @@ export class FileSyncBridge {
const oldFilePath = this.fileRefPathMap[entityId]
if (oldFilePath) {
const newPath = dirname(oldFilePath) === '.' ? newName : dirname(oldFilePath) + '/' + newName
- console.log(`[FileSyncBridge] remote rename file: ${oldFilePath} → ${newPath}`)
+ bridgeLog(`[FileSyncBridge] remote rename file: ${oldFilePath} → ${newPath}`)
// Update maps
this.fileRefPathMap[entityId] = newPath
@@ -381,7 +417,12 @@ export class FileSyncBridge {
if (this.stopped) return
// Layer 1: Skip if bridge is currently writing this file
- if (this.writesInProgress.has(relPath)) return
+ if (this.writesInProgress.has(relPath)) {
+ bridgeLog(`[FileSyncBridge] skipping ${relPath} (write in progress)`)
+ return
+ }
+
+ bridgeLog(`[FileSyncBridge] onFileChanged: ${relPath}, isDoc=${!!this.pathDocMap[relPath]}, isFile=${!!this.pathFileRefMap[relPath]}, isEditorDoc=${this.editorDocs.has(this.pathDocMap[relPath] || '')}`)
// Layer 3: Debounce 300ms per file
const existing = this.debounceTimers.get(relPath)
@@ -413,19 +454,24 @@ export class FileSyncBridge {
let newContent: string
try {
newContent = await readFile(join(this.tmpDir, relPath), 'utf-8')
- } catch {
+ } catch (e) {
+ bridgeLog(`[FileSyncBridge] read error for ${relPath}:`, e)
return // file deleted or unreadable
}
const lastKnown = this.lastKnownContent.get(relPath)
// Layer 2: Content equality check
- if (newContent === lastKnown) return
+ if (newContent === lastKnown) {
+ bridgeLog(`[FileSyncBridge] content unchanged for ${relPath}, skipping`)
+ return
+ }
- console.log(`[FileSyncBridge] disk change detected: ${relPath} (${(newContent.length)} chars)`)
+ bridgeLog(`[FileSyncBridge] disk change detected: ${relPath} (${newContent.length} chars, was ${lastKnown?.length ?? 'undefined'})`)
if (this.editorDocs.has(docId)) {
// Doc is open in editor → send to renderer via IPC
+ bridgeLog(`[FileSyncBridge] → sending sync:externalEdit to renderer for ${relPath}`)
this.lastKnownContent.set(relPath, newContent)
this.mainWindow.webContents.send('sync:externalEdit', { docId, content: newContent })
} else {
@@ -437,10 +483,14 @@ export class FileSyncBridge {
dmp.diff_cleanupEfficiency(diffs)
const ops = diffsToOtOps(diffs)
+ bridgeLog(`[FileSyncBridge] → direct OT for ${relPath}: ${ops.length} ops`)
+
if (ops.length > 0) {
const otClient = this.otClients.get(docId)
if (otClient) {
otClient.onLocalOps(ops)
+ } else {
+ bridgeLog(`[FileSyncBridge] WARNING: no OtClient for docId ${docId}`)
}
}
}
@@ -461,14 +511,14 @@ export class FileSyncBridge {
const oldHash = this.binaryHashes.get(relPath)
if (newHash === oldHash) return
- console.log(`[FileSyncBridge] binary change detected: ${relPath} (${fileData.length} bytes)`)
+ bridgeLog(`[FileSyncBridge] binary change detected: ${relPath} (${fileData.length} bytes)`)
this.binaryHashes.set(relPath, newHash)
// Upload to Overleaf via REST API (this replaces the existing file)
try {
await this.uploadBinary(relPath, fileData)
} catch (e) {
- console.log(`[FileSyncBridge] failed to upload binary ${relPath}:`, e)
+ bridgeLog(`[FileSyncBridge] failed to upload binary ${relPath}:`, e)
}
}
@@ -494,7 +544,7 @@ export class FileSyncBridge {
// Set write guard before writing
this.writesInProgress.add(relPath)
await writeFile(fullPath, data)
- setTimeout(() => this.writesInProgress.delete(relPath), 150)
+ setTimeout(() => this.writesInProgress.delete(relPath), 1000)
// Store hash
this.binaryHashes.set(relPath, createHash('sha1').update(data).digest('hex'))
@@ -549,7 +599,7 @@ export class FileSyncBridge {
req.on('response', (res) => {
res.on('data', (chunk: Buffer) => { resBody += chunk.toString() })
res.on('end', () => {
- console.log(`[FileSyncBridge] upload ${relPath}: ${res.statusCode} ${resBody.slice(0, 200)}`)
+ bridgeLog(`[FileSyncBridge] upload ${relPath}: ${res.statusCode} ${resBody.slice(0, 200)}`)
try {
const data = JSON.parse(resBody)
if (data.success !== false && !data.error) {
@@ -670,7 +720,7 @@ export class FileSyncBridge {
this.writeToDisk(relPath, content)
}).catch((e) => {
- console.log(`[FileSyncBridge] failed to re-join doc ${relPath}:`, e)
+ bridgeLog(`[FileSyncBridge] failed to re-join doc ${relPath}:`, e)
})
}
@@ -686,7 +736,7 @@ export class FileSyncBridge {
await mkdir(dir, { recursive: true })
await writeFile(fullPath, content, 'utf-8')
} catch (e) {
- console.log(`[FileSyncBridge] write error for ${relPath}:`, e)
+ bridgeLog(`[FileSyncBridge] write error for ${relPath}:`, e)
}
setTimeout(() => {
@@ -716,7 +766,7 @@ export class FileSyncBridge {
await mkdir(dirname(newFull), { recursive: true })
await fsRename(oldFull, newFull)
} catch (e) {
- console.log(`[FileSyncBridge] rename error ${oldRelPath} → ${newRelPath}:`, e)
+ bridgeLog(`[FileSyncBridge] rename error ${oldRelPath} → ${newRelPath}:`, e)
}
setTimeout(() => {
diff --git a/src/main/index.ts b/src/main/index.ts
index 0d93b17..d4d9b2d 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -11,7 +11,7 @@ import { CompilationManager } from './compilationManager'
import { FileSyncBridge } from './fileSyncBridge'
let mainWindow: BrowserWindow | null = null
-let ptyInstance: pty.IPty | null = null
+const ptyInstances = new Map<string, pty.IPty>()
let overleafSock: OverleafSocket | null = null
let compilationManager: CompilationManager | null = null
let fileSyncBridge: FileSyncBridge | null = null
@@ -91,13 +91,16 @@ ipcMain.handle('synctex:editFromPdf', async (_e, pdfPath: string, page: number,
// ── Terminal / PTY ───────────────────────────────────────────────
-ipcMain.handle('pty:spawn', async (_e, cwd: string) => {
- if (ptyInstance) {
- ptyInstance.kill()
+ipcMain.handle('pty:spawn', async (_e, id: string, cwd: string, cmd?: string, args?: string[]) => {
+ const existing = ptyInstances.get(id)
+ if (existing) {
+ existing.kill()
+ ptyInstances.delete(id)
}
- const shellPath = process.env.SHELL || '/bin/zsh'
- ptyInstance = pty.spawn(shellPath, ['-l'], {
+ const shellPath = cmd || process.env.SHELL || '/bin/zsh'
+ const shellArgs = args || ['-l']
+ const instance = pty.spawn(shellPath, shellArgs, {
name: 'xterm-256color',
cols: 80,
rows: 24,
@@ -105,28 +108,37 @@ ipcMain.handle('pty:spawn', async (_e, cwd: string) => {
env: process.env as Record<string, string>
})
- ptyInstance.onData((data) => {
- sendToRenderer('pty:data', data)
+ ptyInstances.set(id, instance)
+
+ instance.onData((data) => {
+ sendToRenderer(`pty:data:${id}`, data)
})
- ptyInstance.onExit(() => {
- sendToRenderer('pty:exit')
+ instance.onExit(() => {
+ // Only delete if this is still the current instance (avoid race with re-spawn)
+ if (ptyInstances.get(id) === instance) {
+ sendToRenderer(`pty:exit:${id}`)
+ ptyInstances.delete(id)
+ }
})
})
-ipcMain.handle('pty:write', async (_e, data: string) => {
- ptyInstance?.write(data)
+ipcMain.handle('pty:write', async (_e, id: string, data: string) => {
+ ptyInstances.get(id)?.write(data)
})
-ipcMain.handle('pty:resize', async (_e, cols: number, rows: number) => {
+ipcMain.handle('pty:resize', async (_e, id: string, cols: number, rows: number) => {
try {
- ptyInstance?.resize(cols, rows)
+ ptyInstances.get(id)?.resize(cols, rows)
} catch { /* ignore resize errors */ }
})
-ipcMain.handle('pty:kill', async () => {
- ptyInstance?.kill()
- ptyInstance = null
+ipcMain.handle('pty:kill', async (_e, id: string) => {
+ const instance = ptyInstances.get(id)
+ if (instance) {
+ instance.kill()
+ ptyInstances.delete(id)
+ }
})
// ── Overleaf Web Session (for comments) ─────────────────────────
@@ -613,12 +625,15 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => {
})
// otUpdateApplied: server acknowledges our op (ack signal for OT client)
+ // Only ack when there's no 'op' field — presence of 'op' means it's a remote update, not our ack
overleafSock.on('serverEvent', (name: string, args: unknown[]) => {
if (name === 'otUpdateApplied') {
- const update = args[0] as { doc?: string; v?: number } | undefined
- if (update?.doc) {
+ const update = args[0] as { doc?: string; op?: unknown[]; v?: number } | undefined
+ if (update?.doc && !update.op) {
sendToRenderer('ot:ack', { docId: update.doc })
}
+ } else if (name === 'otUpdateError') {
+ console.log(`[ot:error] server rejected update:`, JSON.stringify(args).slice(0, 500))
}
})
@@ -650,9 +665,7 @@ ipcMain.handle('ot:connect', async (_e, projectId: string) => {
// Set up file sync bridge for bidirectional sync
const tmpDir = compilationManager.dir
fileSyncBridge = new FileSyncBridge(overleafSock, tmpDir, docPathMap, pathDocMap, fileRefs, mainWindow!, projectId, overleafSessionCookie, overleafCsrfToken)
- fileSyncBridge.start().catch((e) => {
- console.log('[ot:connect] fileSyncBridge start error:', e)
- })
+ await fileSyncBridge.start()
return {
success: true,
@@ -691,7 +704,6 @@ ipcMain.handle('ot:joinDoc', async (_e, docId: string) => {
try {
const result = await overleafSock.joinDoc(docId)
const content = (result.docLines || []).join('\n')
-
// Update compilation manager with doc content
if (compilationManager && overleafSock.projectData) {
const { docPathMap } = walkRootFolder(overleafSock.projectData.project.rootFolder)
diff --git a/src/main/overleafSocket.ts b/src/main/overleafSocket.ts
index 811e433..195eb05 100644
--- a/src/main/overleafSocket.ts
+++ b/src/main/overleafSocket.ts
@@ -155,6 +155,10 @@ export class OverleafSocket extends EventEmitter {
this.ws.on('close', () => {
this.stopHeartbeat()
+ // Clear pending ack callbacks to prevent timeout errors after reconnect
+ for (const [id, cb] of this.ackCallbacks) {
+ this.ackCallbacks.delete(id)
+ }
if (this._state === 'connected' && this.shouldReconnect) {
this.scheduleReconnect()
}
@@ -298,8 +302,10 @@ export class OverleafSocket extends EventEmitter {
}
async applyOtUpdate(docId: string, ops: unknown[], version: number, hash: string): Promise<void> {
- // Fire-and-forget: server responds with otUpdateApplied or otUpdateError event
- this.ws?.send(encodeEvent('applyOtUpdate', [docId, { doc: docId, op: ops, v: version, hash, lastV: version }]))
+ // Use emitWithAck so the server's callback response comes back as a Socket.IO ack
+ // Do NOT send hash — Overleaf's document-updater hash check causes disconnect + rollback on mismatch
+ const result = await this.emitWithAck('applyOtUpdate', [docId, { doc: docId, op: ops, v: version }])
+ if (result) console.log(`[applyOtUpdate] ack for ${docId} v=${version}`)
}
/** Get list of connected users with their cursor positions */
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 1bf97b3..aa16872 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -16,20 +16,20 @@ const api = {
return () => ipcRenderer.removeListener('latex:log', handler)
},
- // Terminal
- ptySpawn: (cwd: string) => ipcRenderer.invoke('pty:spawn', cwd),
- ptyWrite: (data: string) => ipcRenderer.invoke('pty:write', data),
- ptyResize: (cols: number, rows: number) => ipcRenderer.invoke('pty:resize', cols, rows),
- ptyKill: () => ipcRenderer.invoke('pty:kill'),
- onPtyData: (cb: (data: string) => void) => {
+ // Terminal (supports multiple named instances)
+ ptySpawn: (id: string, cwd: string, cmd?: string, args?: string[]) => ipcRenderer.invoke('pty:spawn', id, cwd, cmd, args),
+ ptyWrite: (id: string, data: string) => ipcRenderer.invoke('pty:write', id, data),
+ ptyResize: (id: string, cols: number, rows: number) => ipcRenderer.invoke('pty:resize', id, cols, rows),
+ ptyKill: (id: string) => ipcRenderer.invoke('pty:kill', id),
+ onPtyData: (id: string, cb: (data: string) => void) => {
const handler = (_e: Electron.IpcRendererEvent, data: string) => cb(data)
- ipcRenderer.on('pty:data', handler)
- return () => ipcRenderer.removeListener('pty:data', handler)
+ ipcRenderer.on(`pty:data:${id}`, handler)
+ return () => ipcRenderer.removeListener(`pty:data:${id}`, handler)
},
- onPtyExit: (cb: () => void) => {
+ onPtyExit: (id: string, cb: () => void) => {
const handler = () => cb()
- ipcRenderer.on('pty:exit', handler)
- return () => ipcRenderer.removeListener('pty:exit', handler)
+ ipcRenderer.on(`pty:exit:${id}`, handler)
+ return () => ipcRenderer.removeListener(`pty:exit:${id}`, handler)
},
// SyncTeX
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css
index c6d8126..aae0b4b 100644
--- a/src/renderer/src/App.css
+++ b/src/renderer/src/App.css
@@ -2221,6 +2221,18 @@ html, body, #root {
font-family: var(--font-mono);
}
+/* ── Math Region Highlighting ─────────────────────────────── */
+
+.cm-math-inline {
+ background: rgba(184, 134, 11, 0.06);
+ border-radius: 2px;
+}
+
+.cm-math-display {
+ background: rgba(184, 134, 11, 0.08);
+ border-radius: 3px;
+}
+
/* ── Math Preview ──────────────────────────────────────────── */
.cm-math-preview {
@@ -2229,3 +2241,20 @@ html, body, #root {
border-radius: var(--radius);
box-shadow: var(--shadow-md);
}
+
+.cm-math-preview .katex {
+ font-size: 1.15em;
+}
+
+.cm-math-preview .katex-display {
+ margin: 0;
+}
+
+/* ── Autocomplete Symbol ─────────────────────────────────── */
+
+.cm-completionDetail .completion-symbol {
+ font-family: "Times New Roman", "STIX Two Math", serif;
+ font-style: normal;
+ font-size: 13px;
+ margin-right: 4px;
+}
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
index a95d15c..75b3872 100644
--- a/src/renderer/src/components/Editor.tsx
+++ b/src/renderer/src/components/Editor.tsx
@@ -5,10 +5,11 @@ import { useEffect, useRef, useState, useCallback } from 'react'
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
-import { bracketMatching, foldGutter, indentOnInput, StreamLanguage } from '@codemirror/language'
+import { bracketMatching, foldGutter, indentOnInput, StreamLanguage, syntaxHighlighting, HighlightStyle } from '@codemirror/language'
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { stex } from '@codemirror/legacy-modes/mode/stex'
+import { tags } from '@lezer/highlight'
import { useAppStore } from '../stores/appStore'
import {
commentHighlights,
@@ -24,6 +25,7 @@ import { latexAutocomplete } from '../extensions/latexAutocomplete'
import { latexFolding } from '../extensions/latexFolding'
import { latexClosing } from '../extensions/latexClosing'
import { mathPreview } from '../extensions/mathPreview'
+import { mathHighlight } from '../extensions/mathHighlight'
import { OverleafDocSync } from '../ot/overleafSync'
import { activeDocSyncs, remoteCursors } from '../App'
@@ -46,23 +48,30 @@ const cosmicLatteTheme = EditorView.theme({
'.cm-lineNumbers .cm-gutterElement': { padding: '0 8px' },
'.cm-foldGutter': { width: '16px' },
'.cm-matchingBracket': { backgroundColor: '#D4C9A8', outline: 'none' },
- // LaTeX syntax colors
- '.cm-keyword': { color: '#8B2252', fontWeight: '600' }, // \commands
- '.cm-atom': { color: '#B8860B' }, // special symbols
- '.cm-string': { color: '#5B8A3C' }, // arguments
- '.cm-comment': { color: '#A09880', fontStyle: 'italic' },
- '.cm-bracket': { color: '#4A6FA5' }, // { } [ ]
- '.cm-tag': { color: '#8B2252', fontWeight: '600' }, // \begin \end
- '.cm-builtin': { color: '#6B5B3E' },
- '.cm-meta': { color: '#C75643' }, // $ math delimiters
- '.cm-number': { color: '#B8860B' },
- // StreamLanguage stex token class overrides
- '.ͼ5': { color: '#8B2252', fontWeight: '600' }, // keyword
- '.ͼ6': { color: '#4A6FA5' }, // bracket/variable
- '.ͼ7': { color: '#5B8A3C' }, // string
- '.ͼ8': { color: '#A09880', fontStyle: 'italic' }, // comment
}, { dark: false })
+const cosmicLatteHighlight = HighlightStyle.define([
+ { tag: tags.keyword, color: '#8B2252', fontWeight: '600' }, // \commands
+ { tag: tags.tagName, color: '#8B2252', fontWeight: '600' }, // \begin \end
+ { tag: tags.atom, color: '#B8860B' }, // special symbols
+ { tag: tags.number, color: '#B8860B' }, // numbers
+ { tag: tags.string, color: '#5B8A3C' }, // arguments in braces
+ { tag: tags.comment, color: '#A09880', fontStyle: 'italic' }, // % comments
+ { tag: tags.bracket, color: '#4A6FA5' }, // { } [ ] ( )
+ { tag: tags.paren, color: '#4A6FA5' },
+ { tag: tags.squareBracket, color: '#4A6FA5' },
+ { tag: tags.brace, color: '#4A6FA5' },
+ { tag: tags.meta, color: '#C75643' }, // $ math delimiters
+ { tag: tags.standard(tags.name), color: '#6B5B3E' }, // builtins
+ { tag: tags.variableName, color: '#4A6FA5' }, // variables
+ { tag: tags.definition(tags.variableName), color: '#6B5B3E' }, // definitions
+ { tag: tags.operator, color: '#8B6B8B' }, // operators
+ { tag: tags.heading, color: '#8B2252', fontWeight: '700' }, // headings
+ { tag: tags.contentSeparator, color: '#D6CEBC' }, // horizontal rules
+ { tag: tags.url, color: '#4A6FA5', textDecoration: 'underline' }, // URLs
+ { tag: tags.invalid, color: '#C75643', textDecoration: 'underline' }, // errors
+])
+
export default function Editor() {
const editorRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
@@ -221,6 +230,7 @@ export default function Editor() {
history(),
highlightSelectionMatches(),
StreamLanguage.define(stex),
+ syntaxHighlighting(cosmicLatteHighlight),
keymap.of([
...defaultKeymap,
...historyKeymap,
@@ -236,6 +246,7 @@ export default function Editor() {
latexFolding(),
latexClosing(),
mathPreview(),
+ mathHighlight,
commentHighlights(),
overleafProjectId ? addCommentTooltip() : [],
...otExt,
diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx
index e2eb5e8..9a2e24c 100644
--- a/src/renderer/src/components/Terminal.tsx
+++ b/src/renderer/src/components/Terminal.tsx
@@ -1,44 +1,55 @@
// Copyright (c) 2026 Yuren Hao
// Licensed under AGPL-3.0 - see LICENSE file
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState, useCallback } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { useAppStore } from '../stores/appStore'
-export default function Terminal() {
+const XTERM_THEME = {
+ background: '#2D2A24',
+ foreground: '#E8DFC0',
+ cursor: '#FFF8E7',
+ selectionBackground: '#5C5040',
+ black: '#2D2A24',
+ red: '#C75643',
+ green: '#5B8A3C',
+ yellow: '#B8860B',
+ blue: '#4A6FA5',
+ magenta: '#8B6B8B',
+ cyan: '#5B8A8A',
+ white: '#E8DFC0',
+ brightBlack: '#6B5B3E',
+ brightRed: '#D46A58',
+ brightGreen: '#6FA050',
+ brightYellow: '#D4A020',
+ brightBlue: '#5E84B8',
+ brightMagenta: '#A080A0',
+ brightCyan: '#6FA0A0',
+ brightWhite: '#FFF8E7'
+}
+
+/** A single xterm + pty instance */
+function TerminalInstance({ id, cwd, cmd, args, visible }: {
+ id: string
+ cwd: string
+ cmd?: string
+ args?: string[]
+ visible: boolean
+}) {
const termRef = useRef<HTMLDivElement>(null)
const xtermRef = useRef<XTerm | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
- const [mode, setMode] = useState<'terminal' | 'claude'>('terminal')
+ const spawnedRef = useRef(false)
+ const initializedRef = useRef(false)
useEffect(() => {
- if (!termRef.current) return
+ if (!termRef.current || initializedRef.current) return
+ initializedRef.current = true
const xterm = new XTerm({
- theme: {
- background: '#2D2A24',
- foreground: '#E8DFC0',
- cursor: '#FFF8E7',
- selectionBackground: '#5C5040',
- black: '#2D2A24',
- red: '#C75643',
- green: '#5B8A3C',
- yellow: '#B8860B',
- blue: '#4A6FA5',
- magenta: '#8B6B8B',
- cyan: '#5B8A8A',
- white: '#E8DFC0',
- brightBlack: '#6B5B3E',
- brightRed: '#D46A58',
- brightGreen: '#6FA050',
- brightYellow: '#D4A020',
- brightBlue: '#5E84B8',
- brightMagenta: '#A080A0',
- brightCyan: '#6FA0A0',
- brightWhite: '#FFF8E7'
- },
+ theme: XTERM_THEME,
fontFamily: '"SF Mono", "Fira Code", "JetBrains Mono", monospace',
fontSize: 13,
cursorBlink: true,
@@ -49,58 +60,73 @@ export default function Terminal() {
xterm.loadAddon(fitAddon)
xterm.open(termRef.current)
- // Fit after a small delay to ensure container is sized
setTimeout(() => fitAddon.fit(), 100)
xtermRef.current = xterm
fitAddonRef.current = fitAddon
- // Spawn shell in project sync directory
- const syncDir = useAppStore.getState().syncDir || '/tmp'
- window.api.ptySpawn(syncDir)
+ // Spawn pty
+ window.api.ptySpawn(id, cwd, cmd, args)
+ spawnedRef.current = true
- // Pipe data
- const unsubData = window.api.onPtyData((data) => {
+ const unsubData = window.api.onPtyData(id, (data) => {
xterm.write(data)
})
- const unsubExit = window.api.onPtyExit(() => {
+ const unsubExit = window.api.onPtyExit(id, () => {
xterm.writeln('\r\n[Process exited]')
+ spawnedRef.current = false
})
- // Send input
xterm.onData((data) => {
- window.api.ptyWrite(data)
+ window.api.ptyWrite(id, data)
})
- // Handle resize
const resizeObserver = new ResizeObserver(() => {
try {
fitAddon.fit()
- window.api.ptyResize(xterm.cols, xterm.rows)
+ if (spawnedRef.current) {
+ window.api.ptyResize(id, xterm.cols, xterm.rows)
+ }
} catch { /* ignore */ }
})
resizeObserver.observe(termRef.current)
return () => {
+ initializedRef.current = false
resizeObserver.disconnect()
unsubData()
unsubExit()
- window.api.ptyKill()
+ window.api.ptyKill(id)
xterm.dispose()
}
- }, [])
+ }, [id, cwd, cmd])
- const launchClaude = () => {
- if (!xtermRef.current) return
- window.api.ptyWrite('claude\n')
- setMode('claude')
- }
+ // Re-fit when becoming visible
+ useEffect(() => {
+ if (visible && fitAddonRef.current) {
+ setTimeout(() => fitAddonRef.current?.fit(), 50)
+ }
+ }, [visible])
- const sendToClaude = (prompt: string) => {
- if (!xtermRef.current) return
- window.api.ptyWrite(prompt + '\n')
- }
+ return (
+ <div
+ ref={termRef}
+ className="terminal-content"
+ style={visible ? undefined : { display: 'none' }}
+ />
+ )
+}
+
+export default function Terminal() {
+ const [mode, setMode] = useState<'terminal' | 'claude'>('terminal')
+ const [claudeSpawned, setClaudeSpawned] = useState(false)
+ const syncDir = useAppStore((s) => s.syncDir) || '/tmp'
+
+ const launchClaude = useCallback(() => {
+ setClaudeSpawned(true)
+ setMode('claude')
+ }, [])
return (
<div className="terminal-panel">
@@ -118,23 +144,31 @@ export default function Terminal() {
Claude
</button>
<div className="pdf-toolbar-spacer" />
- <QuickActions onSend={sendToClaude} />
+ <QuickActions ptyId={claudeSpawned ? 'claude' : 'terminal'} />
</div>
- <div ref={termRef} className="terminal-content" />
+
+ <TerminalInstance id="terminal" cwd={syncDir} visible={mode === 'terminal'} />
+ {claudeSpawned && (
+ <TerminalInstance id="claude" cwd={syncDir} cmd="claude" args={[]} visible={mode === 'claude'} />
+ )}
</div>
)
}
-function QuickActions({ onSend }: { onSend: (cmd: string) => void }) {
+function QuickActions({ ptyId }: { ptyId: string }) {
const { activeTab, fileContents } = useAppStore()
+ const send = (prompt: string) => {
+ window.api.ptyWrite(ptyId, prompt + '\n')
+ }
+
const actions = [
{
label: 'Fix Errors',
action: () => {
const log = useAppStore.getState().compileLog
if (log) {
- onSend(`Fix these LaTeX compilation errors:\n${log.slice(-2000)}`)
+ send(`Fix these LaTeX compilation errors:\n${log.slice(-2000)}`)
}
}
},
@@ -142,7 +176,7 @@ function QuickActions({ onSend }: { onSend: (cmd: string) => void }) {
label: 'Review',
action: () => {
if (activeTab && fileContents[activeTab]) {
- onSend(`Review this LaTeX file for issues and improvements: ${activeTab}`)
+ send(`Review this LaTeX file for issues and improvements: ${activeTab}`)
}
}
},
@@ -150,7 +184,7 @@ function QuickActions({ onSend }: { onSend: (cmd: string) => void }) {
label: 'Explain',
action: () => {
if (activeTab) {
- onSend(`Explain the structure and content of: ${activeTab}`)
+ send(`Explain the structure and content of: ${activeTab}`)
}
}
}
diff --git a/src/renderer/src/data/latexCommands.ts b/src/renderer/src/data/latexCommands.ts
index 2e2dad7..2364e4b 100644
--- a/src/renderer/src/data/latexCommands.ts
+++ b/src/renderer/src/data/latexCommands.ts
@@ -10,6 +10,7 @@ export interface LatexCommand {
snippet?: string // e.g. "\\frac{$1}{$2}" — if absent, label is used as-is
detail?: string // short description
section?: string // category for grouping
+ symbol?: string // Unicode symbol preview (e.g. "α" for \alpha)
}
export const latexCommands: LatexCommand[] = [
@@ -92,29 +93,29 @@ export const latexCommands: LatexCommand[] = [
{ label: '\\tfrac', snippet: '\\tfrac{$1}{$2}', detail: 'Text fraction', section: 'math' },
{ label: '\\sqrt', snippet: '\\sqrt{$1}', detail: 'Square root', section: 'math' },
{ label: '\\sqrt[]', snippet: '\\sqrt[${1:n}]{$2}', detail: 'Nth root', section: 'math' },
- { label: '\\sum', snippet: '\\sum_{${1:i=1}}^{${2:n}}', detail: 'Summation', section: 'math' },
- { label: '\\prod', snippet: '\\prod_{${1:i=1}}^{${2:n}}', detail: 'Product', section: 'math' },
- { label: '\\int', snippet: '\\int_{${1:a}}^{${2:b}}', detail: 'Integral', section: 'math' },
- { label: '\\iint', snippet: '\\iint_{$1}', detail: 'Double integral', section: 'math' },
- { label: '\\iiint', snippet: '\\iiint_{$1}', detail: 'Triple integral', section: 'math' },
- { label: '\\oint', snippet: '\\oint_{$1}', detail: 'Contour integral', section: 'math' },
+ { label: '\\sum', snippet: '\\sum_{${1:i=1}}^{${2:n}}', detail: 'Summation', section: 'math', symbol: '∑' },
+ { label: '\\prod', snippet: '\\prod_{${1:i=1}}^{${2:n}}', detail: 'Product', section: 'math', symbol: '∏' },
+ { label: '\\int', snippet: '\\int_{${1:a}}^{${2:b}}', detail: 'Integral', section: 'math', symbol: '∫' },
+ { label: '\\iint', snippet: '\\iint_{$1}', detail: 'Double integral', section: 'math', symbol: '∬' },
+ { label: '\\iiint', snippet: '\\iiint_{$1}', detail: 'Triple integral', section: 'math', symbol: '∭' },
+ { label: '\\oint', snippet: '\\oint_{$1}', detail: 'Contour integral', section: 'math', symbol: '∮' },
{ label: '\\lim', snippet: '\\lim_{${1:x \\to \\infty}}', detail: 'Limit', section: 'math' },
- { label: '\\infty', detail: 'Infinity', section: 'math' },
- { label: '\\partial', detail: 'Partial derivative', section: 'math' },
- { label: '\\nabla', detail: 'Nabla/Del', section: 'math' },
- { label: '\\forall', detail: 'For all', section: 'math' },
- { label: '\\exists', detail: 'Exists', section: 'math' },
- { label: '\\nexists', detail: 'Not exists', section: 'math' },
- { label: '\\in', detail: 'Element of', section: 'math' },
- { label: '\\notin', detail: 'Not element of', section: 'math' },
- { label: '\\subset', detail: 'Subset', section: 'math' },
- { label: '\\subseteq', detail: 'Subset or equal', section: 'math' },
- { label: '\\supset', detail: 'Superset', section: 'math' },
- { label: '\\supseteq', detail: 'Superset or equal', section: 'math' },
- { label: '\\cup', detail: 'Union', section: 'math' },
- { label: '\\cap', detail: 'Intersection', section: 'math' },
- { label: '\\emptyset', detail: 'Empty set', section: 'math' },
- { label: '\\varnothing', detail: 'Empty set (variant)', section: 'math' },
+ { label: '\\infty', detail: 'Infinity', section: 'math', symbol: '∞' },
+ { label: '\\partial', detail: 'Partial derivative', section: 'math', symbol: '∂' },
+ { label: '\\nabla', detail: 'Nabla/Del', section: 'math', symbol: '∇' },
+ { label: '\\forall', detail: 'For all', section: 'math', symbol: '∀' },
+ { label: '\\exists', detail: 'Exists', section: 'math', symbol: '∃' },
+ { label: '\\nexists', detail: 'Not exists', section: 'math', symbol: '∄' },
+ { label: '\\in', detail: 'Element of', section: 'math', symbol: '∈' },
+ { label: '\\notin', detail: 'Not element of', section: 'math', symbol: '∉' },
+ { label: '\\subset', detail: 'Subset', section: 'math', symbol: '⊂' },
+ { label: '\\subseteq', detail: 'Subset or equal', section: 'math', symbol: '⊆' },
+ { label: '\\supset', detail: 'Superset', section: 'math', symbol: '⊃' },
+ { label: '\\supseteq', detail: 'Superset or equal', section: 'math', symbol: '⊇' },
+ { label: '\\cup', detail: 'Union', section: 'math', symbol: '∪' },
+ { label: '\\cap', detail: 'Intersection', section: 'math', symbol: '∩' },
+ { label: '\\emptyset', detail: 'Empty set', section: 'math', symbol: '∅' },
+ { label: '\\varnothing', detail: 'Empty set (variant)', section: 'math', symbol: '∅' },
{ label: '\\mathbb', snippet: '\\mathbb{$1}', detail: 'Blackboard bold', section: 'math' },
{ label: '\\mathcal', snippet: '\\mathcal{$1}', detail: 'Calligraphic', section: 'math' },
{ label: '\\mathfrak', snippet: '\\mathfrak{$1}', detail: 'Fraktur', section: 'math' },
@@ -136,71 +137,71 @@ export const latexCommands: LatexCommand[] = [
{ label: '\\right', detail: 'Right delimiter', section: 'math' },
{ label: '\\bigl', detail: 'Big left', section: 'math' },
{ label: '\\bigr', detail: 'Big right', section: 'math' },
- { label: '\\cdot', detail: 'Center dot', section: 'math' },
- { label: '\\cdots', detail: 'Center dots', section: 'math' },
- { label: '\\ldots', detail: 'Low dots', section: 'math' },
- { label: '\\vdots', detail: 'Vertical dots', section: 'math' },
- { label: '\\ddots', detail: 'Diagonal dots', section: 'math' },
- { label: '\\times', detail: 'Times', section: 'math' },
- { label: '\\div', detail: 'Division', section: 'math' },
- { label: '\\pm', detail: 'Plus-minus', section: 'math' },
- { label: '\\mp', detail: 'Minus-plus', section: 'math' },
- { label: '\\leq', detail: 'Less or equal', section: 'math' },
- { label: '\\geq', detail: 'Greater or equal', section: 'math' },
- { label: '\\neq', detail: 'Not equal', section: 'math' },
- { label: '\\approx', detail: 'Approximately', section: 'math' },
- { label: '\\equiv', detail: 'Equivalent', section: 'math' },
- { label: '\\sim', detail: 'Similar', section: 'math' },
- { label: '\\propto', detail: 'Proportional to', section: 'math' },
- { label: '\\ll', detail: 'Much less', section: 'math' },
- { label: '\\gg', detail: 'Much greater', section: 'math' },
- { label: '\\to', detail: 'Right arrow', section: 'math' },
- { label: '\\rightarrow', detail: 'Right arrow', section: 'math' },
- { label: '\\leftarrow', detail: 'Left arrow', section: 'math' },
- { label: '\\leftrightarrow', detail: 'Left-right arrow', section: 'math' },
- { label: '\\Rightarrow', detail: 'Double right arrow', section: 'math' },
- { label: '\\Leftarrow', detail: 'Double left arrow', section: 'math' },
- { label: '\\Leftrightarrow', detail: 'Double left-right arrow', section: 'math' },
- { label: '\\mapsto', detail: 'Maps to', section: 'math' },
- { label: '\\uparrow', detail: 'Up arrow', section: 'math' },
- { label: '\\downarrow', detail: 'Down arrow', section: 'math' },
- { label: '\\alpha', detail: 'Greek alpha', section: 'greek' },
- { label: '\\beta', detail: 'Greek beta', section: 'greek' },
- { label: '\\gamma', detail: 'Greek gamma', section: 'greek' },
- { label: '\\Gamma', detail: 'Greek Gamma', section: 'greek' },
- { label: '\\delta', detail: 'Greek delta', section: 'greek' },
- { label: '\\Delta', detail: 'Greek Delta', section: 'greek' },
- { label: '\\epsilon', detail: 'Greek epsilon', section: 'greek' },
- { label: '\\varepsilon', detail: 'Greek varepsilon', section: 'greek' },
- { label: '\\zeta', detail: 'Greek zeta', section: 'greek' },
- { label: '\\eta', detail: 'Greek eta', section: 'greek' },
- { label: '\\theta', detail: 'Greek theta', section: 'greek' },
- { label: '\\Theta', detail: 'Greek Theta', section: 'greek' },
- { label: '\\vartheta', detail: 'Greek vartheta', section: 'greek' },
- { label: '\\iota', detail: 'Greek iota', section: 'greek' },
- { label: '\\kappa', detail: 'Greek kappa', section: 'greek' },
- { label: '\\lambda', detail: 'Greek lambda', section: 'greek' },
- { label: '\\Lambda', detail: 'Greek Lambda', section: 'greek' },
- { label: '\\mu', detail: 'Greek mu', section: 'greek' },
- { label: '\\nu', detail: 'Greek nu', section: 'greek' },
- { label: '\\xi', detail: 'Greek xi', section: 'greek' },
- { label: '\\Xi', detail: 'Greek Xi', section: 'greek' },
- { label: '\\pi', detail: 'Greek pi', section: 'greek' },
- { label: '\\Pi', detail: 'Greek Pi', section: 'greek' },
- { label: '\\rho', detail: 'Greek rho', section: 'greek' },
- { label: '\\varrho', detail: 'Greek varrho', section: 'greek' },
- { label: '\\sigma', detail: 'Greek sigma', section: 'greek' },
- { label: '\\Sigma', detail: 'Greek Sigma', section: 'greek' },
- { label: '\\tau', detail: 'Greek tau', section: 'greek' },
- { label: '\\upsilon', detail: 'Greek upsilon', section: 'greek' },
- { label: '\\phi', detail: 'Greek phi', section: 'greek' },
- { label: '\\Phi', detail: 'Greek Phi', section: 'greek' },
- { label: '\\varphi', detail: 'Greek varphi', section: 'greek' },
- { label: '\\chi', detail: 'Greek chi', section: 'greek' },
- { label: '\\psi', detail: 'Greek psi', section: 'greek' },
- { label: '\\Psi', detail: 'Greek Psi', section: 'greek' },
- { label: '\\omega', detail: 'Greek omega', section: 'greek' },
- { label: '\\Omega', detail: 'Greek Omega', section: 'greek' },
+ { label: '\\cdot', detail: 'Center dot', section: 'math', symbol: '·' },
+ { label: '\\cdots', detail: 'Center dots', section: 'math', symbol: '⋯' },
+ { label: '\\ldots', detail: 'Low dots', section: 'math', symbol: '…' },
+ { label: '\\vdots', detail: 'Vertical dots', section: 'math', symbol: '⋮' },
+ { label: '\\ddots', detail: 'Diagonal dots', section: 'math', symbol: '⋱' },
+ { label: '\\times', detail: 'Times', section: 'math', symbol: '×' },
+ { label: '\\div', detail: 'Division', section: 'math', symbol: '÷' },
+ { label: '\\pm', detail: 'Plus-minus', section: 'math', symbol: '±' },
+ { label: '\\mp', detail: 'Minus-plus', section: 'math', symbol: '∓' },
+ { label: '\\leq', detail: 'Less or equal', section: 'math', symbol: '≤' },
+ { label: '\\geq', detail: 'Greater or equal', section: 'math', symbol: '≥' },
+ { label: '\\neq', detail: 'Not equal', section: 'math', symbol: '≠' },
+ { label: '\\approx', detail: 'Approximately', section: 'math', symbol: '≈' },
+ { label: '\\equiv', detail: 'Equivalent', section: 'math', symbol: '≡' },
+ { label: '\\sim', detail: 'Similar', section: 'math', symbol: '∼' },
+ { label: '\\propto', detail: 'Proportional to', section: 'math', symbol: '∝' },
+ { label: '\\ll', detail: 'Much less', section: 'math', symbol: '≪' },
+ { label: '\\gg', detail: 'Much greater', section: 'math', symbol: '≫' },
+ { label: '\\to', detail: 'Right arrow', section: 'math', symbol: '→' },
+ { label: '\\rightarrow', detail: 'Right arrow', section: 'math', symbol: '→' },
+ { label: '\\leftarrow', detail: 'Left arrow', section: 'math', symbol: '←' },
+ { label: '\\leftrightarrow', detail: 'Left-right arrow', section: 'math', symbol: '↔' },
+ { label: '\\Rightarrow', detail: 'Double right arrow', section: 'math', symbol: '⇒' },
+ { label: '\\Leftarrow', detail: 'Double left arrow', section: 'math', symbol: '⇐' },
+ { label: '\\Leftrightarrow', detail: 'Double left-right arrow', section: 'math', symbol: '⇔' },
+ { label: '\\mapsto', detail: 'Maps to', section: 'math', symbol: '↦' },
+ { label: '\\uparrow', detail: 'Up arrow', section: 'math', symbol: '↑' },
+ { label: '\\downarrow', detail: 'Down arrow', section: 'math', symbol: '↓' },
+ { label: '\\alpha', detail: 'Greek alpha', section: 'greek', symbol: 'α' },
+ { label: '\\beta', detail: 'Greek beta', section: 'greek', symbol: 'β' },
+ { label: '\\gamma', detail: 'Greek gamma', section: 'greek', symbol: 'γ' },
+ { label: '\\Gamma', detail: 'Greek Gamma', section: 'greek', symbol: 'Γ' },
+ { label: '\\delta', detail: 'Greek delta', section: 'greek', symbol: 'δ' },
+ { label: '\\Delta', detail: 'Greek Delta', section: 'greek', symbol: 'Δ' },
+ { label: '\\epsilon', detail: 'Greek epsilon', section: 'greek', symbol: 'ϵ' },
+ { label: '\\varepsilon', detail: 'Greek varepsilon', section: 'greek', symbol: 'ε' },
+ { label: '\\zeta', detail: 'Greek zeta', section: 'greek', symbol: 'ζ' },
+ { label: '\\eta', detail: 'Greek eta', section: 'greek', symbol: 'η' },
+ { label: '\\theta', detail: 'Greek theta', section: 'greek', symbol: 'θ' },
+ { label: '\\Theta', detail: 'Greek Theta', section: 'greek', symbol: 'Θ' },
+ { label: '\\vartheta', detail: 'Greek vartheta', section: 'greek', symbol: 'ϑ' },
+ { label: '\\iota', detail: 'Greek iota', section: 'greek', symbol: 'ι' },
+ { label: '\\kappa', detail: 'Greek kappa', section: 'greek', symbol: 'κ' },
+ { label: '\\lambda', detail: 'Greek lambda', section: 'greek', symbol: 'λ' },
+ { label: '\\Lambda', detail: 'Greek Lambda', section: 'greek', symbol: 'Λ' },
+ { label: '\\mu', detail: 'Greek mu', section: 'greek', symbol: 'μ' },
+ { label: '\\nu', detail: 'Greek nu', section: 'greek', symbol: 'ν' },
+ { label: '\\xi', detail: 'Greek xi', section: 'greek', symbol: 'ξ' },
+ { label: '\\Xi', detail: 'Greek Xi', section: 'greek', symbol: 'Ξ' },
+ { label: '\\pi', detail: 'Greek pi', section: 'greek', symbol: 'π' },
+ { label: '\\Pi', detail: 'Greek Pi', section: 'greek', symbol: 'Π' },
+ { label: '\\rho', detail: 'Greek rho', section: 'greek', symbol: 'ρ' },
+ { label: '\\varrho', detail: 'Greek varrho', section: 'greek', symbol: 'ϱ' },
+ { label: '\\sigma', detail: 'Greek sigma', section: 'greek', symbol: 'σ' },
+ { label: '\\Sigma', detail: 'Greek Sigma', section: 'greek', symbol: 'Σ' },
+ { label: '\\tau', detail: 'Greek tau', section: 'greek', symbol: 'τ' },
+ { label: '\\upsilon', detail: 'Greek upsilon', section: 'greek', symbol: 'υ' },
+ { label: '\\phi', detail: 'Greek phi', section: 'greek', symbol: 'ϕ' },
+ { label: '\\Phi', detail: 'Greek Phi', section: 'greek', symbol: 'Φ' },
+ { label: '\\varphi', detail: 'Greek varphi', section: 'greek', symbol: 'φ' },
+ { label: '\\chi', detail: 'Greek chi', section: 'greek', symbol: 'χ' },
+ { label: '\\psi', detail: 'Greek psi', section: 'greek', symbol: 'ψ' },
+ { label: '\\Psi', detail: 'Greek Psi', section: 'greek', symbol: 'Ψ' },
+ { label: '\\omega', detail: 'Greek omega', section: 'greek', symbol: 'ω' },
+ { label: '\\Omega', detail: 'Greek Omega', section: 'greek', symbol: 'Ω' },
// ── Environments (as commands) ──
{ label: '\\begin', snippet: '\\begin{$1}\n\t$2\n\\end{$1}', detail: 'Begin environment', section: 'env' },
diff --git a/src/renderer/src/extensions/latexAutocomplete.ts b/src/renderer/src/extensions/latexAutocomplete.ts
index a151876..df00bb4 100644
--- a/src/renderer/src/extensions/latexAutocomplete.ts
+++ b/src/renderer/src/extensions/latexAutocomplete.ts
@@ -172,17 +172,18 @@ function commandSource(context: CompletionContext): CompletionResult | null {
if (word.text.length < 2 && !context.explicit) return null
const options: Completion[] = latexCommands.map((cmd) => {
+ const detail = cmd.symbol ? `${cmd.symbol} ${cmd.detail || ''}` : cmd.detail
if (cmd.snippet) {
return snippetCompletion(cmd.snippet, {
label: cmd.label,
- detail: cmd.detail,
+ detail,
type: 'function',
boost: cmd.section === 'structure' || cmd.section === 'sectioning' ? 2 : 0,
})
}
return {
label: cmd.label,
- detail: cmd.detail,
+ detail,
type: 'function',
}
})
diff --git a/src/renderer/src/extensions/mathHighlight.ts b/src/renderer/src/extensions/mathHighlight.ts
new file mode 100644
index 0000000..f2c492b
--- /dev/null
+++ b/src/renderer/src/extensions/mathHighlight.ts
@@ -0,0 +1,146 @@
+// Copyright (c) 2026 Yuren Hao
+// Licensed under AGPL-3.0 - see LICENSE file
+
+/**
+ * CodeMirror 6 extension: background highlight for math regions.
+ * Adds subtle background color to $...$ (inline) and $$...$$ / \[...\] (display) regions.
+ */
+import { ViewPlugin, Decoration, type DecorationSet, EditorView, ViewUpdate } from '@codemirror/view'
+import { RangeSetBuilder } from '@codemirror/state'
+
+const inlineMathDeco = Decoration.mark({ class: 'cm-math-inline' })
+const displayMathDeco = Decoration.mark({ class: 'cm-math-display' })
+
+interface MathRegion {
+ from: number
+ to: number
+ display: boolean
+}
+
+function findMathRegions(text: string): MathRegion[] {
+ const regions: MathRegion[] = []
+ let i = 0
+
+ while (i < text.length) {
+ // Skip escaped characters
+ if (text[i] === '\\' && i + 1 < text.length) {
+ // Check for \[ ... \] (display math)
+ if (text[i + 1] === '[') {
+ const start = i
+ const closeIdx = text.indexOf('\\]', i + 2)
+ if (closeIdx !== -1) {
+ regions.push({ from: start, to: closeIdx + 2, display: true })
+ i = closeIdx + 2
+ continue
+ }
+ }
+ // Check for \( ... \) (inline math)
+ if (text[i + 1] === '(') {
+ const start = i
+ const closeIdx = text.indexOf('\\)', i + 2)
+ if (closeIdx !== -1) {
+ regions.push({ from: start, to: closeIdx + 2, display: false })
+ i = closeIdx + 2
+ continue
+ }
+ }
+ // Skip other escape sequences
+ i += 2
+ continue
+ }
+
+ // Check for $$ (display math)
+ if (text[i] === '$' && i + 1 < text.length && text[i + 1] === '$') {
+ const start = i
+ // Find closing $$
+ let j = i + 2
+ while (j < text.length - 1) {
+ if (text[j] === '$' && text[j + 1] === '$' && text[j - 1] !== '\\') {
+ regions.push({ from: start, to: j + 2, display: true })
+ i = j + 2
+ break
+ }
+ j++
+ }
+ if (j >= text.length - 1) {
+ i = j + 1
+ }
+ continue
+ }
+
+ // Check for $ (inline math)
+ if (text[i] === '$') {
+ // Don't match if preceded by backslash
+ if (i > 0 && text[i - 1] === '\\') {
+ i++
+ continue
+ }
+ const start = i
+ let j = i + 1
+ while (j < text.length) {
+ if (text[j] === '$' && text[j - 1] !== '\\') {
+ // Make sure it's not $$
+ if (j + 1 < text.length && text[j + 1] === '$') {
+ j++
+ continue
+ }
+ regions.push({ from: start, to: j + 1, display: false })
+ i = j + 1
+ break
+ }
+ // Inline math doesn't span paragraphs
+ if (text[j] === '\n' && j + 1 < text.length && text[j + 1] === '\n') {
+ i = j
+ break
+ }
+ j++
+ }
+ if (j >= text.length) {
+ i = j
+ }
+ continue
+ }
+
+ i++
+ }
+
+ return regions
+}
+
+function buildDecorations(view: EditorView): DecorationSet {
+ const { from, to } = view.viewport
+ // Extend a bit beyond viewport for smooth scrolling
+ const extFrom = Math.max(0, from - 1000)
+ const extTo = Math.min(view.state.doc.length, to + 1000)
+ const text = view.state.doc.sliceString(extFrom, extTo)
+ const regions = findMathRegions(text)
+
+ const builder = new RangeSetBuilder<Decoration>()
+ for (const r of regions) {
+ const absFrom = extFrom + r.from
+ const absTo = extFrom + r.to
+ // Only add decorations that are at least partially in the viewport
+ if (absTo < from || absFrom > to) continue
+ const clampFrom = Math.max(absFrom, 0)
+ const clampTo = Math.min(absTo, view.state.doc.length)
+ if (clampFrom < clampTo) {
+ builder.add(clampFrom, clampTo, r.display ? displayMathDeco : inlineMathDeco)
+ }
+ }
+ return builder.finish()
+}
+
+export const mathHighlight = ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet
+ constructor(view: EditorView) {
+ this.decorations = buildDecorations(view)
+ }
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.viewportChanged) {
+ this.decorations = buildDecorations(update.view)
+ }
+ }
+ },
+ { decorations: (v) => v.decorations }
+)
diff --git a/src/renderer/src/extensions/mathPreview.ts b/src/renderer/src/extensions/mathPreview.ts
index 01ac0f6..9338b8f 100644
--- a/src/renderer/src/extensions/mathPreview.ts
+++ b/src/renderer/src/extensions/mathPreview.ts
@@ -3,14 +3,14 @@
/**
* CodeMirror 6 extension: hover preview for LaTeX math expressions.
- * Shows a rendered preview tooltip when hovering over $...$ or $$...$$ or \(...\) or \[...\].
+ * Uses KaTeX for beautiful rendered math when hovering over $...$ or $$...$$ or \(...\) or \[...\].
*/
import { hoverTooltip, type Tooltip } from '@codemirror/view'
+import katex from 'katex'
+import 'katex/dist/katex.min.css'
/** Find the math expression surrounding the given position */
function findMathAt(docText: string, pos: number): { from: number; to: number; tex: string; display: boolean } | null {
- // Search for display math first ($$...$$, \[...\])
- // Then inline math ($...$, \(...\))
const patterns: Array<{ open: string; close: string; display: boolean }> = [
{ open: '$$', close: '$$', display: true },
{ open: '\\[', close: '\\]', display: true },
@@ -19,7 +19,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
]
for (const { open, close, display } of patterns) {
- // Search backward for opener
const searchStart = Math.max(0, pos - 2000)
const before = docText.slice(searchStart, pos + open.length)
@@ -28,7 +27,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
while (searchFrom >= 0) {
const idx = before.lastIndexOf(open, searchFrom)
if (idx === -1) break
- // For $$, skip if it's actually a single $ at boundary
if (open === '$$' && idx > 0 && docText[searchStart + idx - 1] === '$') {
searchFrom = idx - 1
continue
@@ -42,7 +40,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
}
if (openIdx === -1 || openIdx > pos) continue
- // Search forward for closer
const afterStart = openIdx + open.length
const closeIdx = docText.indexOf(close, Math.max(afterStart, pos - close.length + 1))
if (closeIdx === -1 || closeIdx < pos - close.length) continue
@@ -51,7 +48,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
const contentEnd = closeIdx
if (contentEnd <= contentStart) continue
- // Check pos is within the math region
if (pos < openIdx || pos > closeIdx + close.length) continue
const tex = docText.slice(contentStart, contentEnd).trim()
@@ -63,27 +59,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
return null
}
-/** Render LaTeX to HTML using KaTeX-like approach via CSS */
-function renderMathToHtml(tex: string, display: boolean): string {
- // Use a simple approach: create an img tag with a data URI from a math rendering service
- // Or use the browser's MathML support
- // For simplicity, we'll render using MathML basic support + fallback to raw TeX
-
- // Try MathML rendering for common patterns, fallback to formatted TeX display
- const escaped = tex
- .replace(/&/g, '&amp;')
- .replace(/</g, '&lt;')
- .replace(/>/g, '&gt;')
-
- const fontSize = display ? '1.2em' : '1em'
- return `<div style="font-size: ${fontSize}; font-family: 'Times New Roman', serif; padding: 8px 12px; max-width: 400px; overflow-x: auto; white-space: pre-wrap; line-height: 1.6; color: #3B3228;">
- <math xmlns="http://www.w3.org/1998/Math/MathML" ${display ? 'display="block"' : ''}>
- <mrow><mtext>${escaped}</mtext></mrow>
- </math>
- <div style="margin-top: 4px; font-family: 'SF Mono', monospace; font-size: 11px; color: #A09880; border-top: 1px solid #E8DFC0; padding-top: 4px;">${display ? '$$' : '$'}${escaped}${display ? '$$' : '$'}</div>
- </div>`
-}
-
export function mathPreview() {
return hoverTooltip((view, pos): Tooltip | null => {
const docText = view.state.doc.toString()
@@ -97,7 +72,19 @@ export function mathPreview() {
create() {
const dom = document.createElement('div')
dom.className = 'cm-math-preview'
- dom.innerHTML = renderMathToHtml(result.tex, result.display)
+ try {
+ const html = katex.renderToString(result.tex, {
+ displayMode: result.display,
+ throwOnError: false,
+ errorColor: '#C75643',
+ trust: true,
+ strict: false,
+ output: 'html',
+ })
+ dom.innerHTML = `<div style="padding: 10px 14px; max-width: 500px; overflow-x: auto;">${html}</div>`
+ } catch {
+ dom.innerHTML = `<div style="padding: 8px 12px; font-family: monospace; font-size: 12px; color: #C75643;">Error rendering: ${result.tex}</div>`
+ }
return { dom }
}
}
diff --git a/src/renderer/src/ot/overleafSync.ts b/src/renderer/src/ot/overleafSync.ts
index 1c6672b..e288c56 100644
--- a/src/renderer/src/ot/overleafSync.ts
+++ b/src/renderer/src/ot/overleafSync.ts
@@ -19,6 +19,7 @@ export class OverleafDocSync {
private view: EditorView | null = null
private docId: string
private pendingChanges: ChangeSet | null = null
+ private pendingBaseDoc: Text | null = null // doc before pendingChanges
private debounceTimer: ReturnType<typeof setTimeout> | null = null
private debounceMs = 150
@@ -46,6 +47,7 @@ export class OverleafDocSync {
this.pendingChanges = this.pendingChanges.compose(changes)
} else {
this.pendingChanges = changes
+ this.pendingBaseDoc = oldDoc // save the base doc for correct OT op generation
}
// Debounce send
@@ -54,29 +56,17 @@ export class OverleafDocSync {
}
private flushLocalChanges() {
- if (!this.pendingChanges || !this.view) return
+ if (!this.pendingChanges || !this.view || !this.pendingBaseDoc) return
- const oldDoc = this.view.state.doc
- // We need the doc state BEFORE the pending changes were applied
- // Since we composed changes incrementally, we work backward
- // Actually, we stored the ChangeSet which maps old positions, so we convert directly
- const ops = changeSetToOtOps(this.pendingChanges, this.getOldDoc())
+ const ops = changeSetToOtOps(this.pendingChanges, this.pendingBaseDoc)
this.pendingChanges = null
+ this.pendingBaseDoc = null
if (ops.length > 0) {
this.otClient.onLocalOps(ops)
}
}
- private getOldDoc(): Text {
- // The "old doc" is the current doc minus pending local changes
- // Since pendingChanges is null at send time (we just cleared it),
- // and the ChangeSet was already composed against the old doc,
- // we just use the doc that was current when changes started accumulating.
- // For simplicity, we pass the doc at change time via changeSetToOtOps
- return this.view!.state.doc
- }
-
/** Send ops to server via IPC */
private handleSend(ops: OtOp[], version: number) {
const docText = this.view?.state.doc.toString() || ''
@@ -114,6 +104,7 @@ export class OverleafDocSync {
reset(version: number, docContent: string) {
this.otClient.reset(version)
this.pendingChanges = null
+ this.pendingBaseDoc = null
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
@@ -164,5 +155,6 @@ export class OverleafDocSync {
if (this.debounceTimer) clearTimeout(this.debounceTimer)
this.view = null
this.pendingChanges = null
+ this.pendingBaseDoc = null
}
}