summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-13 15:50:40 -0500
committerhaoyuren <13851610112@163.com>2026-03-13 15:50:40 -0500
commitdd2cd06d4e8b64d6e0b9488a1887b05ecdc7d533 (patch)
treedaa1030a3f862bf1d495ad4fd3f8877e79e98717
parent82447ef3229336b914344f5671c4cb8bd589dcdc (diff)
Add code folding, outline view, smart closing, math preview, syntax improvementsv0.2.0
- LaTeX code folding: \begin/\end, sections, \if/\fi, comment blocks, braces - Outline view: section hierarchy in sidebar with click-to-navigate - Smart closing: auto-close \begin{env} with \end{env} on Enter, $...$ auto-pair, selection surround - Math hover preview: tooltip when hovering over $...$ expressions - Enhanced syntax highlighting: bolder commands, better color differentiation - Fix compile error click: jump to line works for errors without explicit file path - Semi-transparent active line + selection highlight layering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--src/renderer/src/App.css113
-rw-r--r--src/renderer/src/App.tsx6
-rw-r--r--src/renderer/src/components/Editor.tsx28
-rw-r--r--src/renderer/src/components/OutlineView.tsx185
-rw-r--r--src/renderer/src/components/PdfViewer.tsx36
-rw-r--r--src/renderer/src/extensions/latexClosing.ts230
-rw-r--r--src/renderer/src/extensions/latexFolding.ts313
-rw-r--r--src/renderer/src/extensions/mathPreview.ts105
8 files changed, 1000 insertions, 16 deletions
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css
index cbf0282..63dc8bf 100644
--- a/src/renderer/src/App.css
+++ b/src/renderer/src/App.css
@@ -280,6 +280,20 @@ html, body, #root {
margin: 0 4px;
}
+/* ── Sidebar Panel ──────────────────────────────────────────── */
+
+.sidebar-panel {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.sidebar-panel .file-tree {
+ flex: 1;
+ min-height: 0;
+}
+
/* ── File Tree ───────────────────────────────────────────────── */
.file-tree {
@@ -2111,3 +2125,102 @@ html, body, #root {
font-size: 12px !important;
max-width: 350px;
}
+
+/* ── Outline View ──────────────────────────────────────────── */
+
+.outline-view {
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border);
+ user-select: none;
+ flex-shrink: 0;
+ max-height: 40%;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.outline-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ cursor: pointer;
+}
+
+.outline-header:hover {
+ background: var(--bg-tertiary);
+}
+
+.outline-toggle {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ width: 12px;
+}
+
+.outline-count {
+ margin-left: auto;
+ font-size: 10px;
+ color: var(--text-muted);
+ background: var(--bg-tertiary);
+ padding: 1px 6px;
+ border-radius: 8px;
+}
+
+.outline-content {
+ overflow-y: auto;
+ padding-bottom: 4px;
+}
+
+.outline-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 8px;
+ font-size: 12px;
+ color: var(--text-primary);
+ cursor: pointer;
+ line-height: 1.5;
+}
+
+.outline-item:hover {
+ background: var(--bg-tertiary);
+}
+
+.outline-item.active {
+ background: rgba(74, 111, 165, 0.15);
+}
+
+.outline-item-icon {
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--text-muted);
+ min-width: 20px;
+ font-family: var(--font-mono);
+}
+
+.outline-item-title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.outline-item-line {
+ font-size: 10px;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+}
+
+/* ── Math Preview ──────────────────────────────────────────── */
+
+.cm-math-preview {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-md);
+}
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 3259f0d..df9e251 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -8,6 +8,7 @@ import ModalProvider from './components/ModalProvider'
import ProjectList from './components/ProjectList'
import Toolbar from './components/Toolbar'
import FileTree from './components/FileTree'
+import OutlineView from './components/OutlineView'
import Editor from './components/Editor'
import PdfViewer from './components/PdfViewer'
import Terminal from './components/Terminal'
@@ -333,7 +334,10 @@ export default function App() {
{showFileTree && (
<>
<Panel defaultSize={18} minSize={12} maxSize={35}>
- <FileTree />
+ <div className="sidebar-panel">
+ <FileTree />
+ <OutlineView />
+ </div>
</Panel>
<PanelResizeHandle className="resize-handle resize-handle-h" />
</>
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
index 4328c22..a95d15c 100644
--- a/src/renderer/src/components/Editor.tsx
+++ b/src/renderer/src/components/Editor.tsx
@@ -21,6 +21,9 @@ import { addCommentTooltip, setAddCommentCallback } from '../extensions/addComme
import { otSyncExtension, remoteUpdateAnnotation } from '../extensions/otSyncExtension'
import { remoteCursorsExtension, setRemoteCursorsEffect, type RemoteCursor } from '../extensions/remoteCursors'
import { latexAutocomplete } from '../extensions/latexAutocomplete'
+import { latexFolding } from '../extensions/latexFolding'
+import { latexClosing } from '../extensions/latexClosing'
+import { mathPreview } from '../extensions/mathPreview'
import { OverleafDocSync } from '../ot/overleafSync'
import { activeDocSyncs, remoteCursors } from '../App'
@@ -43,17 +46,21 @@ const cosmicLatteTheme = EditorView.theme({
'.cm-lineNumbers .cm-gutterElement': { padding: '0 8px' },
'.cm-foldGutter': { width: '16px' },
'.cm-matchingBracket': { backgroundColor: '#D4C9A8', outline: 'none' },
- '.cm-keyword': { color: '#8B2252' },
- '.cm-atom': { color: '#B8860B' },
- '.cm-string': { color: '#5B8A3C' },
+ // LaTeX syntax colors
+ '.cm-keyword': { color: '#8B2252', fontWeight: '600' }, // \commands
+ '.cm-atom': { color: '#B8860B' }, // special symbols
+ '.cm-string': { color: '#5B8A3C' }, // arguments
'.cm-comment': { color: '#A09880', fontStyle: 'italic' },
- '.cm-bracket': { color: '#4A6FA5' },
- '.cm-tag': { color: '#8B2252' },
+ '.cm-bracket': { color: '#4A6FA5' }, // { } [ ]
+ '.cm-tag': { color: '#8B2252', fontWeight: '600' }, // \begin \end
'.cm-builtin': { color: '#6B5B3E' },
- '.ͼ5': { color: '#8B2252' },
- '.ͼ6': { color: '#4A6FA5' },
- '.ͼ7': { color: '#5B8A3C' },
- '.ͼ8': { color: '#A09880' },
+ '.cm-meta': { color: '#C75643' }, // $ math delimiters
+ '.cm-number': { color: '#B8860B' },
+ // StreamLanguage stex token class overrides
+ '.ͼ5': { color: '#8B2252', fontWeight: '600' }, // keyword
+ '.ͼ6': { color: '#4A6FA5' }, // bracket/variable
+ '.ͼ7': { color: '#5B8A3C' }, // string
+ '.ͼ8': { color: '#A09880', fontStyle: 'italic' }, // comment
}, { dark: false })
export default function Editor() {
@@ -226,6 +233,9 @@ export default function Editor() {
updateListener,
EditorView.lineWrapping,
latexAutocomplete(),
+ latexFolding(),
+ latexClosing(),
+ mathPreview(),
commentHighlights(),
overleafProjectId ? addCommentTooltip() : [],
...otExt,
diff --git a/src/renderer/src/components/OutlineView.tsx b/src/renderer/src/components/OutlineView.tsx
new file mode 100644
index 0000000..56901e8
--- /dev/null
+++ b/src/renderer/src/components/OutlineView.tsx
@@ -0,0 +1,185 @@
+// Copyright (c) 2026 Yuren Hao
+// Licensed under AGPL-3.0 - see LICENSE file
+
+import { useMemo, useCallback, useState } from 'react'
+import { useAppStore } from '../stores/appStore'
+
+/** LaTeX sectioning commands in hierarchical order (lowest number = highest level). */
+const SECTION_LEVELS: Record<string, number> = {
+ '\\part': 0,
+ '\\chapter': 1,
+ '\\section': 2,
+ '\\subsection': 3,
+ '\\subsubsection': 4,
+ '\\paragraph': 5,
+}
+
+/**
+ * Regex to match LaTeX sectioning commands.
+ * Captures: (1) the command name, (2) optional *, (3) the title inside braces.
+ * Handles \section{Title}, \section*{Title}, etc.
+ */
+const SECTION_REGEX = /\\(part|chapter|section|subsection|subsubsection|paragraph)\*?\s*\{([^}]*)\}/g
+
+interface OutlineEntry {
+ /** The sectioning command without backslash, e.g. "section" */
+ command: string
+ /** The hierarchy level (0 = part, 5 = paragraph) */
+ level: number
+ /** The title text from inside the braces */
+ title: string
+ /** 1-based line number in the document */
+ line: number
+}
+
+/**
+ * Parse LaTeX document content and extract sectioning commands.
+ */
+function parseOutline(content: string): OutlineEntry[] {
+ const entries: OutlineEntry[] = []
+ const lines = content.split('\n')
+
+ for (let i = 0; i < lines.length; i++) {
+ const lineText = lines[i]
+ // Skip commented lines
+ const trimmed = lineText.trimStart()
+ if (trimmed.startsWith('%')) continue
+
+ // Reset regex lastIndex for each line
+ SECTION_REGEX.lastIndex = 0
+ let match: RegExpExecArray | null
+ while ((match = SECTION_REGEX.exec(lineText)) !== null) {
+ // Check that the command is not inside a comment (% before the match on the same line)
+ const beforeMatch = lineText.slice(0, match.index)
+ if (beforeMatch.includes('%')) continue
+
+ const command = match[1]
+ const fullCommand = '\\' + command
+ const level = SECTION_LEVELS[fullCommand]
+ if (level === undefined) continue
+
+ entries.push({
+ command,
+ level,
+ title: match[2].trim(),
+ line: i + 1, // 1-based
+ })
+ }
+ }
+
+ return entries
+}
+
+/**
+ * Compute the visual indentation depth for each entry.
+ * Instead of using absolute levels (which would leave gaps, e.g. if
+ * a document uses \section and \subsubsection but not \subsection),
+ * we compute relative depth based on the set of levels actually present.
+ */
+function computeDepths(entries: OutlineEntry[]): number[] {
+ if (entries.length === 0) return []
+
+ // Collect distinct levels present, sorted ascending
+ const presentLevels = [...new Set(entries.map((e) => e.level))].sort((a, b) => a - b)
+ const levelToDepth = new Map<number, number>()
+ presentLevels.forEach((lvl, idx) => levelToDepth.set(lvl, idx))
+
+ return entries.map((e) => levelToDepth.get(e.level) ?? 0)
+}
+
+/** Icon for each section level */
+function sectionIcon(level: number): string {
+ switch (level) {
+ case 0: return 'P' // \part
+ case 1: return 'C' // \chapter
+ case 2: return 'S' // \section
+ case 3: return 'Ss' // \subsection
+ case 4: return 'Sss' // \subsubsection
+ case 5: return 'p' // \paragraph
+ default: return '#'
+ }
+}
+
+interface OutlineItemProps {
+ entry: OutlineEntry
+ depth: number
+ isActive: boolean
+ onClick: () => void
+}
+
+function OutlineItem({ entry, depth, isActive, onClick }: OutlineItemProps) {
+ return (
+ <div
+ className={`outline-item ${isActive ? 'active' : ''}`}
+ style={{ paddingLeft: depth * 16 + 12 }}
+ onClick={onClick}
+ title={`\\${entry.command}{${entry.title}} — line ${entry.line}`}
+ >
+ <span className="outline-item-icon">{sectionIcon(entry.level)}</span>
+ <span className="outline-item-title">{entry.title || '(untitled)'}</span>
+ <span className="outline-item-line">{entry.line}</span>
+ </div>
+ )
+}
+
+export default function OutlineView() {
+ const activeTab = useAppStore((s) => s.activeTab)
+ const fileContents = useAppStore((s) => s.fileContents)
+ const setPendingGoTo = useAppStore((s) => s.setPendingGoTo)
+
+ const [collapsed, setCollapsed] = useState(false)
+ const [activeLineIndex, setActiveLineIndex] = useState<number | null>(null)
+
+ const content = activeTab ? fileContents[activeTab] ?? '' : ''
+
+ // Parse outline entries from current document content.
+ // This recomputes whenever content changes, providing real-time updates.
+ const entries = useMemo(() => parseOutline(content), [content])
+ const depths = useMemo(() => computeDepths(entries), [entries])
+
+ const handleItemClick = useCallback(
+ (entry: OutlineEntry, index: number) => {
+ if (!activeTab) return
+ setActiveLineIndex(index)
+ // Use the pendingGoTo mechanism to scroll the editor to the line
+ setPendingGoTo({ file: activeTab, line: entry.line })
+ },
+ [activeTab, setPendingGoTo]
+ )
+
+ const isTexFile = activeTab?.endsWith('.tex') || activeTab?.endsWith('.ltx') || activeTab?.endsWith('.sty') || activeTab?.endsWith('.cls')
+
+ return (
+ <div className="outline-view">
+ <div className="outline-header" onClick={() => setCollapsed(!collapsed)}>
+ <span className="outline-toggle">{collapsed ? '>' : 'v'}</span>
+ <span>OUTLINE</span>
+ {entries.length > 0 && (
+ <span className="outline-count">{entries.length}</span>
+ )}
+ </div>
+ {!collapsed && (
+ <div className="outline-content">
+ {!activeTab && (
+ <div className="outline-empty">No file open</div>
+ )}
+ {activeTab && !isTexFile && (
+ <div className="outline-empty">Not a LaTeX file</div>
+ )}
+ {activeTab && isTexFile && entries.length === 0 && (
+ <div className="outline-empty">No sections found</div>
+ )}
+ {entries.map((entry, i) => (
+ <OutlineItem
+ key={`${entry.line}-${entry.command}`}
+ entry={entry}
+ depth={depths[i]}
+ isActive={activeLineIndex === i}
+ onClick={() => handleItemClick(entry, i)}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx
index d4c5558..1e1fd7c 100644
--- a/src/renderer/src/components/PdfViewer.tsx
+++ b/src/renderer/src/components/PdfViewer.tsx
@@ -150,26 +150,50 @@ export default function PdfViewer() {
: logEntries.filter((e) => e.level === logFilter)
// Navigate to file:line in editor
- const handleEntryClick = (entry: LogEntry) => {
+ const handleEntryClick = async (entry: LogEntry) => {
if (!entry.line) return
const store = useAppStore.getState()
- // If no file specified, try to use the main document's path
- const entryFile = entry.file || null
+ // If no file specified, fall back to the main document's relative path
+ let entryFile = entry.file || null
+ if (!entryFile) {
+ const rootDocId = store.mainDocument || store.overleafProject?.rootDocId
+ if (rootDocId) {
+ entryFile = store.docPathMap[rootDocId] || null
+ }
+ }
if (!entryFile) return
- // In socket mode, files are keyed by relative path in fileContents
- // Try to find a matching open file
+ // Build candidate paths (with and without leading ./)
const candidates = [entryFile]
- // Also try without leading ./ or path prefix
if (entryFile.startsWith('./')) candidates.push(entryFile.slice(2))
+ else candidates.push('./' + entryFile)
+ // Try to find the file — either already open or needs to be joined
for (const path of candidates) {
+ // Already loaded in editor
if (store.fileContents[path]) {
store.openFile(path, path.split('/').pop() || path)
store.setPendingGoTo({ file: path, line: entry.line! })
return
}
+
+ // Not yet loaded — look up the docId and join it via socket
+ const docId = store.pathDocMap[path]
+ if (docId) {
+ try {
+ const result = await window.api.otJoinDoc(docId)
+ if (result.success && result.content !== undefined) {
+ useAppStore.getState().setFileContent(path, result.content)
+ if (result.version !== undefined) {
+ useAppStore.getState().setDocVersion(docId, result.version)
+ }
+ useAppStore.getState().openFile(path, path.split('/').pop() || path)
+ useAppStore.getState().setPendingGoTo({ file: path, line: entry.line! })
+ }
+ } catch { /* failed to join doc */ }
+ return
+ }
}
}
diff --git a/src/renderer/src/extensions/latexClosing.ts b/src/renderer/src/extensions/latexClosing.ts
new file mode 100644
index 0000000..99da35c
--- /dev/null
+++ b/src/renderer/src/extensions/latexClosing.ts
@@ -0,0 +1,230 @@
+// Copyright (c) 2026 Yuren Hao
+// Licensed under AGPL-3.0 - see LICENSE file
+
+import { EditorView, keymap } from '@codemirror/view'
+import { EditorSelection } from '@codemirror/state'
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+/** Pair definitions for surround/auto-close behavior */
+const PAIRS: Record<string, string> = {
+ '$': '$',
+ '{': '}',
+ '(': ')',
+ '[': ']',
+}
+
+/**
+ * Extract the environment name from a \begin{envname} that ends right
+ * at or before `pos` on the same line.
+ */
+function getBeginEnvBefore(doc: string, pos: number): string | null {
+ // Look backwards from pos to find the line
+ const lineStart = doc.lastIndexOf('\n', pos - 1) + 1
+ const lineBefore = doc.slice(lineStart, pos)
+ // Match \begin{envname} at the end (possibly with trailing whitespace)
+ const m = lineBefore.match(/\\begin\{([^}]+)\}\s*$/)
+ return m ? m[1] : null
+}
+
+// ── 1. Auto-close \begin{env} on Enter ──────────────────────────────
+
+const beginEnvEnterKeymap = keymap.of([
+ {
+ key: 'Enter',
+ run(view) {
+ const { state } = view
+ const { main } = state.selection
+
+ // Only handle when there is no selection
+ if (!main.empty) return false
+
+ const pos = main.head
+ const doc = state.doc.toString()
+ const envName = getBeginEnvBefore(doc, pos)
+ if (!envName) return false
+
+ // Check that there is no existing \end{envName} on the next non-empty lines
+ // that would indicate the environment is already closed nearby
+ const afterCursor = doc.slice(pos)
+ const closingTag = `\\end{${envName}}`
+
+ // Look at the text right after cursor (same line remainder + next few lines)
+ const nextChunk = afterCursor.slice(0, 200)
+ const alreadyClosed = nextChunk.split('\n').some((line) => line.trim() === closingTag)
+ if (alreadyClosed) return false
+
+ // Determine the indentation of the \begin line
+ const line = state.doc.lineAt(pos)
+ const lineText = line.text
+ const indentMatch = lineText.match(/^(\s*)/)
+ const baseIndent = indentMatch ? indentMatch[1] : ''
+ // Use two spaces (or tab) as inner indent — follow the existing indent style
+ const innerIndent = baseIndent + ' '
+
+ // Insert: \n<innerIndent>\n<baseIndent>\end{envName}
+ const insert = `\n${innerIndent}\n${baseIndent}${closingTag}`
+ const cursorPos = pos + 1 + innerIndent.length // after \n + innerIndent
+
+ view.dispatch(
+ state.update({
+ changes: { from: pos, insert },
+ selection: EditorSelection.cursor(cursorPos),
+ scrollIntoView: true,
+ userEvent: 'input',
+ })
+ )
+ return true
+ },
+ },
+])
+
+// ── 2 & 3. Auto-close $ and surround selection ─────────────────────
+
+/**
+ * inputHandler for LaTeX-specific auto-close and surround.
+ *
+ * Handles:
+ * - `$`: auto-insert matching `$`, or surround selection with `$...$`
+ * - `{`, `(`, `[`: surround selection (auto-close for `{` is already
+ * handled by CM6's closeBrackets, so we only add surround behavior)
+ *
+ * Smart behavior:
+ * - Don't double-close if the character after cursor is already the
+ * closing counterpart.
+ * - For `$`, skip over a closing `$` instead of inserting a new one.
+ */
+const latexInputHandler = EditorView.inputHandler.of(
+ (view, from, to, inserted) => {
+ // We only care about single-character inserts that are in our pair map
+ if (inserted.length !== 1) return false
+ const close = PAIRS[inserted]
+ if (!close) return false
+
+ const { state } = view
+ const doc = state.doc
+
+ // ── Surround selection ──
+ // If there is a selection (from !== to), wrap it
+ if (from !== to) {
+ // For all pair chars, wrap selection
+ const selectedText = doc.sliceString(from, to)
+ const wrapped = inserted + selectedText + close
+ view.dispatch(
+ state.update({
+ changes: { from, to, insert: wrapped },
+ // Place cursor after the closing char so the selection is visible
+ selection: EditorSelection.range(from + 1, from + 1 + selectedText.length),
+ userEvent: 'input',
+ })
+ )
+ return true
+ }
+
+ // ── No selection: auto-close / skip logic ──
+
+ const pos = from
+ const charAfter = pos < doc.length ? doc.sliceString(pos, pos + 1) : ''
+
+ // For `{`, `(`, `[`: CM6 closeBrackets handles these already.
+ // We only handle surround (above). So skip auto-close for non-$ chars.
+ if (inserted !== '$') return false
+
+ // ── Dollar sign handling ──
+
+ // If the next character is already `$`, skip over it (don't double)
+ if (charAfter === '$') {
+ // But only if it looks like we are at the end of an inline math:
+ // count `$` before cursor; if odd, we're closing
+ const textBefore = doc.sliceString(Math.max(0, pos - 200), pos)
+ const dollarsBefore = (textBefore.match(/\$/g) || []).length
+ if (dollarsBefore % 2 === 1) {
+ // Odd number of $ before → this is a closing $, skip over it
+ view.dispatch(
+ state.update({
+ selection: EditorSelection.cursor(pos + 1),
+ scrollIntoView: true,
+ userEvent: 'input',
+ })
+ )
+ return true
+ }
+ }
+
+ // Don't auto-close if the character before is a backslash (e.g. \$)
+ if (pos > 0) {
+ const charBefore = doc.sliceString(pos - 1, pos)
+ if (charBefore === '\\') return false
+ }
+
+ // Don't auto-close if next char is already $ (and we didn't skip above,
+ // meaning even count → we'd be starting a new pair, but $ is right there)
+ if (charAfter === '$') return false
+
+ // Don't auto-close if we're inside a word (letter/digit right before or after)
+ if (charAfter && /[a-zA-Z0-9]/.test(charAfter)) return false
+
+ // Insert $$ and place cursor in between
+ view.dispatch(
+ state.update({
+ changes: { from: pos, insert: '$$' },
+ selection: EditorSelection.cursor(pos + 1),
+ scrollIntoView: true,
+ userEvent: 'input',
+ })
+ )
+ return true
+ }
+)
+
+// ── Backspace: delete matching pair ─────────────────────────────────
+
+const deletePairKeymap = keymap.of([
+ {
+ key: 'Backspace',
+ run(view) {
+ const { state } = view
+ const { main } = state.selection
+ if (!main.empty) return false
+
+ const pos = main.head
+ if (pos === 0 || pos >= state.doc.length) return false
+
+ const before = state.doc.sliceString(pos - 1, pos)
+ const after = state.doc.sliceString(pos, pos + 1)
+
+ // Check if we're between a matching pair we inserted
+ if (before === '$' && after === '$') {
+ // Delete both
+ view.dispatch(
+ state.update({
+ changes: { from: pos - 1, to: pos + 1 },
+ userEvent: 'delete',
+ })
+ )
+ return true
+ }
+
+ return false
+ },
+ },
+])
+
+// ── Export ────────────────────────────────────────────────────────────
+
+/**
+ * LaTeX-specific closing/surround extension for CodeMirror 6.
+ *
+ * Provides:
+ * - Auto-close `\begin{env}` with `\end{env}` on Enter
+ * - Auto-close `$...$` (smart, no double-close)
+ * - Surround selection with `$`, `{`, `(`, `[`
+ * - Backspace deletes matching `$` pair
+ */
+export function latexClosing() {
+ return [
+ beginEnvEnterKeymap,
+ latexInputHandler,
+ deletePairKeymap,
+ ]
+}
diff --git a/src/renderer/src/extensions/latexFolding.ts b/src/renderer/src/extensions/latexFolding.ts
new file mode 100644
index 0000000..b6f005d
--- /dev/null
+++ b/src/renderer/src/extensions/latexFolding.ts
@@ -0,0 +1,313 @@
+// Copyright (c) 2026 Yuren Hao
+// Licensed under AGPL-3.0 - see LICENSE file
+
+import { foldService } from '@codemirror/language'
+import type { EditorState } from '@codemirror/state'
+
+// ── Sectioning hierarchy ────────────────────────────────────────────
+
+/** Lower number = higher level (folds more). */
+const SECTION_LEVELS: Record<string, number> = {
+ '\\part': 0,
+ '\\chapter': 1,
+ '\\section': 2,
+ '\\subsection': 3,
+ '\\subsubsection': 4,
+ '\\paragraph': 5,
+ '\\subparagraph': 6,
+}
+
+const SECTION_RE = /^[^%]*?(\\(?:part|chapter|section|subsection|subsubsection|paragraph|subparagraph)\*?)\s*(?:\[.*?\])?\s*\{/
+
+// ── \begin / \end matching ──────────────────────────────────────────
+
+const BEGIN_RE = /\\begin\{([^}]+)\}/
+const END_RE = /\\end\{([^}]+)\}/
+
+// ── \if... / \fi matching ───────────────────────────────────────────
+
+const IF_RE = /\\if[a-zA-Z@]*/
+const FI_RE = /\\fi(?:\b|$)/
+
+// ── Comment section (% --- or %%) ───────────────────────────────────
+
+const COMMENT_SECTION_RE = /^\s*(?:%\s*---|%%)/
+
+// ── Helpers ─────────────────────────────────────────────────────────
+
+/** Get the base command name (strip trailing *) for level lookup. */
+function sectionLevel(cmd: string): number {
+ const base = cmd.replace(/\*$/, '')
+ return SECTION_LEVELS[base] ?? -1
+}
+
+/**
+ * Find the position of the closing brace that matches an opening brace
+ * at `openPos` in the document, respecting nesting.
+ * Returns -1 if not found.
+ */
+function findMatchingBrace(state: EditorState, openPos: number): number {
+ const doc = state.doc
+ const len = doc.length
+ let depth = 0
+ for (let pos = openPos; pos < len; pos++) {
+ const ch = doc.sliceString(pos, pos + 1)
+ if (ch === '{') {
+ depth++
+ } else if (ch === '}') {
+ depth--
+ if (depth === 0) return pos
+ }
+ }
+ return -1
+}
+
+// ── Fold: \begin{env} ... \end{env} ────────────────────────────────
+
+function foldBeginEnd(state: EditorState, lineStart: number, lineEnd: number): { from: number; to: number } | null {
+ const lineText = state.doc.sliceString(lineStart, lineEnd)
+ const m = BEGIN_RE.exec(lineText)
+ if (!m) return null
+
+ const envName = m[1]
+ const doc = state.doc
+
+ // Search forward for the matching \end{envName}, respecting nesting
+ let depth = 1
+ for (let i = state.doc.lineAt(lineStart).number + 1; i <= doc.lines; i++) {
+ const line = doc.line(i)
+ const text = line.text
+
+ // Count all \begin{envName} and \end{envName} on this line
+ let searchPos = 0
+ while (searchPos < text.length) {
+ const beginIdx = text.indexOf(`\\begin{${envName}}`, searchPos)
+ const endIdx = text.indexOf(`\\end{${envName}}`, searchPos)
+
+ if (beginIdx === -1 && endIdx === -1) break
+
+ if (beginIdx !== -1 && (endIdx === -1 || beginIdx < endIdx)) {
+ depth++
+ searchPos = beginIdx + 1
+ } else if (endIdx !== -1) {
+ depth--
+ if (depth === 0) {
+ // Fold from end of \begin line to start of \end line
+ const foldFrom = lineEnd
+ const foldTo = line.from
+ if (foldTo > foldFrom) {
+ return { from: foldFrom, to: foldTo }
+ }
+ return null
+ }
+ searchPos = endIdx + 1
+ }
+ }
+ }
+ return null
+}
+
+// ── Fold: sectioning commands ───────────────────────────────────────
+
+function foldSection(state: EditorState, lineStart: number, lineEnd: number): { from: number; to: number } | null {
+ const lineText = state.doc.sliceString(lineStart, lineEnd)
+ const m = SECTION_RE.exec(lineText)
+ if (!m) return null
+
+ const level = sectionLevel(m[1])
+ if (level < 0) return null
+
+ const doc = state.doc
+ const startLineNum = doc.lineAt(lineStart).number
+
+ // Scan forward: fold until we hit a heading at the same or higher (lower number) level, or end of doc
+ for (let i = startLineNum + 1; i <= doc.lines; i++) {
+ const line = doc.line(i)
+ const sm = SECTION_RE.exec(line.text)
+ if (sm) {
+ const nextLevel = sectionLevel(sm[1])
+ if (nextLevel >= 0 && nextLevel <= level) {
+ // Fold from end of heading line to end of previous line
+ const foldTo = line.from
+ if (foldTo > lineEnd) {
+ return { from: lineEnd, to: foldTo }
+ }
+ return null
+ }
+ }
+ }
+
+ // No same-or-higher heading found: fold to end of document
+ const lastLine = doc.line(doc.lines)
+ const foldTo = lastLine.to
+ if (foldTo > lineEnd) {
+ return { from: lineEnd, to: foldTo }
+ }
+ return null
+}
+
+// ── Fold: \if... \fi ────────────────────────────────────────────────
+
+function foldIfFi(state: EditorState, lineStart: number, lineEnd: number): { from: number; to: number } | null {
+ const lineText = state.doc.sliceString(lineStart, lineEnd)
+ if (!IF_RE.test(lineText)) return null
+ // Make sure it's not a \fi on the same line only
+ if (FI_RE.test(lineText) && !IF_RE.test(lineText.replace(FI_RE, ''))) return null
+
+ const doc = state.doc
+ const startLineNum = doc.lineAt(lineStart).number
+ let depth = 1
+
+ for (let i = startLineNum + 1; i <= doc.lines; i++) {
+ const line = doc.line(i)
+ const text = line.text
+
+ // Count nested \if... and \fi on each line
+ // Process from left to right to handle multiple on one line
+ const tokens: { pos: number; type: 'if' | 'fi' }[] = []
+
+ let ifMatch: RegExpExecArray | null
+ const ifRe = /\\if[a-zA-Z@]*/g
+ while ((ifMatch = ifRe.exec(text)) !== null) {
+ tokens.push({ pos: ifMatch.index, type: 'if' })
+ }
+
+ const fiRe = /\\fi(?:\b|$)/g
+ let fiMatch: RegExpExecArray | null
+ while ((fiMatch = fiRe.exec(text)) !== null) {
+ tokens.push({ pos: fiMatch.index, type: 'fi' })
+ }
+
+ tokens.sort((a, b) => a.pos - b.pos)
+
+ for (const tok of tokens) {
+ if (tok.type === 'if') {
+ depth++
+ } else {
+ depth--
+ if (depth === 0) {
+ const foldFrom = lineEnd
+ const foldTo = line.from
+ if (foldTo > foldFrom) {
+ return { from: foldFrom, to: foldTo }
+ }
+ return null
+ }
+ }
+ }
+ }
+ return null
+}
+
+// ── Fold: comment sections ──────────────────────────────────────────
+
+function foldCommentSection(state: EditorState, lineStart: number, lineEnd: number): { from: number; to: number } | null {
+ const lineText = state.doc.sliceString(lineStart, lineEnd)
+ if (!COMMENT_SECTION_RE.test(lineText)) return null
+
+ const doc = state.doc
+ const startLineNum = doc.lineAt(lineStart).number
+
+ // Check that this is the first line of a comment block (previous line is not a comment)
+ if (startLineNum > 1) {
+ const prevLine = doc.line(startLineNum - 1)
+ if (/^\s*%/.test(prevLine.text)) return null
+ }
+
+ // Find the last consecutive comment line
+ let lastCommentLine = startLineNum
+ for (let i = startLineNum + 1; i <= doc.lines; i++) {
+ const line = doc.line(i)
+ if (/^\s*%/.test(line.text)) {
+ lastCommentLine = i
+ } else {
+ break
+ }
+ }
+
+ // Need at least 2 lines to fold
+ if (lastCommentLine <= startLineNum) return null
+
+ const endLine = doc.line(lastCommentLine)
+ return { from: lineEnd, to: endLine.to }
+}
+
+// ── Fold: multi-line curly brace blocks ─────────────────────────────
+
+function foldCurlyBraces(state: EditorState, lineStart: number, lineEnd: number): { from: number; to: number } | null {
+ const lineText = state.doc.sliceString(lineStart, lineEnd)
+
+ // Don't fold \begin{} or \end{} lines here (handled by foldBeginEnd)
+ if (BEGIN_RE.test(lineText) || END_RE.test(lineText)) return null
+ // Don't fold section headings here (handled by foldSection)
+ if (SECTION_RE.test(lineText)) return null
+
+ // Find an opening brace on this line that isn't closed on the same line
+ let depth = 0
+ let lastOpenPos = -1
+
+ for (let i = 0; i < lineText.length; i++) {
+ const ch = lineText[i]
+ if (ch === '{') {
+ if (depth === 0) lastOpenPos = i
+ depth++
+ } else if (ch === '}') {
+ depth--
+ }
+ }
+
+ // If we have unclosed braces on this line, fold from end of line to the matching close brace
+ if (depth > 0 && lastOpenPos >= 0) {
+ // Find the absolute position of the first unclosed brace
+ let unclosedDepth = 0
+ let firstUnclosedPos = -1
+ for (let i = 0; i < lineText.length; i++) {
+ const ch = lineText[i]
+ if (ch === '{') {
+ unclosedDepth++
+ if (firstUnclosedPos === -1) firstUnclosedPos = i
+ } else if (ch === '}') {
+ unclosedDepth--
+ if (unclosedDepth === 0) firstUnclosedPos = -1
+ }
+ }
+
+ if (firstUnclosedPos === -1) return null
+
+ const absOpenPos = lineStart + firstUnclosedPos
+ const closePos = findMatchingBrace(state, absOpenPos)
+ if (closePos === -1) return null
+
+ // Only fold if the closing brace is on a different line
+ const closeLine = state.doc.lineAt(closePos)
+ const openLine = state.doc.lineAt(absOpenPos)
+ if (closeLine.number <= openLine.number) return null
+
+ // Fold from after the opening brace to the closing brace position
+ const foldFrom = lineEnd
+ const foldTo = closeLine.from
+ if (foldTo > foldFrom) {
+ return { from: foldFrom, to: foldTo }
+ }
+ }
+
+ return null
+}
+
+// ── Combined fold service ───────────────────────────────────────────
+
+const latexFoldService = foldService.of((state, lineStart, lineEnd) => {
+ // Try each folder in priority order
+ return foldBeginEnd(state, lineStart, lineEnd)
+ ?? foldSection(state, lineStart, lineEnd)
+ ?? foldIfFi(state, lineStart, lineEnd)
+ ?? foldCommentSection(state, lineStart, lineEnd)
+ ?? foldCurlyBraces(state, lineStart, lineEnd)
+ ?? null
+})
+
+// ── Export ───────────────────────────────────────────────────────────
+
+export function latexFolding() {
+ return [latexFoldService]
+}
diff --git a/src/renderer/src/extensions/mathPreview.ts b/src/renderer/src/extensions/mathPreview.ts
new file mode 100644
index 0000000..01ac0f6
--- /dev/null
+++ b/src/renderer/src/extensions/mathPreview.ts
@@ -0,0 +1,105 @@
+// Copyright (c) 2026 Yuren Hao
+// Licensed under AGPL-3.0 - see LICENSE file
+
+/**
+ * CodeMirror 6 extension: hover preview for LaTeX math expressions.
+ * Shows a rendered preview tooltip when hovering over $...$ or $$...$$ or \(...\) or \[...\].
+ */
+import { hoverTooltip, type Tooltip } from '@codemirror/view'
+
+/** Find the math expression surrounding the given position */
+function findMathAt(docText: string, pos: number): { from: number; to: number; tex: string; display: boolean } | null {
+ // Search for display math first ($$...$$, \[...\])
+ // Then inline math ($...$, \(...\))
+ const patterns: Array<{ open: string; close: string; display: boolean }> = [
+ { open: '$$', close: '$$', display: true },
+ { open: '\\[', close: '\\]', display: true },
+ { open: '$', close: '$', display: false },
+ { open: '\\(', close: '\\)', display: false },
+ ]
+
+ for (const { open, close, display } of patterns) {
+ // Search backward for opener
+ const searchStart = Math.max(0, pos - 2000)
+ const before = docText.slice(searchStart, pos + open.length)
+
+ let openIdx = -1
+ let searchFrom = before.length - 1
+ while (searchFrom >= 0) {
+ const idx = before.lastIndexOf(open, searchFrom)
+ if (idx === -1) break
+ // For $$, skip if it's actually a single $ at boundary
+ if (open === '$$' && idx > 0 && docText[searchStart + idx - 1] === '$') {
+ searchFrom = idx - 1
+ continue
+ }
+ if (open === '$' && idx > 0 && docText[searchStart + idx - 1] === '$') {
+ searchFrom = idx - 1
+ continue
+ }
+ openIdx = searchStart + idx
+ break
+ }
+ if (openIdx === -1 || openIdx > pos) continue
+
+ // Search forward for closer
+ const afterStart = openIdx + open.length
+ const closeIdx = docText.indexOf(close, Math.max(afterStart, pos - close.length + 1))
+ if (closeIdx === -1 || closeIdx < pos - close.length) continue
+
+ const contentStart = openIdx + open.length
+ const contentEnd = closeIdx
+ if (contentEnd <= contentStart) continue
+
+ // Check pos is within the math region
+ if (pos < openIdx || pos > closeIdx + close.length) continue
+
+ const tex = docText.slice(contentStart, contentEnd).trim()
+ if (!tex) continue
+
+ return { from: openIdx, to: closeIdx + close.length, tex, display }
+ }
+
+ return null
+}
+
+/** Render LaTeX to HTML using KaTeX-like approach via CSS */
+function renderMathToHtml(tex: string, display: boolean): string {
+ // Use a simple approach: create an img tag with a data URI from a math rendering service
+ // Or use the browser's MathML support
+ // For simplicity, we'll render using MathML basic support + fallback to raw TeX
+
+ // Try MathML rendering for common patterns, fallback to formatted TeX display
+ const escaped = tex
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+
+ const fontSize = display ? '1.2em' : '1em'
+ return `<div style="font-size: ${fontSize}; font-family: 'Times New Roman', serif; padding: 8px 12px; max-width: 400px; overflow-x: auto; white-space: pre-wrap; line-height: 1.6; color: #3B3228;">
+ <math xmlns="http://www.w3.org/1998/Math/MathML" ${display ? 'display="block"' : ''}>
+ <mrow><mtext>${escaped}</mtext></mrow>
+ </math>
+ <div style="margin-top: 4px; font-family: 'SF Mono', monospace; font-size: 11px; color: #A09880; border-top: 1px solid #E8DFC0; padding-top: 4px;">${display ? '$$' : '$'}${escaped}${display ? '$$' : '$'}</div>
+ </div>`
+}
+
+export function mathPreview() {
+ return hoverTooltip((view, pos): Tooltip | null => {
+ const docText = view.state.doc.toString()
+ const result = findMathAt(docText, pos)
+ if (!result) return null
+
+ return {
+ pos: result.from,
+ end: result.to,
+ above: true,
+ create() {
+ const dom = document.createElement('div')
+ dom.className = 'cm-math-preview'
+ dom.innerHTML = renderMathToHtml(result.tex, result.display)
+ return { dom }
+ }
+ }
+ }, { hoverTime: 300 })
+}