diff options
| -rw-r--r-- | electron-builder.yml | 4 | ||||
| -rw-r--r-- | package-lock.json | 170 | ||||
| -rw-r--r-- | package.json | 6 | ||||
| -rw-r--r-- | resources/logo.svg | 28 | ||||
| -rw-r--r-- | src/main/compilationManager.ts | 2 | ||||
| -rw-r--r-- | src/main/index.ts | 4 | ||||
| -rw-r--r-- | src/main/overleafSocket.ts | 31 | ||||
| -rw-r--r-- | src/renderer/index.html | 2 | ||||
| -rw-r--r-- | src/renderer/src/App.css | 73 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 34 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 5 | ||||
| -rw-r--r-- | src/renderer/src/components/PdfViewer.tsx | 15 | ||||
| -rw-r--r-- | src/renderer/src/components/ProjectList.tsx | 2 | ||||
| -rw-r--r-- | src/renderer/src/data/latexCommands.ts | 300 | ||||
| -rw-r--r-- | src/renderer/src/data/latexEnvironments.ts | 98 | ||||
| -rw-r--r-- | src/renderer/src/extensions/commentHighlights.ts | 43 | ||||
| -rw-r--r-- | src/renderer/src/extensions/latexAutocomplete.ts | 349 |
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, + }) +} |
