summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorhaoyuren <13851610112@163.com>2026-03-13 18:02:10 -0500
committerhaoyuren <13851610112@163.com>2026-03-13 18:02:10 -0500
commitfa52c3c4d6c21a26c838fa33de2d7fa447f9499e (patch)
treed53e8445c596aaa2eea0ea311a42768efd60593d /src
parent7983c9f6df623ddda068e6fbf04423ae7f0901a6 (diff)
Improve editing experience: KaTeX math preview, syntax highlighting, symbol autocomplete
- Replace broken MathML math preview with KaTeX for rendered formula tooltips - Fix syntax highlighting by using proper HighlightStyle + syntaxHighlighting() instead of non-functional CSS class overrides - Add math region background highlighting (subtle gold tint for $...$ and $$...$$ regions) - Add Unicode symbol previews in autocomplete for Greek letters and math operators Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/renderer/src/App.css29
-rw-r--r--src/renderer/src/components/Editor.tsx43
-rw-r--r--src/renderer/src/data/latexCommands.ts175
-rw-r--r--src/renderer/src/extensions/latexAutocomplete.ts5
-rw-r--r--src/renderer/src/extensions/mathHighlight.ts146
-rw-r--r--src/renderer/src/extensions/mathPreview.ts45
6 files changed, 309 insertions, 134 deletions
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css
index c6d8126..aae0b4b 100644
--- a/src/renderer/src/App.css
+++ b/src/renderer/src/App.css
@@ -2221,6 +2221,18 @@ html, body, #root {
font-family: var(--font-mono);
}
+/* ── Math Region Highlighting ─────────────────────────────── */
+
+.cm-math-inline {
+ background: rgba(184, 134, 11, 0.06);
+ border-radius: 2px;
+}
+
+.cm-math-display {
+ background: rgba(184, 134, 11, 0.08);
+ border-radius: 3px;
+}
+
/* ── Math Preview ──────────────────────────────────────────── */
.cm-math-preview {
@@ -2229,3 +2241,20 @@ html, body, #root {
border-radius: var(--radius);
box-shadow: var(--shadow-md);
}
+
+.cm-math-preview .katex {
+ font-size: 1.15em;
+}
+
+.cm-math-preview .katex-display {
+ margin: 0;
+}
+
+/* ── Autocomplete Symbol ─────────────────────────────────── */
+
+.cm-completionDetail .completion-symbol {
+ font-family: "Times New Roman", "STIX Two Math", serif;
+ font-style: normal;
+ font-size: 13px;
+ margin-right: 4px;
+}
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
index a95d15c..75b3872 100644
--- a/src/renderer/src/components/Editor.tsx
+++ b/src/renderer/src/components/Editor.tsx
@@ -5,10 +5,11 @@ import { useEffect, useRef, useState, useCallback } from 'react'
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, rectangularSelection } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
-import { bracketMatching, foldGutter, indentOnInput, StreamLanguage } from '@codemirror/language'
+import { bracketMatching, foldGutter, indentOnInput, StreamLanguage, syntaxHighlighting, HighlightStyle } from '@codemirror/language'
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { stex } from '@codemirror/legacy-modes/mode/stex'
+import { tags } from '@lezer/highlight'
import { useAppStore } from '../stores/appStore'
import {
commentHighlights,
@@ -24,6 +25,7 @@ import { latexAutocomplete } from '../extensions/latexAutocomplete'
import { latexFolding } from '../extensions/latexFolding'
import { latexClosing } from '../extensions/latexClosing'
import { mathPreview } from '../extensions/mathPreview'
+import { mathHighlight } from '../extensions/mathHighlight'
import { OverleafDocSync } from '../ot/overleafSync'
import { activeDocSyncs, remoteCursors } from '../App'
@@ -46,23 +48,30 @@ const cosmicLatteTheme = EditorView.theme({
'.cm-lineNumbers .cm-gutterElement': { padding: '0 8px' },
'.cm-foldGutter': { width: '16px' },
'.cm-matchingBracket': { backgroundColor: '#D4C9A8', outline: 'none' },
- // LaTeX syntax colors
- '.cm-keyword': { color: '#8B2252', fontWeight: '600' }, // \commands
- '.cm-atom': { color: '#B8860B' }, // special symbols
- '.cm-string': { color: '#5B8A3C' }, // arguments
- '.cm-comment': { color: '#A09880', fontStyle: 'italic' },
- '.cm-bracket': { color: '#4A6FA5' }, // { } [ ]
- '.cm-tag': { color: '#8B2252', fontWeight: '600' }, // \begin \end
- '.cm-builtin': { color: '#6B5B3E' },
- '.cm-meta': { color: '#C75643' }, // $ math delimiters
- '.cm-number': { color: '#B8860B' },
- // StreamLanguage stex token class overrides
- '.ͼ5': { color: '#8B2252', fontWeight: '600' }, // keyword
- '.ͼ6': { color: '#4A6FA5' }, // bracket/variable
- '.ͼ7': { color: '#5B8A3C' }, // string
- '.ͼ8': { color: '#A09880', fontStyle: 'italic' }, // comment
}, { dark: false })
+const cosmicLatteHighlight = HighlightStyle.define([
+ { tag: tags.keyword, color: '#8B2252', fontWeight: '600' }, // \commands
+ { tag: tags.tagName, color: '#8B2252', fontWeight: '600' }, // \begin \end
+ { tag: tags.atom, color: '#B8860B' }, // special symbols
+ { tag: tags.number, color: '#B8860B' }, // numbers
+ { tag: tags.string, color: '#5B8A3C' }, // arguments in braces
+ { tag: tags.comment, color: '#A09880', fontStyle: 'italic' }, // % comments
+ { tag: tags.bracket, color: '#4A6FA5' }, // { } [ ] ( )
+ { tag: tags.paren, color: '#4A6FA5' },
+ { tag: tags.squareBracket, color: '#4A6FA5' },
+ { tag: tags.brace, color: '#4A6FA5' },
+ { tag: tags.meta, color: '#C75643' }, // $ math delimiters
+ { tag: tags.standard(tags.name), color: '#6B5B3E' }, // builtins
+ { tag: tags.variableName, color: '#4A6FA5' }, // variables
+ { tag: tags.definition(tags.variableName), color: '#6B5B3E' }, // definitions
+ { tag: tags.operator, color: '#8B6B8B' }, // operators
+ { tag: tags.heading, color: '#8B2252', fontWeight: '700' }, // headings
+ { tag: tags.contentSeparator, color: '#D6CEBC' }, // horizontal rules
+ { tag: tags.url, color: '#4A6FA5', textDecoration: 'underline' }, // URLs
+ { tag: tags.invalid, color: '#C75643', textDecoration: 'underline' }, // errors
+])
+
export default function Editor() {
const editorRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
@@ -221,6 +230,7 @@ export default function Editor() {
history(),
highlightSelectionMatches(),
StreamLanguage.define(stex),
+ syntaxHighlighting(cosmicLatteHighlight),
keymap.of([
...defaultKeymap,
...historyKeymap,
@@ -236,6 +246,7 @@ export default function Editor() {
latexFolding(),
latexClosing(),
mathPreview(),
+ mathHighlight,
commentHighlights(),
overleafProjectId ? addCommentTooltip() : [],
...otExt,
diff --git a/src/renderer/src/data/latexCommands.ts b/src/renderer/src/data/latexCommands.ts
index 2e2dad7..2364e4b 100644
--- a/src/renderer/src/data/latexCommands.ts
+++ b/src/renderer/src/data/latexCommands.ts
@@ -10,6 +10,7 @@ export interface LatexCommand {
snippet?: string // e.g. "\\frac{$1}{$2}" — if absent, label is used as-is
detail?: string // short description
section?: string // category for grouping
+ symbol?: string // Unicode symbol preview (e.g. "α" for \alpha)
}
export const latexCommands: LatexCommand[] = [
@@ -92,29 +93,29 @@ export const latexCommands: LatexCommand[] = [
{ label: '\\tfrac', snippet: '\\tfrac{$1}{$2}', detail: 'Text fraction', section: 'math' },
{ label: '\\sqrt', snippet: '\\sqrt{$1}', detail: 'Square root', section: 'math' },
{ label: '\\sqrt[]', snippet: '\\sqrt[${1:n}]{$2}', detail: 'Nth root', section: 'math' },
- { label: '\\sum', snippet: '\\sum_{${1:i=1}}^{${2:n}}', detail: 'Summation', section: 'math' },
- { label: '\\prod', snippet: '\\prod_{${1:i=1}}^{${2:n}}', detail: 'Product', section: 'math' },
- { label: '\\int', snippet: '\\int_{${1:a}}^{${2:b}}', detail: 'Integral', section: 'math' },
- { label: '\\iint', snippet: '\\iint_{$1}', detail: 'Double integral', section: 'math' },
- { label: '\\iiint', snippet: '\\iiint_{$1}', detail: 'Triple integral', section: 'math' },
- { label: '\\oint', snippet: '\\oint_{$1}', detail: 'Contour integral', section: 'math' },
+ { label: '\\sum', snippet: '\\sum_{${1:i=1}}^{${2:n}}', detail: 'Summation', section: 'math', symbol: '∑' },
+ { label: '\\prod', snippet: '\\prod_{${1:i=1}}^{${2:n}}', detail: 'Product', section: 'math', symbol: '∏' },
+ { label: '\\int', snippet: '\\int_{${1:a}}^{${2:b}}', detail: 'Integral', section: 'math', symbol: '∫' },
+ { label: '\\iint', snippet: '\\iint_{$1}', detail: 'Double integral', section: 'math', symbol: '∬' },
+ { label: '\\iiint', snippet: '\\iiint_{$1}', detail: 'Triple integral', section: 'math', symbol: '∭' },
+ { label: '\\oint', snippet: '\\oint_{$1}', detail: 'Contour integral', section: 'math', symbol: '∮' },
{ label: '\\lim', snippet: '\\lim_{${1:x \\to \\infty}}', detail: 'Limit', section: 'math' },
- { label: '\\infty', detail: 'Infinity', section: 'math' },
- { label: '\\partial', detail: 'Partial derivative', section: 'math' },
- { label: '\\nabla', detail: 'Nabla/Del', section: 'math' },
- { label: '\\forall', detail: 'For all', section: 'math' },
- { label: '\\exists', detail: 'Exists', section: 'math' },
- { label: '\\nexists', detail: 'Not exists', section: 'math' },
- { label: '\\in', detail: 'Element of', section: 'math' },
- { label: '\\notin', detail: 'Not element of', section: 'math' },
- { label: '\\subset', detail: 'Subset', section: 'math' },
- { label: '\\subseteq', detail: 'Subset or equal', section: 'math' },
- { label: '\\supset', detail: 'Superset', section: 'math' },
- { label: '\\supseteq', detail: 'Superset or equal', section: 'math' },
- { label: '\\cup', detail: 'Union', section: 'math' },
- { label: '\\cap', detail: 'Intersection', section: 'math' },
- { label: '\\emptyset', detail: 'Empty set', section: 'math' },
- { label: '\\varnothing', detail: 'Empty set (variant)', section: 'math' },
+ { label: '\\infty', detail: 'Infinity', section: 'math', symbol: '∞' },
+ { label: '\\partial', detail: 'Partial derivative', section: 'math', symbol: '∂' },
+ { label: '\\nabla', detail: 'Nabla/Del', section: 'math', symbol: '∇' },
+ { label: '\\forall', detail: 'For all', section: 'math', symbol: '∀' },
+ { label: '\\exists', detail: 'Exists', section: 'math', symbol: '∃' },
+ { label: '\\nexists', detail: 'Not exists', section: 'math', symbol: '∄' },
+ { label: '\\in', detail: 'Element of', section: 'math', symbol: '∈' },
+ { label: '\\notin', detail: 'Not element of', section: 'math', symbol: '∉' },
+ { label: '\\subset', detail: 'Subset', section: 'math', symbol: '⊂' },
+ { label: '\\subseteq', detail: 'Subset or equal', section: 'math', symbol: '⊆' },
+ { label: '\\supset', detail: 'Superset', section: 'math', symbol: '⊃' },
+ { label: '\\supseteq', detail: 'Superset or equal', section: 'math', symbol: '⊇' },
+ { label: '\\cup', detail: 'Union', section: 'math', symbol: '∪' },
+ { label: '\\cap', detail: 'Intersection', section: 'math', symbol: '∩' },
+ { label: '\\emptyset', detail: 'Empty set', section: 'math', symbol: '∅' },
+ { label: '\\varnothing', detail: 'Empty set (variant)', section: 'math', symbol: '∅' },
{ label: '\\mathbb', snippet: '\\mathbb{$1}', detail: 'Blackboard bold', section: 'math' },
{ label: '\\mathcal', snippet: '\\mathcal{$1}', detail: 'Calligraphic', section: 'math' },
{ label: '\\mathfrak', snippet: '\\mathfrak{$1}', detail: 'Fraktur', section: 'math' },
@@ -136,71 +137,71 @@ export const latexCommands: LatexCommand[] = [
{ label: '\\right', detail: 'Right delimiter', section: 'math' },
{ label: '\\bigl', detail: 'Big left', section: 'math' },
{ label: '\\bigr', detail: 'Big right', section: 'math' },
- { label: '\\cdot', detail: 'Center dot', section: 'math' },
- { label: '\\cdots', detail: 'Center dots', section: 'math' },
- { label: '\\ldots', detail: 'Low dots', section: 'math' },
- { label: '\\vdots', detail: 'Vertical dots', section: 'math' },
- { label: '\\ddots', detail: 'Diagonal dots', section: 'math' },
- { label: '\\times', detail: 'Times', section: 'math' },
- { label: '\\div', detail: 'Division', section: 'math' },
- { label: '\\pm', detail: 'Plus-minus', section: 'math' },
- { label: '\\mp', detail: 'Minus-plus', section: 'math' },
- { label: '\\leq', detail: 'Less or equal', section: 'math' },
- { label: '\\geq', detail: 'Greater or equal', section: 'math' },
- { label: '\\neq', detail: 'Not equal', section: 'math' },
- { label: '\\approx', detail: 'Approximately', section: 'math' },
- { label: '\\equiv', detail: 'Equivalent', section: 'math' },
- { label: '\\sim', detail: 'Similar', section: 'math' },
- { label: '\\propto', detail: 'Proportional to', section: 'math' },
- { label: '\\ll', detail: 'Much less', section: 'math' },
- { label: '\\gg', detail: 'Much greater', section: 'math' },
- { label: '\\to', detail: 'Right arrow', section: 'math' },
- { label: '\\rightarrow', detail: 'Right arrow', section: 'math' },
- { label: '\\leftarrow', detail: 'Left arrow', section: 'math' },
- { label: '\\leftrightarrow', detail: 'Left-right arrow', section: 'math' },
- { label: '\\Rightarrow', detail: 'Double right arrow', section: 'math' },
- { label: '\\Leftarrow', detail: 'Double left arrow', section: 'math' },
- { label: '\\Leftrightarrow', detail: 'Double left-right arrow', section: 'math' },
- { label: '\\mapsto', detail: 'Maps to', section: 'math' },
- { label: '\\uparrow', detail: 'Up arrow', section: 'math' },
- { label: '\\downarrow', detail: 'Down arrow', section: 'math' },
- { label: '\\alpha', detail: 'Greek alpha', section: 'greek' },
- { label: '\\beta', detail: 'Greek beta', section: 'greek' },
- { label: '\\gamma', detail: 'Greek gamma', section: 'greek' },
- { label: '\\Gamma', detail: 'Greek Gamma', section: 'greek' },
- { label: '\\delta', detail: 'Greek delta', section: 'greek' },
- { label: '\\Delta', detail: 'Greek Delta', section: 'greek' },
- { label: '\\epsilon', detail: 'Greek epsilon', section: 'greek' },
- { label: '\\varepsilon', detail: 'Greek varepsilon', section: 'greek' },
- { label: '\\zeta', detail: 'Greek zeta', section: 'greek' },
- { label: '\\eta', detail: 'Greek eta', section: 'greek' },
- { label: '\\theta', detail: 'Greek theta', section: 'greek' },
- { label: '\\Theta', detail: 'Greek Theta', section: 'greek' },
- { label: '\\vartheta', detail: 'Greek vartheta', section: 'greek' },
- { label: '\\iota', detail: 'Greek iota', section: 'greek' },
- { label: '\\kappa', detail: 'Greek kappa', section: 'greek' },
- { label: '\\lambda', detail: 'Greek lambda', section: 'greek' },
- { label: '\\Lambda', detail: 'Greek Lambda', section: 'greek' },
- { label: '\\mu', detail: 'Greek mu', section: 'greek' },
- { label: '\\nu', detail: 'Greek nu', section: 'greek' },
- { label: '\\xi', detail: 'Greek xi', section: 'greek' },
- { label: '\\Xi', detail: 'Greek Xi', section: 'greek' },
- { label: '\\pi', detail: 'Greek pi', section: 'greek' },
- { label: '\\Pi', detail: 'Greek Pi', section: 'greek' },
- { label: '\\rho', detail: 'Greek rho', section: 'greek' },
- { label: '\\varrho', detail: 'Greek varrho', section: 'greek' },
- { label: '\\sigma', detail: 'Greek sigma', section: 'greek' },
- { label: '\\Sigma', detail: 'Greek Sigma', section: 'greek' },
- { label: '\\tau', detail: 'Greek tau', section: 'greek' },
- { label: '\\upsilon', detail: 'Greek upsilon', section: 'greek' },
- { label: '\\phi', detail: 'Greek phi', section: 'greek' },
- { label: '\\Phi', detail: 'Greek Phi', section: 'greek' },
- { label: '\\varphi', detail: 'Greek varphi', section: 'greek' },
- { label: '\\chi', detail: 'Greek chi', section: 'greek' },
- { label: '\\psi', detail: 'Greek psi', section: 'greek' },
- { label: '\\Psi', detail: 'Greek Psi', section: 'greek' },
- { label: '\\omega', detail: 'Greek omega', section: 'greek' },
- { label: '\\Omega', detail: 'Greek Omega', section: 'greek' },
+ { label: '\\cdot', detail: 'Center dot', section: 'math', symbol: '·' },
+ { label: '\\cdots', detail: 'Center dots', section: 'math', symbol: '⋯' },
+ { label: '\\ldots', detail: 'Low dots', section: 'math', symbol: '…' },
+ { label: '\\vdots', detail: 'Vertical dots', section: 'math', symbol: '⋮' },
+ { label: '\\ddots', detail: 'Diagonal dots', section: 'math', symbol: '⋱' },
+ { label: '\\times', detail: 'Times', section: 'math', symbol: '×' },
+ { label: '\\div', detail: 'Division', section: 'math', symbol: '÷' },
+ { label: '\\pm', detail: 'Plus-minus', section: 'math', symbol: '±' },
+ { label: '\\mp', detail: 'Minus-plus', section: 'math', symbol: '∓' },
+ { label: '\\leq', detail: 'Less or equal', section: 'math', symbol: '≤' },
+ { label: '\\geq', detail: 'Greater or equal', section: 'math', symbol: '≥' },
+ { label: '\\neq', detail: 'Not equal', section: 'math', symbol: '≠' },
+ { label: '\\approx', detail: 'Approximately', section: 'math', symbol: '≈' },
+ { label: '\\equiv', detail: 'Equivalent', section: 'math', symbol: '≡' },
+ { label: '\\sim', detail: 'Similar', section: 'math', symbol: '∼' },
+ { label: '\\propto', detail: 'Proportional to', section: 'math', symbol: '∝' },
+ { label: '\\ll', detail: 'Much less', section: 'math', symbol: '≪' },
+ { label: '\\gg', detail: 'Much greater', section: 'math', symbol: '≫' },
+ { label: '\\to', detail: 'Right arrow', section: 'math', symbol: '→' },
+ { label: '\\rightarrow', detail: 'Right arrow', section: 'math', symbol: '→' },
+ { label: '\\leftarrow', detail: 'Left arrow', section: 'math', symbol: '←' },
+ { label: '\\leftrightarrow', detail: 'Left-right arrow', section: 'math', symbol: '↔' },
+ { label: '\\Rightarrow', detail: 'Double right arrow', section: 'math', symbol: '⇒' },
+ { label: '\\Leftarrow', detail: 'Double left arrow', section: 'math', symbol: '⇐' },
+ { label: '\\Leftrightarrow', detail: 'Double left-right arrow', section: 'math', symbol: '⇔' },
+ { label: '\\mapsto', detail: 'Maps to', section: 'math', symbol: '↦' },
+ { label: '\\uparrow', detail: 'Up arrow', section: 'math', symbol: '↑' },
+ { label: '\\downarrow', detail: 'Down arrow', section: 'math', symbol: '↓' },
+ { label: '\\alpha', detail: 'Greek alpha', section: 'greek', symbol: 'α' },
+ { label: '\\beta', detail: 'Greek beta', section: 'greek', symbol: 'β' },
+ { label: '\\gamma', detail: 'Greek gamma', section: 'greek', symbol: 'γ' },
+ { label: '\\Gamma', detail: 'Greek Gamma', section: 'greek', symbol: 'Γ' },
+ { label: '\\delta', detail: 'Greek delta', section: 'greek', symbol: 'δ' },
+ { label: '\\Delta', detail: 'Greek Delta', section: 'greek', symbol: 'Δ' },
+ { label: '\\epsilon', detail: 'Greek epsilon', section: 'greek', symbol: 'ϵ' },
+ { label: '\\varepsilon', detail: 'Greek varepsilon', section: 'greek', symbol: 'ε' },
+ { label: '\\zeta', detail: 'Greek zeta', section: 'greek', symbol: 'ζ' },
+ { label: '\\eta', detail: 'Greek eta', section: 'greek', symbol: 'η' },
+ { label: '\\theta', detail: 'Greek theta', section: 'greek', symbol: 'θ' },
+ { label: '\\Theta', detail: 'Greek Theta', section: 'greek', symbol: 'Θ' },
+ { label: '\\vartheta', detail: 'Greek vartheta', section: 'greek', symbol: 'ϑ' },
+ { label: '\\iota', detail: 'Greek iota', section: 'greek', symbol: 'ι' },
+ { label: '\\kappa', detail: 'Greek kappa', section: 'greek', symbol: 'κ' },
+ { label: '\\lambda', detail: 'Greek lambda', section: 'greek', symbol: 'λ' },
+ { label: '\\Lambda', detail: 'Greek Lambda', section: 'greek', symbol: 'Λ' },
+ { label: '\\mu', detail: 'Greek mu', section: 'greek', symbol: 'μ' },
+ { label: '\\nu', detail: 'Greek nu', section: 'greek', symbol: 'ν' },
+ { label: '\\xi', detail: 'Greek xi', section: 'greek', symbol: 'ξ' },
+ { label: '\\Xi', detail: 'Greek Xi', section: 'greek', symbol: 'Ξ' },
+ { label: '\\pi', detail: 'Greek pi', section: 'greek', symbol: 'π' },
+ { label: '\\Pi', detail: 'Greek Pi', section: 'greek', symbol: 'Π' },
+ { label: '\\rho', detail: 'Greek rho', section: 'greek', symbol: 'ρ' },
+ { label: '\\varrho', detail: 'Greek varrho', section: 'greek', symbol: 'ϱ' },
+ { label: '\\sigma', detail: 'Greek sigma', section: 'greek', symbol: 'σ' },
+ { label: '\\Sigma', detail: 'Greek Sigma', section: 'greek', symbol: 'Σ' },
+ { label: '\\tau', detail: 'Greek tau', section: 'greek', symbol: 'τ' },
+ { label: '\\upsilon', detail: 'Greek upsilon', section: 'greek', symbol: 'υ' },
+ { label: '\\phi', detail: 'Greek phi', section: 'greek', symbol: 'ϕ' },
+ { label: '\\Phi', detail: 'Greek Phi', section: 'greek', symbol: 'Φ' },
+ { label: '\\varphi', detail: 'Greek varphi', section: 'greek', symbol: 'φ' },
+ { label: '\\chi', detail: 'Greek chi', section: 'greek', symbol: 'χ' },
+ { label: '\\psi', detail: 'Greek psi', section: 'greek', symbol: 'ψ' },
+ { label: '\\Psi', detail: 'Greek Psi', section: 'greek', symbol: 'Ψ' },
+ { label: '\\omega', detail: 'Greek omega', section: 'greek', symbol: 'ω' },
+ { label: '\\Omega', detail: 'Greek Omega', section: 'greek', symbol: 'Ω' },
// ── Environments (as commands) ──
{ label: '\\begin', snippet: '\\begin{$1}\n\t$2\n\\end{$1}', detail: 'Begin environment', section: 'env' },
diff --git a/src/renderer/src/extensions/latexAutocomplete.ts b/src/renderer/src/extensions/latexAutocomplete.ts
index a151876..df00bb4 100644
--- a/src/renderer/src/extensions/latexAutocomplete.ts
+++ b/src/renderer/src/extensions/latexAutocomplete.ts
@@ -172,17 +172,18 @@ function commandSource(context: CompletionContext): CompletionResult | null {
if (word.text.length < 2 && !context.explicit) return null
const options: Completion[] = latexCommands.map((cmd) => {
+ const detail = cmd.symbol ? `${cmd.symbol} ${cmd.detail || ''}` : cmd.detail
if (cmd.snippet) {
return snippetCompletion(cmd.snippet, {
label: cmd.label,
- detail: cmd.detail,
+ detail,
type: 'function',
boost: cmd.section === 'structure' || cmd.section === 'sectioning' ? 2 : 0,
})
}
return {
label: cmd.label,
- detail: cmd.detail,
+ detail,
type: 'function',
}
})
diff --git a/src/renderer/src/extensions/mathHighlight.ts b/src/renderer/src/extensions/mathHighlight.ts
new file mode 100644
index 0000000..f2c492b
--- /dev/null
+++ b/src/renderer/src/extensions/mathHighlight.ts
@@ -0,0 +1,146 @@
+// Copyright (c) 2026 Yuren Hao
+// Licensed under AGPL-3.0 - see LICENSE file
+
+/**
+ * CodeMirror 6 extension: background highlight for math regions.
+ * Adds subtle background color to $...$ (inline) and $$...$$ / \[...\] (display) regions.
+ */
+import { ViewPlugin, Decoration, type DecorationSet, EditorView, ViewUpdate } from '@codemirror/view'
+import { RangeSetBuilder } from '@codemirror/state'
+
+const inlineMathDeco = Decoration.mark({ class: 'cm-math-inline' })
+const displayMathDeco = Decoration.mark({ class: 'cm-math-display' })
+
+interface MathRegion {
+ from: number
+ to: number
+ display: boolean
+}
+
+function findMathRegions(text: string): MathRegion[] {
+ const regions: MathRegion[] = []
+ let i = 0
+
+ while (i < text.length) {
+ // Skip escaped characters
+ if (text[i] === '\\' && i + 1 < text.length) {
+ // Check for \[ ... \] (display math)
+ if (text[i + 1] === '[') {
+ const start = i
+ const closeIdx = text.indexOf('\\]', i + 2)
+ if (closeIdx !== -1) {
+ regions.push({ from: start, to: closeIdx + 2, display: true })
+ i = closeIdx + 2
+ continue
+ }
+ }
+ // Check for \( ... \) (inline math)
+ if (text[i + 1] === '(') {
+ const start = i
+ const closeIdx = text.indexOf('\\)', i + 2)
+ if (closeIdx !== -1) {
+ regions.push({ from: start, to: closeIdx + 2, display: false })
+ i = closeIdx + 2
+ continue
+ }
+ }
+ // Skip other escape sequences
+ i += 2
+ continue
+ }
+
+ // Check for $$ (display math)
+ if (text[i] === '$' && i + 1 < text.length && text[i + 1] === '$') {
+ const start = i
+ // Find closing $$
+ let j = i + 2
+ while (j < text.length - 1) {
+ if (text[j] === '$' && text[j + 1] === '$' && text[j - 1] !== '\\') {
+ regions.push({ from: start, to: j + 2, display: true })
+ i = j + 2
+ break
+ }
+ j++
+ }
+ if (j >= text.length - 1) {
+ i = j + 1
+ }
+ continue
+ }
+
+ // Check for $ (inline math)
+ if (text[i] === '$') {
+ // Don't match if preceded by backslash
+ if (i > 0 && text[i - 1] === '\\') {
+ i++
+ continue
+ }
+ const start = i
+ let j = i + 1
+ while (j < text.length) {
+ if (text[j] === '$' && text[j - 1] !== '\\') {
+ // Make sure it's not $$
+ if (j + 1 < text.length && text[j + 1] === '$') {
+ j++
+ continue
+ }
+ regions.push({ from: start, to: j + 1, display: false })
+ i = j + 1
+ break
+ }
+ // Inline math doesn't span paragraphs
+ if (text[j] === '\n' && j + 1 < text.length && text[j + 1] === '\n') {
+ i = j
+ break
+ }
+ j++
+ }
+ if (j >= text.length) {
+ i = j
+ }
+ continue
+ }
+
+ i++
+ }
+
+ return regions
+}
+
+function buildDecorations(view: EditorView): DecorationSet {
+ const { from, to } = view.viewport
+ // Extend a bit beyond viewport for smooth scrolling
+ const extFrom = Math.max(0, from - 1000)
+ const extTo = Math.min(view.state.doc.length, to + 1000)
+ const text = view.state.doc.sliceString(extFrom, extTo)
+ const regions = findMathRegions(text)
+
+ const builder = new RangeSetBuilder<Decoration>()
+ for (const r of regions) {
+ const absFrom = extFrom + r.from
+ const absTo = extFrom + r.to
+ // Only add decorations that are at least partially in the viewport
+ if (absTo < from || absFrom > to) continue
+ const clampFrom = Math.max(absFrom, 0)
+ const clampTo = Math.min(absTo, view.state.doc.length)
+ if (clampFrom < clampTo) {
+ builder.add(clampFrom, clampTo, r.display ? displayMathDeco : inlineMathDeco)
+ }
+ }
+ return builder.finish()
+}
+
+export const mathHighlight = ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet
+ constructor(view: EditorView) {
+ this.decorations = buildDecorations(view)
+ }
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.viewportChanged) {
+ this.decorations = buildDecorations(update.view)
+ }
+ }
+ },
+ { decorations: (v) => v.decorations }
+)
diff --git a/src/renderer/src/extensions/mathPreview.ts b/src/renderer/src/extensions/mathPreview.ts
index 01ac0f6..9338b8f 100644
--- a/src/renderer/src/extensions/mathPreview.ts
+++ b/src/renderer/src/extensions/mathPreview.ts
@@ -3,14 +3,14 @@
/**
* CodeMirror 6 extension: hover preview for LaTeX math expressions.
- * Shows a rendered preview tooltip when hovering over $...$ or $$...$$ or \(...\) or \[...\].
+ * Uses KaTeX for beautiful rendered math when hovering over $...$ or $$...$$ or \(...\) or \[...\].
*/
import { hoverTooltip, type Tooltip } from '@codemirror/view'
+import katex from 'katex'
+import 'katex/dist/katex.min.css'
/** Find the math expression surrounding the given position */
function findMathAt(docText: string, pos: number): { from: number; to: number; tex: string; display: boolean } | null {
- // Search for display math first ($$...$$, \[...\])
- // Then inline math ($...$, \(...\))
const patterns: Array<{ open: string; close: string; display: boolean }> = [
{ open: '$$', close: '$$', display: true },
{ open: '\\[', close: '\\]', display: true },
@@ -19,7 +19,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
]
for (const { open, close, display } of patterns) {
- // Search backward for opener
const searchStart = Math.max(0, pos - 2000)
const before = docText.slice(searchStart, pos + open.length)
@@ -28,7 +27,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
while (searchFrom >= 0) {
const idx = before.lastIndexOf(open, searchFrom)
if (idx === -1) break
- // For $$, skip if it's actually a single $ at boundary
if (open === '$$' && idx > 0 && docText[searchStart + idx - 1] === '$') {
searchFrom = idx - 1
continue
@@ -42,7 +40,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
}
if (openIdx === -1 || openIdx > pos) continue
- // Search forward for closer
const afterStart = openIdx + open.length
const closeIdx = docText.indexOf(close, Math.max(afterStart, pos - close.length + 1))
if (closeIdx === -1 || closeIdx < pos - close.length) continue
@@ -51,7 +48,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
const contentEnd = closeIdx
if (contentEnd <= contentStart) continue
- // Check pos is within the math region
if (pos < openIdx || pos > closeIdx + close.length) continue
const tex = docText.slice(contentStart, contentEnd).trim()
@@ -63,27 +59,6 @@ function findMathAt(docText: string, pos: number): { from: number; to: number; t
return null
}
-/** Render LaTeX to HTML using KaTeX-like approach via CSS */
-function renderMathToHtml(tex: string, display: boolean): string {
- // Use a simple approach: create an img tag with a data URI from a math rendering service
- // Or use the browser's MathML support
- // For simplicity, we'll render using MathML basic support + fallback to raw TeX
-
- // Try MathML rendering for common patterns, fallback to formatted TeX display
- const escaped = tex
- .replace(/&/g, '&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()
@@ -97,7 +72,19 @@ export function mathPreview() {
create() {
const dom = document.createElement('div')
dom.className = 'cm-math-preview'
- dom.innerHTML = renderMathToHtml(result.tex, result.display)
+ try {
+ const html = katex.renderToString(result.tex, {
+ displayMode: result.display,
+ throwOnError: false,
+ errorColor: '#C75643',
+ trust: true,
+ strict: false,
+ output: 'html',
+ })
+ dom.innerHTML = `<div style="padding: 10px 14px; max-width: 500px; overflow-x: auto;">${html}</div>`
+ } catch {
+ dom.innerHTML = `<div style="padding: 8px 12px; font-family: monospace; font-size: 12px; color: #C75643;">Error rendering: ${result.tex}</div>`
+ }
return { dom }
}
}