diff options
| -rw-r--r-- | package-lock.json | 39 | ||||
| -rw-r--r-- | package.json | 4 | ||||
| -rw-r--r-- | src/main/fileSyncBridge.ts | 132 | ||||
| -rw-r--r-- | src/main/index.ts | 58 | ||||
| -rw-r--r-- | src/main/overleafSocket.ts | 10 | ||||
| -rw-r--r-- | src/preload/index.ts | 22 | ||||
| -rw-r--r-- | src/renderer/src/App.css | 29 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 43 | ||||
| -rw-r--r-- | src/renderer/src/components/Terminal.tsx | 142 | ||||
| -rw-r--r-- | src/renderer/src/data/latexCommands.ts | 175 | ||||
| -rw-r--r-- | src/renderer/src/extensions/latexAutocomplete.ts | 5 | ||||
| -rw-r--r-- | src/renderer/src/extensions/mathHighlight.ts | 146 | ||||
| -rw-r--r-- | src/renderer/src/extensions/mathPreview.ts | 45 | ||||
| -rw-r--r-- | src/renderer/src/ot/overleafSync.ts | 22 |
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, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - - 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 } } |
