diff options
| -rw-r--r-- | package-lock.json | 39 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | src/renderer/src/App.css | 29 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 43 | ||||
| -rw-r--r-- | src/renderer/src/data/latexCommands.ts | 175 | ||||
| -rw-r--r-- | src/renderer/src/extensions/latexAutocomplete.ts | 5 | ||||
| -rw-r--r-- | src/renderer/src/extensions/mathHighlight.ts | 146 | ||||
| -rw-r--r-- | src/renderer/src/extensions/mathPreview.ts | 45 |
8 files changed, 348 insertions, 136 deletions
diff --git a/package-lock.json b/package-lock.json index 4a4b460..6324729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "lattex", - "version": "0.1.0", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lattex", - "version": "0.1.0", + "version": "0.2.2", "hasInstallScript": true, + "license": "AGPL-3.0", "dependencies": { "@codemirror/autocomplete": "^6.18.0", "@codemirror/commands": "^6.6.0", @@ -20,6 +21,7 @@ "@xterm/xterm": "^5.5.0", "chokidar": "^3.6.0", "diff-match-patch": "^1.0.5", + "katex": "^0.16.38", "node-pty": "^1.0.0", "pdfjs-dist": "^4.9.155", "react": "^18.3.1", @@ -30,6 +32,7 @@ }, "devDependencies": { "@types/diff-match-patch": "^1.0.36", + "@types/katex": "^0.16.8", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", "@types/ws": "^8.18.1", @@ -2258,6 +2261,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -5381,6 +5391,31 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/katex": { + "version": "0.16.38", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", + "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 5e157b2..70a245a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@xterm/xterm": "^5.5.0", "chokidar": "^3.6.0", "diff-match-patch": "^1.0.5", + "katex": "^0.16.38", "node-pty": "^1.0.0", "pdfjs-dist": "^4.9.155", "react": "^18.3.1", @@ -35,6 +36,7 @@ }, "devDependencies": { "@types/diff-match-patch": "^1.0.36", + "@types/katex": "^0.16.8", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", "@types/ws": "^8.18.1", 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, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - - 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 } } } |
