From dd2cd06d4e8b64d6e0b9488a1887b05ecdc7d533 Mon Sep 17 00:00:00 2001 From: haoyuren <13851610112@163.com> Date: Fri, 13 Mar 2026 15:50:40 -0500 Subject: Add code folding, outline view, smart closing, math preview, syntax improvements - 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 --- src/renderer/src/App.css | 113 ++++++++++ src/renderer/src/App.tsx | 6 +- src/renderer/src/components/Editor.tsx | 28 ++- src/renderer/src/components/OutlineView.tsx | 185 ++++++++++++++++ src/renderer/src/components/PdfViewer.tsx | 36 +++- src/renderer/src/extensions/latexClosing.ts | 230 ++++++++++++++++++++ src/renderer/src/extensions/latexFolding.ts | 313 ++++++++++++++++++++++++++++ src/renderer/src/extensions/mathPreview.ts | 105 ++++++++++ 8 files changed, 1000 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/components/OutlineView.tsx create mode 100644 src/renderer/src/extensions/latexClosing.ts create mode 100644 src/renderer/src/extensions/latexFolding.ts create mode 100644 src/renderer/src/extensions/mathPreview.ts (limited to 'src/renderer') 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 && ( <> - +
+ + +
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 = { + '\\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() + 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 ( +
+ {sectionIcon(entry.level)} + {entry.title || '(untitled)'} + {entry.line} +
+ ) +} + +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(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 ( +
+
setCollapsed(!collapsed)}> + {collapsed ? '>' : 'v'} + OUTLINE + {entries.length > 0 && ( + {entries.length} + )} +
+ {!collapsed && ( +
+ {!activeTab && ( +
No file open
+ )} + {activeTab && !isTexFile && ( +
Not a LaTeX file
+ )} + {activeTab && isTexFile && entries.length === 0 && ( +
No sections found
+ )} + {entries.map((entry, i) => ( + handleItemClick(entry, i)} + /> + ))} +
+ )} +
+ ) +} 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 = { + '$': '$', + '{': '}', + '(': ')', + '[': ']', +} + +/** + * 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\n\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 = { + '\\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, '&') + .replace(//g, '>') + + const fontSize = display ? '1.2em' : '1em' + return `
+ + ${escaped} + +
${display ? '$$' : '$'}${escaped}${display ? '$$' : '$'}
+
` +} + +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 }) +} -- cgit v1.2.3