summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-13 00:52:59 -0500
committerhaoyuren <13851610112@163.com>2026-03-13 00:52:59 -0500
commit52a5c24f5e28a4b2ba8ffb006874cd7b552d60f7 (patch)
tree2dc6182077eb3d3a5d6d1a5f655cde1896435cad
parenta0dd3d7ac642111faeaefd02c5a452898b9c6d49 (diff)
Rename to LatteX, add LaTeX autocomplete, fix comment highlight positionsv0.1.0
- Rename project from ClaudeTeX to LatteX (Cosmic Latte theme pun) - Add new coffee cup logo and branded welcome/project screens - Add 5-source LaTeX autocomplete: commands, environments, \ref, \cite, file paths - Fix comment highlight drift by decoding WebSocket-encoded UTF-8 lines and range text via decodeURIComponent(escape()), matching Overleaf's client implementation - Remove hacky byte-offset position remapping from index.ts - Add pinch-to-zoom on PDF viewer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--electron-builder.yml4
-rw-r--r--package-lock.json170
-rw-r--r--package.json6
-rw-r--r--resources/logo.svg28
-rw-r--r--src/main/compilationManager.ts2
-rw-r--r--src/main/index.ts4
-rw-r--r--src/main/overleafSocket.ts31
-rw-r--r--src/renderer/index.html2
-rw-r--r--src/renderer/src/App.css73
-rw-r--r--src/renderer/src/App.tsx34
-rw-r--r--src/renderer/src/components/Editor.tsx5
-rw-r--r--src/renderer/src/components/PdfViewer.tsx15
-rw-r--r--src/renderer/src/components/ProjectList.tsx2
-rw-r--r--src/renderer/src/data/latexCommands.ts300
-rw-r--r--src/renderer/src/data/latexEnvironments.ts98
-rw-r--r--src/renderer/src/extensions/commentHighlights.ts43
-rw-r--r--src/renderer/src/extensions/latexAutocomplete.ts349
17 files changed, 1117 insertions, 49 deletions
diff --git a/electron-builder.yml b/electron-builder.yml
index bd020a0..9152755 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -1,5 +1,5 @@
-appId: com.claudetex.app
-productName: ClaudeTeX
+appId: com.lattex.app
+productName: LatteX
directories:
buildResources: resources
output: dist
diff --git a/package-lock.json b/package-lock.json
index a2e21b0..4a4b460 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "claude-tex",
+ "name": "lattex",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "claude-tex",
+ "name": "lattex",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
@@ -18,7 +18,7 @@
"@codemirror/view": "^6.34.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
- "chokidar": "^5.0.0",
+ "chokidar": "^3.6.0",
"diff-match-patch": "^1.0.5",
"node-pty": "^1.0.0",
"pdfjs-dist": "^4.9.155",
@@ -2514,6 +2514,19 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/app-builder-bin": {
"version": "5.0.0-alpha.10",
"resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz",
@@ -2828,6 +2841,18 @@
"node": ">=6.0.0"
}
},
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -2879,6 +2904,18 @@
"node": "18 || 20 || >=22"
}
},
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -3215,18 +3252,27 @@
}
},
"node_modules/chokidar": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
- "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"dependencies": {
- "readdirp": "^5.0.0"
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
},
"engines": {
- "node": ">= 20.19.0"
+ "node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
}
},
"node_modules/chownr": {
@@ -4444,6 +4490,18 @@
"node": ">=10"
}
},
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -4538,7 +4596,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -4677,6 +4734,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/glob/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -5078,6 +5147,18 @@
"node": ">= 12"
}
},
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-ci": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
@@ -5091,6 +5172,15 @@
"is-ci": "bin.js"
}
},
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -5101,6 +5191,18 @@
"node": ">=8"
}
},
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-interactive": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
@@ -5118,6 +5220,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
"node_modules/is-unicode-supported": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
@@ -5928,9 +6039,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6170,6 +6279,18 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
@@ -6405,16 +6526,15 @@
}
},
"node_modules/readdirp": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
- "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
- "engines": {
- "node": ">= 20.19.0"
+ "dependencies": {
+ "picomatch": "^2.2.1"
},
- "funding": {
- "type": "individual",
- "url": "https://paulmillr.com/funding/"
+ "engines": {
+ "node": ">=8.10.0"
}
},
"node_modules/require-directory": {
@@ -7080,6 +7200,18 @@
"tmp": "^0.2.0"
}
},
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
diff --git a/package.json b/package.json
index ffcf155..6e6563c 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "claude-tex",
+ "name": "lattex",
"version": "0.1.0",
- "description": "LaTeX editor with Claude AI integration and Overleaf sync",
+ "description": "LaTeX editor with real-time Overleaf sync",
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
@@ -21,7 +21,7 @@
"@codemirror/view": "^6.34.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
- "chokidar": "^5.0.0",
+ "chokidar": "^3.6.0",
"diff-match-patch": "^1.0.5",
"node-pty": "^1.0.0",
"pdfjs-dist": "^4.9.155",
diff --git a/resources/logo.svg b/resources/logo.svg
new file mode 100644
index 0000000..bd5af43
--- /dev/null
+++ b/resources/logo.svg
@@ -0,0 +1,28 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+ <defs>
+ <linearGradient id="cupGrad" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="#FFF8E7"/>
+ <stop offset="100%" stop-color="#EDE5CE"/>
+ </linearGradient>
+ <linearGradient id="coffeeGrad" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="#A0782C"/>
+ <stop offset="100%" stop-color="#7A5A1E"/>
+ </linearGradient>
+ </defs>
+ <!-- Background circle -->
+ <circle cx="256" cy="256" r="240" fill="#6B5B3E"/>
+ <!-- Cup body -->
+ <path d="M130 190 Q124 390 185 405 L327 405 Q388 390 382 190 Z" fill="url(#cupGrad)" stroke="#D6CEBC" stroke-width="2"/>
+ <!-- Coffee surface -->
+ <ellipse cx="256" cy="190" rx="126" ry="36" fill="url(#coffeeGrad)"/>
+ <!-- Latte art - heart -->
+ <path d="M256 175 Q240 165 232 172 Q224 180 236 192 L256 208 L276 192 Q288 180 280 172 Q272 165 256 175Z" fill="#D4B880" opacity="0.6"/>
+ <!-- Cup rim highlight -->
+ <ellipse cx="256" cy="190" rx="126" ry="36" fill="none" stroke="#D6CEBC" stroke-width="3"/>
+ <!-- Handle -->
+ <path d="M382 230 Q435 235 438 300 Q440 365 390 370" fill="none" stroke="#FFF8E7" stroke-width="12" stroke-linecap="round"/>
+ <!-- Steam lines -->
+ <path d="M216 148 Q210 118 220 95" fill="none" stroke="#FFF8E7" stroke-width="5" stroke-linecap="round" opacity="0.5"/>
+ <path d="M256 140 Q250 105 260 80" fill="none" stroke="#FFF8E7" stroke-width="5" stroke-linecap="round" opacity="0.45"/>
+ <path d="M296 148 Q290 118 300 95" fill="none" stroke="#FFF8E7" stroke-width="5" stroke-linecap="round" opacity="0.4"/>
+</svg>
diff --git a/src/main/compilationManager.ts b/src/main/compilationManager.ts
index 3529345..8fbd946 100644
--- a/src/main/compilationManager.ts
+++ b/src/main/compilationManager.ts
@@ -15,7 +15,7 @@ export class CompilationManager {
constructor(projectId: string, cookie: string) {
this.projectId = projectId
this.cookie = cookie
- this.tmpDir = join(require('os').tmpdir(), `claudetex-${projectId}`)
+ this.tmpDir = join(require('os').tmpdir(), `lattex-${projectId}`)
}
get dir(): string {
diff --git a/src/main/index.ts b/src/main/index.ts
index 89a04b0..7fc1c4d 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -211,9 +211,9 @@ ipcMain.handle('overleaf:webLogin', async () => {
// Inject a floating back button when navigated away from overleaf.com
const injectBackButton = () => {
loginWindow.webContents.executeJavaScript(`
- if (!document.getElementById('claudetex-back-btn')) {
+ if (!document.getElementById('lattex-back-btn')) {
const btn = document.createElement('div');
- btn.id = 'claudetex-back-btn';
+ btn.id = 'lattex-back-btn';
btn.innerHTML = '← Back';
btn.style.cssText = 'position:fixed;top:8px;left:8px;z-index:999999;padding:6px 14px;' +
'background:#333;color:#fff;border-radius:6px;cursor:pointer;font:13px -apple-system,sans-serif;' +
diff --git a/src/main/overleafSocket.ts b/src/main/overleafSocket.ts
index 52ac20f..1a12260 100644
--- a/src/main/overleafSocket.ts
+++ b/src/main/overleafSocket.ts
@@ -9,6 +9,15 @@ import {
encodeHeartbeat
} from './overleafProtocol'
+/** Decode WebSocket-encoded UTF-8 text (reverses server's unescape(encodeURIComponent(text))) */
+function decodeUtf8(text: string): string {
+ try {
+ return decodeURIComponent(escape(text))
+ } catch {
+ return text // already decoded or pure ASCII
+ }
+}
+
export interface JoinProjectResult {
publicId: string
project: {
@@ -252,14 +261,30 @@ export class OverleafSocket extends EventEmitter {
this.joinedDocs.add(docId)
// Ack response format: [error, docLines, version, updates, ranges, pathname]
- // First element is error (null = success)
const err = result[0]
if (err) throw new Error(`joinDoc failed: ${JSON.stringify(err)}`)
- const docLines = (result[1] as string[]) || []
+ // Server encodes lines + range text via unescape(encodeURIComponent(text))
+ // for safe WebSocket transport. Decode with decodeURIComponent(escape(text)).
+ const rawLines = (result[1] as string[]) || []
+ const docLines = rawLines.map(line => decodeUtf8(line))
const version = (result[2] as number) || 0
const updates = (result[3] as unknown[]) || []
- const ranges = (result[4] || { comments: [], changes: [] }) as JoinDocResult['ranges']
+ const rawRanges = result[4] as JoinDocResult['ranges'] | undefined
+
+ // Decode range text (op.c, op.i, op.d) — positions (op.p) stay as-is
+ const ranges = rawRanges || { comments: [], changes: [] }
+ if (ranges.comments) {
+ for (const c of ranges.comments) {
+ if (c.op?.c) c.op.c = decodeUtf8(c.op.c)
+ }
+ }
+ if (ranges.changes) {
+ for (const ch of ranges.changes as any[]) {
+ if (ch.op?.i) ch.op.i = decodeUtf8(ch.op.i)
+ if (ch.op?.d) ch.op.d = decodeUtf8(ch.op.d)
+ }
+ }
return { docLines, version, updates, ranges }
}
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 1fb4779..9fa1aa8 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>ClaudeTeX</title>
+ <title>LatteX</title>
</head>
<body>
<div id="root"></div>
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css
index ec9cf88..2a681a5 100644
--- a/src/renderer/src/App.css
+++ b/src/renderer/src/App.css
@@ -83,12 +83,23 @@ html, body, #root {
text-align: center;
}
+.welcome-logo {
+ margin-bottom: 20px;
+}
+
.welcome-content h1 {
- font-size: 48px;
+ font-size: 52px;
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 8px;
color: var(--accent);
+ font-family: "Georgia", "Times New Roman", serif;
+}
+
+.lattex-x {
+ font-weight: 800;
+ font-style: italic;
+ color: var(--accent-blue);
}
.welcome-content p {
@@ -767,7 +778,8 @@ html, body, #root {
.projects-header h1 {
font-size: 22px;
font-weight: 700;
- color: var(--text-primary);
+ color: var(--accent);
+ font-family: "Georgia", "Times New Roman", serif;
}
.projects-header-actions {
@@ -2039,3 +2051,60 @@ html, body, #root {
opacity: 0.5;
cursor: not-allowed;
}
+
+/* ── Autocomplete ─────────────────────────────────────────────── */
+
+.cm-tooltip-autocomplete {
+ background: var(--bg-primary) !important;
+ border: 1px solid var(--border) !important;
+ border-radius: var(--radius) !important;
+ box-shadow: var(--shadow-md) !important;
+ font-family: var(--font-mono) !important;
+ font-size: 12.5px !important;
+}
+
+.cm-tooltip-autocomplete > ul {
+ max-height: 250px !important;
+}
+
+.cm-tooltip-autocomplete > ul > li {
+ padding: 3px 8px !important;
+ color: var(--text-primary) !important;
+ line-height: 1.5;
+}
+
+.cm-tooltip-autocomplete > ul > li[aria-selected] {
+ background: var(--accent-blue) !important;
+ color: #fff !important;
+}
+
+.cm-tooltip-autocomplete .cm-completionLabel {
+ font-weight: 500;
+}
+
+.cm-tooltip-autocomplete .cm-completionDetail {
+ color: var(--text-muted);
+ font-style: italic;
+ margin-left: 8px;
+ font-size: 11.5px;
+}
+
+.cm-tooltip-autocomplete > ul > li[aria-selected] .cm-completionDetail {
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.cm-tooltip-autocomplete .cm-completionIcon {
+ padding: 0 4px 0 0;
+ opacity: 0.6;
+}
+
+.cm-completionInfo {
+ background: var(--bg-secondary) !important;
+ border: 1px solid var(--border) !important;
+ border-radius: var(--radius) !important;
+ color: var(--text-primary) !important;
+ padding: 6px 10px !important;
+ font-family: var(--font-sans) !important;
+ font-size: 12px !important;
+ max-width: 350px;
+}
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 1eb1d76..fb24a36 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -242,6 +242,21 @@ export default function App() {
}
}
}
+
+ // Pre-load .bib files in background for citation autocomplete
+ const st = useAppStore.getState()
+ for (const [docId, relPath] of Object.entries(st.docPathMap)) {
+ if (relPath.endsWith('.bib') && !st.fileContents[relPath]) {
+ window.api.otJoinDoc(docId).then((res) => {
+ if (res.success && res.content !== undefined) {
+ useAppStore.getState().setFileContent(relPath, res.content)
+ if (res.version !== undefined) {
+ useAppStore.getState().setDocVersion(docId, res.version)
+ }
+ }
+ }).catch(() => {})
+ }
+ }
}
const handleBackToProjects = async () => {
@@ -271,7 +286,24 @@ export default function App() {
<div className="welcome-screen">
<div className="welcome-drag-bar" />
<div className="welcome-content">
- <h1>ClaudeTeX</h1>
+ <div className="welcome-logo">
+ <svg viewBox="0 0 512 512" width="96" height="96">
+ <defs>
+ <linearGradient id="wcG" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="#FFF8E7"/><stop offset="100%" stopColor="#EDE5CE"/></linearGradient>
+ <linearGradient id="wcC" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="#A0782C"/><stop offset="100%" stopColor="#7A5A1E"/></linearGradient>
+ </defs>
+ <circle cx="256" cy="256" r="240" fill="#6B5B3E"/>
+ <path d="M130 190 Q124 390 185 405 L327 405 Q388 390 382 190 Z" fill="url(#wcG)"/>
+ <ellipse cx="256" cy="190" rx="126" ry="36" fill="url(#wcC)"/>
+ <path d="M256 175 Q240 165 232 172 Q224 180 236 192 L256 208 L276 192 Q288 180 280 172 Q272 165 256 175Z" fill="#D4B880" opacity="0.6"/>
+ <ellipse cx="256" cy="190" rx="126" ry="36" fill="none" stroke="#D6CEBC" strokeWidth="3"/>
+ <path d="M382 230 Q435 235 438 300 Q440 365 390 370" fill="none" stroke="#FFF8E7" strokeWidth="12" strokeLinecap="round"/>
+ <path d="M216 148 Q210 118 220 95" fill="none" stroke="#FFF8E7" strokeWidth="5" strokeLinecap="round" opacity="0.5"/>
+ <path d="M256 140 Q250 105 260 80" fill="none" stroke="#FFF8E7" strokeWidth="5" strokeLinecap="round" opacity="0.45"/>
+ <path d="M296 148 Q290 118 300 95" fill="none" stroke="#FFF8E7" strokeWidth="5" strokeLinecap="round" opacity="0.4"/>
+ </svg>
+ </div>
+ <h1>Latte<span className="lattex-x">X</span></h1>
<p>LaTeX editor with real-time Overleaf sync</p>
<button className="btn btn-primary btn-large" onClick={handleLogin}>
Sign in to Overleaf
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
index 245fd35..362c705 100644
--- a/src/renderer/src/components/Editor.tsx
+++ b/src/renderer/src/components/Editor.tsx
@@ -3,7 +3,7 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLi
import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { bracketMatching, foldGutter, indentOnInput, StreamLanguage } from '@codemirror/language'
-import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'
+import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { stex } from '@codemirror/legacy-modes/mode/stex'
import { useAppStore } from '../stores/appStore'
@@ -17,6 +17,7 @@ import {
import { addCommentTooltip, setAddCommentCallback } from '../extensions/addCommentTooltip'
import { otSyncExtension, remoteUpdateAnnotation } from '../extensions/otSyncExtension'
import { remoteCursorsExtension, setRemoteCursorsEffect, type RemoteCursor } from '../extensions/remoteCursors'
+import { latexAutocomplete } from '../extensions/latexAutocomplete'
import { OverleafDocSync } from '../ot/overleafSync'
import { activeDocSyncs, remoteCursors } from '../App'
@@ -213,12 +214,14 @@ export default function Editor() {
...defaultKeymap,
...historyKeymap,
...closeBracketsKeymap,
+ ...completionKeymap,
...searchKeymap,
indentWithTab
]),
cosmicLatteTheme,
updateListener,
EditorView.lineWrapping,
+ latexAutocomplete(),
commentHighlights(),
overleafProjectId ? addCommentTooltip() : [],
...otExt,
diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx
index ea2820a..cbfc857 100644
--- a/src/renderer/src/components/PdfViewer.tsx
+++ b/src/renderer/src/components/PdfViewer.tsx
@@ -266,6 +266,21 @@ export default function PdfViewer() {
}
}, [pdfPath, scale, tab])
+ // Scroll wheel zoom on PDF container
+ useEffect(() => {
+ const container = containerRef.current
+ if (!container) return
+ const handleWheel = (e: WheelEvent) => {
+ if (!(e.ctrlKey || e.metaKey)) return
+ e.preventDefault()
+ // Proportional delta clamped — smooth for trackpad pinch, reasonable for mouse wheel
+ const delta = Math.max(-0.2, Math.min(0.2, -e.deltaY * 0.005))
+ setScale((s) => Math.min(3, Math.max(0.25, +(s + delta).toFixed(2))))
+ }
+ container.addEventListener('wheel', handleWheel, { passive: false })
+ return () => container.removeEventListener('wheel', handleWheel)
+ }, [])
+
// Attach double-click listener to PDF container
useEffect(() => {
const container = containerRef.current
diff --git a/src/renderer/src/components/ProjectList.tsx b/src/renderer/src/components/ProjectList.tsx
index 170e3ea..ea71f2b 100644
--- a/src/renderer/src/components/ProjectList.tsx
+++ b/src/renderer/src/components/ProjectList.tsx
@@ -194,7 +194,7 @@ export default function ProjectList({ onOpenProject }: Props) {
<div className="projects-drag-bar" />
<div className="projects-container">
<div className="projects-header">
- <h1>ClaudeTeX</h1>
+ <h1>Latte<span className="lattex-x">X</span></h1>
<div className="projects-header-actions">
<button className="btn btn-secondary btn-sm" onClick={handleLogout}>
Sign out
diff --git a/src/renderer/src/data/latexCommands.ts b/src/renderer/src/data/latexCommands.ts
new file mode 100644
index 0000000..2e183b1
--- /dev/null
+++ b/src/renderer/src/data/latexCommands.ts
@@ -0,0 +1,300 @@
+// Static LaTeX command/snippet completions
+// Each entry: [command, snippet (with $1 tab stops), detail]
+// Snippet syntax: $1, $2 etc are tab stops, ${1:placeholder} has default text
+
+export interface LatexCommand {
+ label: string // e.g. "\\frac"
+ snippet?: string // e.g. "\\frac{$1}{$2}" — if absent, label is used as-is
+ detail?: string // short description
+ section?: string // category for grouping
+}
+
+export const latexCommands: LatexCommand[] = [
+ // ── Document structure ──
+ { label: '\\documentclass', snippet: '\\documentclass{${1:article}}', detail: 'Document class', section: 'structure' },
+ { label: '\\usepackage', snippet: '\\usepackage{$1}', detail: 'Load package', section: 'structure' },
+ { label: '\\usepackage[]', snippet: '\\usepackage[${1:options}]{${2:package}}', detail: 'Load package with options', section: 'structure' },
+ { label: '\\title', snippet: '\\title{$1}', detail: 'Document title', section: 'structure' },
+ { label: '\\author', snippet: '\\author{$1}', detail: 'Document author', section: 'structure' },
+ { label: '\\date', snippet: '\\date{$1}', detail: 'Document date', section: 'structure' },
+ { label: '\\maketitle', detail: 'Print title block', section: 'structure' },
+ { label: '\\tableofcontents', detail: 'Table of contents', section: 'structure' },
+ { label: '\\listoffigures', detail: 'List of figures', section: 'structure' },
+ { label: '\\listoftables', detail: 'List of tables', section: 'structure' },
+ { label: '\\appendix', detail: 'Start appendix', section: 'structure' },
+ { label: '\\bibliography', snippet: '\\bibliography{$1}', detail: 'Bibliography file', section: 'structure' },
+ { label: '\\bibliographystyle', snippet: '\\bibliographystyle{${1:plain}}', detail: 'Bibliography style', section: 'structure' },
+
+ // ── Sectioning ──
+ { label: '\\part', snippet: '\\part{$1}', detail: 'Part heading', section: 'sectioning' },
+ { label: '\\chapter', snippet: '\\chapter{$1}', detail: 'Chapter heading', section: 'sectioning' },
+ { label: '\\section', snippet: '\\section{$1}', detail: 'Section heading', section: 'sectioning' },
+ { label: '\\subsection', snippet: '\\subsection{$1}', detail: 'Subsection heading', section: 'sectioning' },
+ { label: '\\subsubsection', snippet: '\\subsubsection{$1}', detail: 'Subsubsection heading', section: 'sectioning' },
+ { label: '\\paragraph', snippet: '\\paragraph{$1}', detail: 'Paragraph heading', section: 'sectioning' },
+ { label: '\\subparagraph', snippet: '\\subparagraph{$1}', detail: 'Subparagraph heading', section: 'sectioning' },
+ { label: '\\section*', snippet: '\\section*{$1}', detail: 'Unnumbered section', section: 'sectioning' },
+ { label: '\\subsection*', snippet: '\\subsection*{$1}', detail: 'Unnumbered subsection', section: 'sectioning' },
+
+ // ── Text formatting ──
+ { label: '\\textbf', snippet: '\\textbf{$1}', detail: 'Bold text', section: 'formatting' },
+ { label: '\\textit', snippet: '\\textit{$1}', detail: 'Italic text', section: 'formatting' },
+ { label: '\\texttt', snippet: '\\texttt{$1}', detail: 'Monospace text', section: 'formatting' },
+ { label: '\\textsc', snippet: '\\textsc{$1}', detail: 'Small caps', section: 'formatting' },
+ { label: '\\textrm', snippet: '\\textrm{$1}', detail: 'Roman text', section: 'formatting' },
+ { label: '\\textsf', snippet: '\\textsf{$1}', detail: 'Sans serif text', section: 'formatting' },
+ { label: '\\textsl', snippet: '\\textsl{$1}', detail: 'Slanted text', section: 'formatting' },
+ { label: '\\emph', snippet: '\\emph{$1}', detail: 'Emphasized text', section: 'formatting' },
+ { label: '\\underline', snippet: '\\underline{$1}', detail: 'Underline', section: 'formatting' },
+ { label: '\\textcolor', snippet: '\\textcolor{${1:color}}{${2:text}}', detail: 'Colored text', section: 'formatting' },
+ { label: '\\colorbox', snippet: '\\colorbox{${1:color}}{${2:text}}', detail: 'Color box', section: 'formatting' },
+ { label: '\\tiny', detail: 'Tiny size', section: 'formatting' },
+ { label: '\\scriptsize', detail: 'Script size', section: 'formatting' },
+ { label: '\\footnotesize', detail: 'Footnote size', section: 'formatting' },
+ { label: '\\small', detail: 'Small size', section: 'formatting' },
+ { label: '\\normalsize', detail: 'Normal size', section: 'formatting' },
+ { label: '\\large', detail: 'Large size', section: 'formatting' },
+ { label: '\\Large', detail: 'Larger size', section: 'formatting' },
+ { label: '\\LARGE', detail: 'Even larger', section: 'formatting' },
+ { label: '\\huge', detail: 'Huge size', section: 'formatting' },
+ { label: '\\Huge', detail: 'Hugest size', section: 'formatting' },
+
+ // ── References & citations ──
+ { label: '\\label', snippet: '\\label{$1}', detail: 'Set label', section: 'ref' },
+ { label: '\\ref', snippet: '\\ref{$1}', detail: 'Reference', section: 'ref' },
+ { label: '\\eqref', snippet: '\\eqref{$1}', detail: 'Equation reference', section: 'ref' },
+ { label: '\\pageref', snippet: '\\pageref{$1}', detail: 'Page reference', section: 'ref' },
+ { label: '\\autoref', snippet: '\\autoref{$1}', detail: 'Auto reference (hyperref)', section: 'ref' },
+ { label: '\\cref', snippet: '\\cref{$1}', detail: 'Clever reference (cleveref)', section: 'ref' },
+ { label: '\\Cref', snippet: '\\Cref{$1}', detail: 'Clever ref capitalized', section: 'ref' },
+ { label: '\\cite', snippet: '\\cite{$1}', detail: 'Citation', section: 'ref' },
+ { label: '\\cite[]', snippet: '\\cite[${1:note}]{${2:key}}', detail: 'Citation with note', section: 'ref' },
+ { label: '\\citep', snippet: '\\citep{$1}', detail: 'Parenthetical citation', section: 'ref' },
+ { label: '\\citet', snippet: '\\citet{$1}', detail: 'Textual citation', section: 'ref' },
+ { label: '\\citep[]', snippet: '\\citep[${1:note}]{${2:key}}', detail: 'Parenthetical cite+note', section: 'ref' },
+ { label: '\\citeauthor', snippet: '\\citeauthor{$1}', detail: 'Cite author', section: 'ref' },
+ { label: '\\citeyear', snippet: '\\citeyear{$1}', detail: 'Cite year', section: 'ref' },
+ { label: '\\footnote', snippet: '\\footnote{$1}', detail: 'Footnote', section: 'ref' },
+
+ // ── File inclusion ──
+ { label: '\\input', snippet: '\\input{$1}', detail: 'Input file', section: 'include' },
+ { label: '\\include', snippet: '\\include{$1}', detail: 'Include file', section: 'include' },
+ { label: '\\includegraphics', snippet: '\\includegraphics{$1}', detail: 'Include image', section: 'include' },
+ { label: '\\includegraphics[]', snippet: '\\includegraphics[${1:width=\\textwidth}]{$2}', detail: 'Include image with options', section: 'include' },
+ { label: '\\includeonly', snippet: '\\includeonly{$1}', detail: 'Include only', section: 'include' },
+
+ // ── Math ──
+ { label: '\\frac', snippet: '\\frac{$1}{$2}', detail: 'Fraction', section: 'math' },
+ { label: '\\dfrac', snippet: '\\dfrac{$1}{$2}', detail: 'Display fraction', section: 'math' },
+ { 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: '\\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: '\\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' },
+ { label: '\\mathrm', snippet: '\\mathrm{$1}', detail: 'Roman in math', section: 'math' },
+ { label: '\\mathbf', snippet: '\\mathbf{$1}', detail: 'Bold in math', section: 'math' },
+ { label: '\\mathit', snippet: '\\mathit{$1}', detail: 'Italic in math', section: 'math' },
+ { label: '\\text', snippet: '\\text{$1}', detail: 'Text in math', section: 'math' },
+ { label: '\\hat', snippet: '\\hat{$1}', detail: 'Hat accent', section: 'math' },
+ { label: '\\bar', snippet: '\\bar{$1}', detail: 'Bar accent', section: 'math' },
+ { label: '\\vec', snippet: '\\vec{$1}', detail: 'Vector accent', section: 'math' },
+ { label: '\\dot', snippet: '\\dot{$1}', detail: 'Dot accent', section: 'math' },
+ { label: '\\ddot', snippet: '\\ddot{$1}', detail: 'Double dot accent', section: 'math' },
+ { label: '\\tilde', snippet: '\\tilde{$1}', detail: 'Tilde accent', section: 'math' },
+ { label: '\\overline', snippet: '\\overline{$1}', detail: 'Overline', section: 'math' },
+ { label: '\\overbrace', snippet: '\\overbrace{$1}^{$2}', detail: 'Overbrace', section: 'math' },
+ { label: '\\underbrace', snippet: '\\underbrace{$1}_{$2}', detail: 'Underbrace', section: 'math' },
+ { label: '\\binom', snippet: '\\binom{$1}{$2}', detail: 'Binomial', section: 'math' },
+ { label: '\\left', snippet: '\\left${1:(} $2 \\right${3:)}', detail: 'Left delimiter', section: 'math' },
+ { 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' },
+
+ // ── Environments (as commands) ──
+ { label: '\\begin', snippet: '\\begin{$1}\n\t$2\n\\end{$1}', detail: 'Begin environment', section: 'env' },
+ { label: '\\end', snippet: '\\end{$1}', detail: 'End environment', section: 'env' },
+ { label: '\\item', snippet: '\\item $1', detail: 'List item', section: 'env' },
+
+ // ── Floats & figures ──
+ { label: '\\caption', snippet: '\\caption{$1}', detail: 'Caption', section: 'float' },
+ { label: '\\centering', detail: 'Center content', section: 'float' },
+ { label: '\\hfill', detail: 'Horizontal fill', section: 'float' },
+ { label: '\\vfill', detail: 'Vertical fill', section: 'float' },
+ { label: '\\hspace', snippet: '\\hspace{$1}', detail: 'Horizontal space', section: 'float' },
+ { label: '\\vspace', snippet: '\\vspace{$1}', detail: 'Vertical space', section: 'float' },
+ { label: '\\newline', detail: 'New line', section: 'float' },
+ { label: '\\linebreak', detail: 'Line break', section: 'float' },
+ { label: '\\pagebreak', detail: 'Page break', section: 'float' },
+ { label: '\\newpage', detail: 'New page', section: 'float' },
+ { label: '\\clearpage', detail: 'Clear page', section: 'float' },
+ { label: '\\noindent', detail: 'No indent', section: 'float' },
+
+ // ── Tables ──
+ { label: '\\hline', detail: 'Horizontal line', section: 'table' },
+ { label: '\\cline', snippet: '\\cline{${1:i}-${2:j}}', detail: 'Partial horizontal line', section: 'table' },
+ { label: '\\multicolumn', snippet: '\\multicolumn{${1:cols}}{${2:align}}{${3:text}}', detail: 'Multi column', section: 'table' },
+ { label: '\\multirow', snippet: '\\multirow{${1:rows}}{${2:width}}{${3:text}}', detail: 'Multi row', section: 'table' },
+ { label: '\\toprule', detail: 'Top rule (booktabs)', section: 'table' },
+ { label: '\\midrule', detail: 'Mid rule (booktabs)', section: 'table' },
+ { label: '\\bottomrule', detail: 'Bottom rule (booktabs)', section: 'table' },
+
+ // ── Miscellaneous ──
+ { label: '\\newcommand', snippet: '\\newcommand{\\${1:name}}[${2:args}]{$3}', detail: 'Define command', section: 'misc' },
+ { label: '\\renewcommand', snippet: '\\renewcommand{\\${1:name}}[${2:args}]{$3}', detail: 'Redefine command', section: 'misc' },
+ { label: '\\newenvironment', snippet: '\\newenvironment{${1:name}}{$2}{$3}', detail: 'Define environment', section: 'misc' },
+ { label: '\\def', snippet: '\\def\\${1:name}{$2}', detail: 'TeX definition', section: 'misc' },
+ { label: '\\let', snippet: '\\let\\${1:new}\\${2:old}', detail: 'TeX let', section: 'misc' },
+ { label: '\\url', snippet: '\\url{$1}', detail: 'URL', section: 'misc' },
+ { label: '\\href', snippet: '\\href{${1:url}}{${2:text}}', detail: 'Hyperlink', section: 'misc' },
+ { label: '\\hyperref', snippet: '\\hyperref[${1:label}]{${2:text}}', detail: 'Hyperref link', section: 'misc' },
+ { label: '\\phantom', snippet: '\\phantom{$1}', detail: 'Invisible space', section: 'misc' },
+ { label: '\\mbox', snippet: '\\mbox{$1}', detail: 'Horizontal box', section: 'misc' },
+ { label: '\\makebox', snippet: '\\makebox[${1:width}]{$2}', detail: 'Make box', section: 'misc' },
+ { label: '\\framebox', snippet: '\\framebox{$1}', detail: 'Framed box', section: 'misc' },
+ { label: '\\fbox', snippet: '\\fbox{$1}', detail: 'Framed box', section: 'misc' },
+ { label: '\\parbox', snippet: '\\parbox{${1:width}}{$2}', detail: 'Paragraph box', section: 'misc' },
+ { label: '\\minipage', snippet: '\\begin{minipage}{${1:width}}\n\t$2\n\\end{minipage}', detail: 'Mini page', section: 'misc' },
+
+ // ── Math operators ──
+ { label: '\\sin', detail: 'Sine', section: 'mathop' },
+ { label: '\\cos', detail: 'Cosine', section: 'mathop' },
+ { label: '\\tan', detail: 'Tangent', section: 'mathop' },
+ { label: '\\sec', detail: 'Secant', section: 'mathop' },
+ { label: '\\csc', detail: 'Cosecant', section: 'mathop' },
+ { label: '\\cot', detail: 'Cotangent', section: 'mathop' },
+ { label: '\\arcsin', detail: 'Arcsine', section: 'mathop' },
+ { label: '\\arccos', detail: 'Arccosine', section: 'mathop' },
+ { label: '\\arctan', detail: 'Arctangent', section: 'mathop' },
+ { label: '\\sinh', detail: 'Hyperbolic sine', section: 'mathop' },
+ { label: '\\cosh', detail: 'Hyperbolic cosine', section: 'mathop' },
+ { label: '\\tanh', detail: 'Hyperbolic tangent', section: 'mathop' },
+ { label: '\\log', detail: 'Logarithm', section: 'mathop' },
+ { label: '\\ln', detail: 'Natural log', section: 'mathop' },
+ { label: '\\exp', detail: 'Exponential', section: 'mathop' },
+ { label: '\\det', detail: 'Determinant', section: 'mathop' },
+ { label: '\\dim', detail: 'Dimension', section: 'mathop' },
+ { label: '\\ker', detail: 'Kernel', section: 'mathop' },
+ { label: '\\hom', detail: 'Homomorphism', section: 'mathop' },
+ { label: '\\deg', detail: 'Degree', section: 'mathop' },
+ { label: '\\max', detail: 'Maximum', section: 'mathop' },
+ { label: '\\min', detail: 'Minimum', section: 'mathop' },
+ { label: '\\sup', detail: 'Supremum', section: 'mathop' },
+ { label: '\\inf', detail: 'Infimum', section: 'mathop' },
+ { label: '\\arg', detail: 'Argument', section: 'mathop' },
+ { label: '\\gcd', detail: 'GCD', section: 'mathop' },
+ { label: '\\mod', detail: 'Modulo', section: 'mathop' },
+ { label: '\\operatorname', snippet: '\\operatorname{$1}', detail: 'Custom operator', section: 'mathop' },
+
+ // ── Math environments shortcuts ──
+ { label: '\\[', snippet: '\\[\n\t$1\n\\]', detail: 'Display math', section: 'math' },
+ { label: '\\(', snippet: '\\($1\\)', detail: 'Inline math', section: 'math' },
+
+ // ── AMS math ──
+ { label: '\\align', snippet: '\\begin{align}\n\t$1\n\\end{align}', detail: 'Align environment', section: 'ams' },
+ { label: '\\equation', snippet: '\\begin{equation}\n\t$1\n\\end{equation}', detail: 'Equation environment', section: 'ams' },
+ { label: '\\gather', snippet: '\\begin{gather}\n\t$1\n\\end{gather}', detail: 'Gather environment', section: 'ams' },
+ { label: '\\cases', snippet: '\\begin{cases}\n\t$1\n\\end{cases}', detail: 'Cases', section: 'ams' },
+ { label: '\\matrix', snippet: '\\begin{matrix}\n\t$1\n\\end{matrix}', detail: 'Matrix', section: 'ams' },
+ { label: '\\pmatrix', snippet: '\\begin{pmatrix}\n\t$1\n\\end{pmatrix}', detail: 'Parenthesized matrix', section: 'ams' },
+ { label: '\\bmatrix', snippet: '\\begin{bmatrix}\n\t$1\n\\end{bmatrix}', detail: 'Bracketed matrix', section: 'ams' },
+
+ // ── TikZ basics ──
+ { label: '\\draw', snippet: '\\draw $1;', detail: 'TikZ draw', section: 'tikz' },
+ { label: '\\fill', snippet: '\\fill $1;', detail: 'TikZ fill', section: 'tikz' },
+ { label: '\\node', snippet: '\\node[${1:options}] at (${2:0,0}) {$3};', detail: 'TikZ node', section: 'tikz' },
+ { label: '\\coordinate', snippet: '\\coordinate (${1:name}) at (${2:0,0});', detail: 'TikZ coordinate', section: 'tikz' },
+ { label: '\\path', snippet: '\\path $1;', detail: 'TikZ path', section: 'tikz' },
+
+ // ── Theorem-like ──
+ { label: '\\newtheorem', snippet: '\\newtheorem{${1:name}}{${2:Theorem}}', detail: 'New theorem', section: 'thm' },
+ { label: '\\proof', snippet: '\\begin{proof}\n\t$1\n\\end{proof}', detail: 'Proof environment', section: 'thm' },
+]
diff --git a/src/renderer/src/data/latexEnvironments.ts b/src/renderer/src/data/latexEnvironments.ts
new file mode 100644
index 0000000..ff6fab6
--- /dev/null
+++ b/src/renderer/src/data/latexEnvironments.ts
@@ -0,0 +1,98 @@
+// Static LaTeX environment names for \begin{} completion
+// Each entry: [name, detail, snippet body (optional)]
+
+export interface LatexEnvironment {
+ name: string
+ detail?: string
+ body?: string // default body inside the environment (e.g. column spec for tabular)
+}
+
+export const latexEnvironments: LatexEnvironment[] = [
+ // ── Document ──
+ { name: 'document', detail: 'Main document body' },
+
+ // ── Lists ──
+ { name: 'itemize', detail: 'Unordered list', body: '\\item $1' },
+ { name: 'enumerate', detail: 'Ordered list', body: '\\item $1' },
+ { name: 'description', detail: 'Description list', body: '\\item[$1] $2' },
+
+ // ── Math ──
+ { name: 'equation', detail: 'Numbered equation' },
+ { name: 'equation*', detail: 'Unnumbered equation' },
+ { name: 'align', detail: 'Aligned equations' },
+ { name: 'align*', detail: 'Aligned equations (unnumbered)' },
+ { name: 'gather', detail: 'Gathered equations' },
+ { name: 'gather*', detail: 'Gathered equations (unnumbered)' },
+ { name: 'multline', detail: 'Multi-line equation' },
+ { name: 'multline*', detail: 'Multi-line (unnumbered)' },
+ { name: 'split', detail: 'Split equation' },
+ { name: 'flalign', detail: 'Full-width align' },
+ { name: 'flalign*', detail: 'Full-width align (unnumbered)' },
+ { name: 'alignat', detail: 'Align at columns' },
+ { name: 'alignat*', detail: 'Align at (unnumbered)' },
+ { name: 'math', detail: 'Inline math environment' },
+ { name: 'displaymath', detail: 'Display math environment' },
+ { name: 'cases', detail: 'Piecewise cases' },
+
+ // ── Matrices ──
+ { name: 'matrix', detail: 'Plain matrix' },
+ { name: 'pmatrix', detail: 'Parenthesized matrix' },
+ { name: 'bmatrix', detail: 'Bracketed matrix' },
+ { name: 'Bmatrix', detail: 'Braced matrix' },
+ { name: 'vmatrix', detail: 'Determinant matrix' },
+ { name: 'Vmatrix', detail: 'Double-bar matrix' },
+ { name: 'smallmatrix', detail: 'Small inline matrix' },
+
+ // ── Tables ──
+ { name: 'tabular', detail: 'Table', body: '{${1:lll}}\n\\hline\n$2 \\\\\\\\\n\\hline' },
+ { name: 'tabular*', detail: 'Table with width' },
+ { name: 'tabularx', detail: 'Table with X columns' },
+ { name: 'longtable', detail: 'Multi-page table' },
+ { name: 'array', detail: 'Math array', body: '{${1:lll}}\n$2' },
+
+ // ── Floats ──
+ { name: 'figure', detail: 'Figure float', body: '\\centering\n\\includegraphics[width=\\textwidth]{$1}\n\\caption{$2}\n\\label{fig:$3}' },
+ { name: 'figure*', detail: 'Full-width figure' },
+ { name: 'table', detail: 'Table float', body: '\\centering\n\\caption{$1}\n\\label{tab:$2}\n\\begin{tabular}{${3:lll}}\n\\hline\n$4 \\\\\\\\\n\\hline\n\\end{tabular}' },
+ { name: 'table*', detail: 'Full-width table float' },
+
+ // ── Text layout ──
+ { name: 'center', detail: 'Centered text' },
+ { name: 'flushleft', detail: 'Left-aligned text' },
+ { name: 'flushright', detail: 'Right-aligned text' },
+ { name: 'minipage', detail: 'Mini page', body: '{${1:\\textwidth}}\n$2' },
+ { name: 'quote', detail: 'Indented quote' },
+ { name: 'quotation', detail: 'Indented quotation' },
+ { name: 'verse', detail: 'Verse' },
+ { name: 'abstract', detail: 'Abstract' },
+ { name: 'verbatim', detail: 'Verbatim text' },
+
+ // ── Frames / boxes ──
+ { name: 'frame', detail: 'Beamer frame', body: '{${1:Title}}\n$2' },
+ { name: 'block', detail: 'Beamer block', body: '{${1:Title}}\n$2' },
+ { name: 'columns', detail: 'Beamer columns' },
+ { name: 'column', detail: 'Beamer column', body: '{${1:0.5\\textwidth}}\n$2' },
+
+ // ── Code / listings ──
+ { name: 'lstlisting', detail: 'Code listing' },
+ { name: 'minted', detail: 'Minted code', body: '{${1:python}}\n$2' },
+
+ // ── TikZ ──
+ { name: 'tikzpicture', detail: 'TikZ picture' },
+ { name: 'scope', detail: 'TikZ scope' },
+
+ // ── Theorem-like ──
+ { name: 'theorem', detail: 'Theorem' },
+ { name: 'lemma', detail: 'Lemma' },
+ { name: 'corollary', detail: 'Corollary' },
+ { name: 'proposition', detail: 'Proposition' },
+ { name: 'definition', detail: 'Definition' },
+ { name: 'example', detail: 'Example' },
+ { name: 'remark', detail: 'Remark' },
+ { name: 'proof', detail: 'Proof' },
+
+ // ── Misc ──
+ { name: 'thebibliography', detail: 'Bibliography', body: '{${1:99}}\n\\bibitem{$2} $3' },
+ { name: 'appendix', detail: 'Appendix' },
+ { name: 'titlepage', detail: 'Title page' },
+]
diff --git a/src/renderer/src/extensions/commentHighlights.ts b/src/renderer/src/extensions/commentHighlights.ts
index 115c8fc..3994060 100644
--- a/src/renderer/src/extensions/commentHighlights.ts
+++ b/src/renderer/src/extensions/commentHighlights.ts
@@ -48,6 +48,15 @@ export const commentRangesField = StateField.define<CommentRange[]>({
return effect.value
}
}
+ // Remap positions through document changes so they stay in sync
+ if (tr.docChanged && ranges.length > 0) {
+ const docLen = tr.newDoc.length
+ return ranges.map(r => {
+ const from = Math.min(tr.changes.mapPos(r.from, 1), docLen)
+ const to = Math.min(tr.changes.mapPos(r.to, -1), docLen)
+ return { ...r, from, to }
+ }).filter(r => r.from < r.to)
+ }
return ranges
},
})
@@ -84,17 +93,19 @@ const focusedThreadField = StateField.define<string | null>({
// ── Decoration Builders ────────────────────────────────────────
-function buildCommentDecorations(ranges: CommentRange[]): DecorationSet {
+function buildCommentDecorations(ranges: CommentRange[], docLen?: number): DecorationSet {
if (ranges.length === 0) return Decoration.none
const decorations = []
for (const r of ranges) {
- if (r.from >= r.to) continue
+ const from = docLen !== undefined ? Math.min(r.from, docLen) : r.from
+ const to = docLen !== undefined ? Math.min(r.to, docLen) : r.to
+ if (from >= to || from < 0) continue
decorations.push(
Decoration.mark({
class: 'cm-comment-highlight',
attributes: { 'data-thread-id': r.threadId },
- }).range(r.from, r.to)
+ }).range(from, to)
)
}
// Must be sorted by from position
@@ -102,21 +113,27 @@ function buildCommentDecorations(ranges: CommentRange[]): DecorationSet {
return Decoration.set(decorations, true)
}
-function buildHighlightDecoration(ranges: CommentRange[], threadId: string | null): DecorationSet {
+function buildHighlightDecoration(ranges: CommentRange[], threadId: string | null, docLen?: number): DecorationSet {
if (!threadId) return Decoration.none
const r = ranges.find(c => c.threadId === threadId)
- if (!r || r.from >= r.to) return Decoration.none
+ if (!r) return Decoration.none
+ const from = docLen !== undefined ? Math.min(r.from, docLen) : r.from
+ const to = docLen !== undefined ? Math.min(r.to, docLen) : r.to
+ if (from >= to || from < 0) return Decoration.none
return Decoration.set([
- Decoration.mark({ class: 'cm-comment-highlight-hover' }).range(r.from, r.to)
+ Decoration.mark({ class: 'cm-comment-highlight-hover' }).range(from, to)
])
}
-function buildFocusDecoration(ranges: CommentRange[], threadId: string | null): DecorationSet {
+function buildFocusDecoration(ranges: CommentRange[], threadId: string | null, docLen?: number): DecorationSet {
if (!threadId) return Decoration.none
const r = ranges.find(c => c.threadId === threadId)
- if (!r || r.from >= r.to) return Decoration.none
+ if (!r) return Decoration.none
+ const from = docLen !== undefined ? Math.min(r.from, docLen) : r.from
+ const to = docLen !== undefined ? Math.min(r.to, docLen) : r.to
+ if (from >= to || from < 0) return Decoration.none
return Decoration.set([
- Decoration.mark({ class: 'cm-comment-highlight-focus' }).range(r.from, r.to)
+ Decoration.mark({ class: 'cm-comment-highlight-focus' }).range(from, to)
])
}
@@ -131,7 +148,7 @@ const commentDecorationsPlugin = ViewPlugin.define<PluginValue & { decorations:
this.decorations = this.decorations.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setCommentRangesEffect)) {
- this.decorations = buildCommentDecorations(effect.value)
+ this.decorations = buildCommentDecorations(effect.value, update.state.doc.length)
}
}
}
@@ -142,7 +159,7 @@ const commentDecorationsPlugin = ViewPlugin.define<PluginValue & { decorations:
/** Hover highlight decoration (stronger yellow, from ReviewPanel hover) */
const hoverHighlightPlugin = ViewPlugin.define<PluginValue & { decorations: DecorationSet }>(
- (view) => ({
+ () => ({
decorations: Decoration.none,
update(update) {
for (const tr of update.transactions) {
@@ -150,7 +167,7 @@ const hoverHighlightPlugin = ViewPlugin.define<PluginValue & { decorations: Deco
if (effect.is(highlightThreadEffect) || effect.is(setCommentRangesEffect)) {
const ranges = update.state.field(commentRangesField)
const threadId = update.state.field(highlightedThreadField)
- this.decorations = buildHighlightDecoration(ranges, threadId)
+ this.decorations = buildHighlightDecoration(ranges, threadId, update.state.doc.length)
return
}
}
@@ -187,7 +204,7 @@ const focusHighlightPlugin = ViewPlugin.define<PluginValue & { decorations: Deco
}
}
- this.decorations = buildFocusDecoration(ranges, foundThreadId)
+ this.decorations = buildFocusDecoration(ranges, foundThreadId, update.state.doc.length)
},
}),
{ decorations: (v) => v.decorations }
diff --git a/src/renderer/src/extensions/latexAutocomplete.ts b/src/renderer/src/extensions/latexAutocomplete.ts
new file mode 100644
index 0000000..d5f8c75
--- /dev/null
+++ b/src/renderer/src/extensions/latexAutocomplete.ts
@@ -0,0 +1,349 @@
+import {
+ autocompletion,
+ type CompletionContext,
+ type CompletionResult,
+ type Completion,
+ snippetCompletion,
+} from '@codemirror/autocomplete'
+import { latexCommands } from '../data/latexCommands'
+import { latexEnvironments } from '../data/latexEnvironments'
+import { useAppStore } from '../stores/appStore'
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+/** Check if cursor is inside a \begin{...} or \end{...} brace */
+function getEnvironmentContext(context: CompletionContext): { from: number; typed: string } | null {
+ const line = context.state.doc.lineAt(context.pos)
+ const textBefore = line.text.slice(0, context.pos - line.from)
+ const match = textBefore.match(/\\(?:begin|end)\{([^}]*)$/)
+ if (match) {
+ return { from: context.pos - match[1].length, typed: match[1] }
+ }
+ return null
+}
+
+/** Check if cursor is inside a \ref-like{...} brace */
+function getRefContext(context: CompletionContext): { from: number; typed: string } | null {
+ const line = context.state.doc.lineAt(context.pos)
+ const textBefore = line.text.slice(0, context.pos - line.from)
+ const match = textBefore.match(/\\(?:ref|eqref|pageref|autoref|cref|Cref|nameref|vref)\{([^}]*)$/)
+ if (match) {
+ return { from: context.pos - match[1].length, typed: match[1] }
+ }
+ return null
+}
+
+/** Check if cursor is inside a \cite-like{...} brace (supports multiple keys: \cite{a,b,...}) */
+function getCiteContext(context: CompletionContext): { from: number; typed: string } | null {
+ const line = context.state.doc.lineAt(context.pos)
+ const textBefore = line.text.slice(0, context.pos - line.from)
+ // Match \cite{key1,key2,partial or \cite[note]{partial or \citep{partial etc.
+ const match = textBefore.match(/\\(?:cite|citep|citet|citealt|citealp|citeauthor|citeyear|Cite|parencite|textcite|autocite|fullcite|footcite|nocite)(?:\[[^\]]*\])?\{([^}]*)$/)
+ if (match) {
+ const inside = match[1]
+ // Find the last comma to support multi-key citations
+ const lastComma = inside.lastIndexOf(',')
+ const typed = lastComma >= 0 ? inside.slice(lastComma + 1).trimStart() : inside
+ const from = lastComma >= 0
+ ? context.pos - inside.length + lastComma + 1 + (inside.slice(lastComma + 1).length - inside.slice(lastComma + 1).trimStart().length)
+ : context.pos - inside.length
+ return { from, typed }
+ }
+ return null
+}
+
+/** Check if cursor is inside a file-include command brace */
+function getFileContext(context: CompletionContext): { from: number; typed: string; isGraphics: boolean } | null {
+ const line = context.state.doc.lineAt(context.pos)
+ const textBefore = line.text.slice(0, context.pos - line.from)
+ const match = textBefore.match(/\\(input|include|includegraphics|subfile|subfileinclude)(?:\[[^\]]*\])?\{([^}]*)$/)
+ if (match) {
+ const isGraphics = match[1] === 'includegraphics'
+ return { from: context.pos - match[2].length, typed: match[2], isGraphics }
+ }
+ return null
+}
+
+// ── Scan documents for labels ────────────────────────────────────────
+
+function scanLabels(): string[] {
+ const { fileContents } = useAppStore.getState()
+ const labels = new Set<string>()
+ const labelRegex = /\\label\{([^}]+)\}/g
+ for (const content of Object.values(fileContents)) {
+ let m: RegExpExecArray | null
+ while ((m = labelRegex.exec(content)) !== null) {
+ labels.add(m[1])
+ }
+ }
+ return Array.from(labels)
+}
+
+// ── Scan .bib files for citation keys ────────────────────────────────
+
+function scanCitations(): { key: string; type: string; title?: string }[] {
+ const { fileContents } = useAppStore.getState()
+ const entries: { key: string; type: string; title?: string }[] = []
+ const seen = new Set<string>()
+ for (const [path, content] of Object.entries(fileContents)) {
+ if (!path.endsWith('.bib')) continue
+ // Match @type{key, patterns
+ const entryRegex = /@(\w+)\s*\{([^,\s]+)/g
+ let m: RegExpExecArray | null
+ while ((m = entryRegex.exec(content)) !== null) {
+ const type = m[1].toLowerCase()
+ if (type === 'string' || type === 'comment' || type === 'preamble') continue
+ const key = m[2].trim()
+ if (!seen.has(key)) {
+ seen.add(key)
+ // Try to extract title
+ const afterKey = content.slice(m.index)
+ const titleMatch = afterKey.match(/title\s*=\s*[{"]([^}"]+)/i)
+ entries.push({ key, type, title: titleMatch?.[1] })
+ }
+ }
+ }
+ return entries
+}
+
+// ── Get file paths from project tree ─────────────────────────────────
+
+function getFilePaths(isGraphics: boolean): string[] {
+ const { files } = useAppStore.getState()
+ const paths: string[] = []
+
+ const imageExts = new Set(['.png', '.jpg', '.jpeg', '.pdf', '.eps', '.svg', '.gif', '.bmp', '.tiff'])
+ const texExts = new Set(['.tex', '.sty', '.cls', '.bib', '.bbl'])
+
+ function walk(nodes: typeof files, prefix: string) {
+ for (const node of nodes) {
+ if (node.isDir) {
+ if (node.children) walk(node.children, prefix ? prefix + '/' + node.name : node.name)
+ } else {
+ const fullPath = prefix ? prefix + '/' + node.name : node.name
+ if (isGraphics) {
+ const ext = '.' + node.name.split('.').pop()?.toLowerCase()
+ if (imageExts.has(ext)) {
+ // For graphics, also offer path without extension
+ paths.push(fullPath)
+ const noExt = fullPath.replace(/\.[^.]+$/, '')
+ if (noExt !== fullPath) paths.push(noExt)
+ }
+ } else {
+ const ext = '.' + node.name.split('.').pop()?.toLowerCase()
+ if (texExts.has(ext)) {
+ paths.push(fullPath)
+ // Also offer without .tex extension (common for \input)
+ if (ext === '.tex') {
+ paths.push(fullPath.replace(/\.tex$/, ''))
+ }
+ }
+ }
+ }
+ }
+ }
+ walk(files, '')
+ return paths
+}
+
+// ── Completion Sources ───────────────────────────────────────────────
+
+/** Source 1: LaTeX commands — triggered by \ */
+function commandSource(context: CompletionContext): CompletionResult | null {
+ // Don't complete inside \begin{} or \end{} braces
+ const envCtx = getEnvironmentContext(context)
+ if (envCtx) return null
+
+ // Don't complete inside \ref{}, \cite{}, etc.
+ const refCtx = getRefContext(context)
+ if (refCtx) return null
+ const citeCtx = getCiteContext(context)
+ if (citeCtx) return null
+ const fileCtx = getFileContext(context)
+ if (fileCtx) return null
+
+ // Match \word at cursor
+ const word = context.matchBefore(/\\[a-zA-Z*]*/)
+ if (!word) return null
+ // Need at least \ + 1 char, or explicit activation
+ if (word.text.length < 2 && !context.explicit) return null
+
+ const options: Completion[] = latexCommands.map((cmd) => {
+ if (cmd.snippet) {
+ return snippetCompletion(cmd.snippet, {
+ label: cmd.label,
+ detail: cmd.detail,
+ type: 'function',
+ boost: cmd.section === 'structure' || cmd.section === 'sectioning' ? 2 : 0,
+ })
+ }
+ return {
+ label: cmd.label,
+ detail: cmd.detail,
+ type: 'function',
+ }
+ })
+
+ return {
+ from: word.from,
+ options,
+ validFor: /^\\[a-zA-Z*]*$/,
+ }
+}
+
+/** Source 2: Environment names inside \begin{} and \end{} */
+function environmentSource(context: CompletionContext): CompletionResult | null {
+ const envCtx = getEnvironmentContext(context)
+ if (!envCtx) return null
+
+ // For \end{}, try to match the most recent unclosed \begin{}
+ const line = context.state.doc.lineAt(context.pos)
+ const textBefore = line.text.slice(0, context.pos - line.from)
+ const isEnd = /\\end\{[^}]*$/.test(textBefore)
+
+ const options: Completion[] = []
+
+ if (isEnd) {
+ // Find the most recent unclosed \begin{} and suggest it first
+ const docText = context.state.doc.sliceString(0, context.pos)
+ const opens: string[] = []
+ const beginRe = /\\begin\{([^}]+)\}/g
+ const endRe = /\\end\{([^}]+)\}/g
+ let m: RegExpExecArray | null
+ while ((m = beginRe.exec(docText)) !== null) opens.push(m[1])
+ while ((m = endRe.exec(docText)) !== null) {
+ const idx = opens.lastIndexOf(m[1])
+ if (idx >= 0) opens.splice(idx, 1)
+ }
+ if (opens.length > 0) {
+ const last = opens[opens.length - 1]
+ options.push({ label: last, detail: 'Close environment', type: 'keyword', boost: 100 })
+ }
+ }
+
+ // Also add all known environments
+ for (const env of latexEnvironments) {
+ // For \begin{}, use snippet with body
+ if (!isEnd && env.body) {
+ // We can't use snippetCompletion here since we're only completing the name
+ // The body will be handled by the \begin snippet in commands
+ options.push({
+ label: env.name,
+ detail: env.detail,
+ type: 'type',
+ })
+ } else {
+ options.push({
+ label: env.name,
+ detail: env.detail,
+ type: 'type',
+ })
+ }
+ }
+
+ // Also scan the document for custom environments (defined with \newenvironment or \newtheorem)
+ const docText = context.state.doc.toString()
+ const customEnvRe = /\\(?:newenvironment|newtheorem)\{([^}]+)\}/g
+ let m: RegExpExecArray | null
+ const seen = new Set(latexEnvironments.map((e) => e.name))
+ while ((m = customEnvRe.exec(docText)) !== null) {
+ if (!seen.has(m[1])) {
+ seen.add(m[1])
+ options.push({ label: m[1], detail: 'Custom', type: 'type' })
+ }
+ }
+
+ // Also scan all open files for custom environments
+ const { fileContents } = useAppStore.getState()
+ for (const content of Object.values(fileContents)) {
+ const re = /\\(?:newenvironment|newtheorem)\{([^}]+)\}/g
+ while ((m = re.exec(content)) !== null) {
+ if (!seen.has(m[1])) {
+ seen.add(m[1])
+ options.push({ label: m[1], detail: 'Custom', type: 'type' })
+ }
+ }
+ }
+
+ return {
+ from: envCtx.from,
+ options,
+ validFor: /^[a-zA-Z*]*$/,
+ }
+}
+
+/** Source 3: Label references inside \ref{}, \eqref{}, etc. */
+function labelSource(context: CompletionContext): CompletionResult | null {
+ const refCtx = getRefContext(context)
+ if (!refCtx) return null
+
+ const labels = scanLabels()
+ const options: Completion[] = labels.map((label) => ({
+ label,
+ type: 'variable',
+ detail: 'label',
+ }))
+
+ return {
+ from: refCtx.from,
+ options,
+ validFor: /^[a-zA-Z0-9_:.-]*$/,
+ }
+}
+
+/** Source 4: Citation keys inside \cite{}, \citep{}, etc. */
+function citationSource(context: CompletionContext): CompletionResult | null {
+ const citeCtx = getCiteContext(context)
+ if (!citeCtx) return null
+
+ const entries = scanCitations()
+ const options: Completion[] = entries.map((entry) => ({
+ label: entry.key,
+ detail: `@${entry.type}`,
+ info: entry.title,
+ type: 'text',
+ }))
+
+ return {
+ from: citeCtx.from,
+ options,
+ validFor: /^[a-zA-Z0-9_:.-]*$/,
+ }
+}
+
+/** Source 5: File paths inside \input{}, \include{}, \includegraphics{} */
+function filePathSource(context: CompletionContext): CompletionResult | null {
+ const fileCtx = getFileContext(context)
+ if (!fileCtx) return null
+
+ const paths = getFilePaths(fileCtx.isGraphics)
+ const options: Completion[] = paths.map((p) => ({
+ label: p,
+ type: 'text',
+ detail: fileCtx.isGraphics ? 'image' : 'file',
+ }))
+
+ return {
+ from: fileCtx.from,
+ options,
+ validFor: /^[a-zA-Z0-9_/.-]*$/,
+ }
+}
+
+// ── Export extension ─────────────────────────────────────────────────
+
+export function latexAutocomplete() {
+ return autocompletion({
+ override: [
+ environmentSource,
+ labelSource,
+ citationSource,
+ filePathSource,
+ commandSource,
+ ],
+ defaultKeymap: true,
+ icons: true,
+ optionClass: () => 'cm-latex-completion',
+ activateOnTyping: true,
+ })
+}