summaryrefslogtreecommitdiff
path: root/src/renderer/src/extensions/latexClosing.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/renderer/src/extensions/latexClosing.ts')
-rw-r--r--src/renderer/src/extensions/latexClosing.ts230
1 files changed, 230 insertions, 0 deletions
diff --git a/src/renderer/src/extensions/latexClosing.ts b/src/renderer/src/extensions/latexClosing.ts
new file mode 100644
index 0000000..99da35c
--- /dev/null
+++ b/src/renderer/src/extensions/latexClosing.ts
@@ -0,0 +1,230 @@
+// Copyright (c) 2026 Yuren Hao
+// Licensed under AGPL-3.0 - see LICENSE file
+
+import { EditorView, keymap } from '@codemirror/view'
+import { EditorSelection } from '@codemirror/state'
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+/** Pair definitions for surround/auto-close behavior */
+const PAIRS: Record<string, string> = {
+ '$': '$',
+ '{': '}',
+ '(': ')',
+ '[': ']',
+}
+
+/**
+ * Extract the environment name from a \begin{envname} that ends right
+ * at or before `pos` on the same line.
+ */
+function getBeginEnvBefore(doc: string, pos: number): string | null {
+ // Look backwards from pos to find the line
+ const lineStart = doc.lastIndexOf('\n', pos - 1) + 1
+ const lineBefore = doc.slice(lineStart, pos)
+ // Match \begin{envname} at the end (possibly with trailing whitespace)
+ const m = lineBefore.match(/\\begin\{([^}]+)\}\s*$/)
+ return m ? m[1] : null
+}
+
+// ── 1. Auto-close \begin{env} on Enter ──────────────────────────────
+
+const beginEnvEnterKeymap = keymap.of([
+ {
+ key: 'Enter',
+ run(view) {
+ const { state } = view
+ const { main } = state.selection
+
+ // Only handle when there is no selection
+ if (!main.empty) return false
+
+ const pos = main.head
+ const doc = state.doc.toString()
+ const envName = getBeginEnvBefore(doc, pos)
+ if (!envName) return false
+
+ // Check that there is no existing \end{envName} on the next non-empty lines
+ // that would indicate the environment is already closed nearby
+ const afterCursor = doc.slice(pos)
+ const closingTag = `\\end{${envName}}`
+
+ // Look at the text right after cursor (same line remainder + next few lines)
+ const nextChunk = afterCursor.slice(0, 200)
+ const alreadyClosed = nextChunk.split('\n').some((line) => line.trim() === closingTag)
+ if (alreadyClosed) return false
+
+ // Determine the indentation of the \begin line
+ const line = state.doc.lineAt(pos)
+ const lineText = line.text
+ const indentMatch = lineText.match(/^(\s*)/)
+ const baseIndent = indentMatch ? indentMatch[1] : ''
+ // Use two spaces (or tab) as inner indent — follow the existing indent style
+ const innerIndent = baseIndent + ' '
+
+ // Insert: \n<innerIndent>\n<baseIndent>\end{envName}
+ const insert = `\n${innerIndent}\n${baseIndent}${closingTag}`
+ const cursorPos = pos + 1 + innerIndent.length // after \n + innerIndent
+
+ view.dispatch(
+ state.update({
+ changes: { from: pos, insert },
+ selection: EditorSelection.cursor(cursorPos),
+ scrollIntoView: true,
+ userEvent: 'input',
+ })
+ )
+ return true
+ },
+ },
+])
+
+// ── 2 & 3. Auto-close $ and surround selection ─────────────────────
+
+/**
+ * inputHandler for LaTeX-specific auto-close and surround.
+ *
+ * Handles:
+ * - `$`: auto-insert matching `$`, or surround selection with `$...$`
+ * - `{`, `(`, `[`: surround selection (auto-close for `{` is already
+ * handled by CM6's closeBrackets, so we only add surround behavior)
+ *
+ * Smart behavior:
+ * - Don't double-close if the character after cursor is already the
+ * closing counterpart.
+ * - For `$`, skip over a closing `$` instead of inserting a new one.
+ */
+const latexInputHandler = EditorView.inputHandler.of(
+ (view, from, to, inserted) => {
+ // We only care about single-character inserts that are in our pair map
+ if (inserted.length !== 1) return false
+ const close = PAIRS[inserted]
+ if (!close) return false
+
+ const { state } = view
+ const doc = state.doc
+
+ // ── Surround selection ──
+ // If there is a selection (from !== to), wrap it
+ if (from !== to) {
+ // For all pair chars, wrap selection
+ const selectedText = doc.sliceString(from, to)
+ const wrapped = inserted + selectedText + close
+ view.dispatch(
+ state.update({
+ changes: { from, to, insert: wrapped },
+ // Place cursor after the closing char so the selection is visible
+ selection: EditorSelection.range(from + 1, from + 1 + selectedText.length),
+ userEvent: 'input',
+ })
+ )
+ return true
+ }
+
+ // ── No selection: auto-close / skip logic ──
+
+ const pos = from
+ const charAfter = pos < doc.length ? doc.sliceString(pos, pos + 1) : ''
+
+ // For `{`, `(`, `[`: CM6 closeBrackets handles these already.
+ // We only handle surround (above). So skip auto-close for non-$ chars.
+ if (inserted !== '$') return false
+
+ // ── Dollar sign handling ──
+
+ // If the next character is already `$`, skip over it (don't double)
+ if (charAfter === '$') {
+ // But only if it looks like we are at the end of an inline math:
+ // count `$` before cursor; if odd, we're closing
+ const textBefore = doc.sliceString(Math.max(0, pos - 200), pos)
+ const dollarsBefore = (textBefore.match(/\$/g) || []).length
+ if (dollarsBefore % 2 === 1) {
+ // Odd number of $ before → this is a closing $, skip over it
+ view.dispatch(
+ state.update({
+ selection: EditorSelection.cursor(pos + 1),
+ scrollIntoView: true,
+ userEvent: 'input',
+ })
+ )
+ return true
+ }
+ }
+
+ // Don't auto-close if the character before is a backslash (e.g. \$)
+ if (pos > 0) {
+ const charBefore = doc.sliceString(pos - 1, pos)
+ if (charBefore === '\\') return false
+ }
+
+ // Don't auto-close if next char is already $ (and we didn't skip above,
+ // meaning even count → we'd be starting a new pair, but $ is right there)
+ if (charAfter === '$') return false
+
+ // Don't auto-close if we're inside a word (letter/digit right before or after)
+ if (charAfter && /[a-zA-Z0-9]/.test(charAfter)) return false
+
+ // Insert $$ and place cursor in between
+ view.dispatch(
+ state.update({
+ changes: { from: pos, insert: '$$' },
+ selection: EditorSelection.cursor(pos + 1),
+ scrollIntoView: true,
+ userEvent: 'input',
+ })
+ )
+ return true
+ }
+)
+
+// ── Backspace: delete matching pair ─────────────────────────────────
+
+const deletePairKeymap = keymap.of([
+ {
+ key: 'Backspace',
+ run(view) {
+ const { state } = view
+ const { main } = state.selection
+ if (!main.empty) return false
+
+ const pos = main.head
+ if (pos === 0 || pos >= state.doc.length) return false
+
+ const before = state.doc.sliceString(pos - 1, pos)
+ const after = state.doc.sliceString(pos, pos + 1)
+
+ // Check if we're between a matching pair we inserted
+ if (before === '$' && after === '$') {
+ // Delete both
+ view.dispatch(
+ state.update({
+ changes: { from: pos - 1, to: pos + 1 },
+ userEvent: 'delete',
+ })
+ )
+ return true
+ }
+
+ return false
+ },
+ },
+])
+
+// ── Export ────────────────────────────────────────────────────────────
+
+/**
+ * LaTeX-specific closing/surround extension for CodeMirror 6.
+ *
+ * Provides:
+ * - Auto-close `\begin{env}` with `\end{env}` on Enter
+ * - Auto-close `$...$` (smart, no double-close)
+ * - Surround selection with `$`, `{`, `(`, `[`
+ * - Backspace deletes matching `$` pair
+ */
+export function latexClosing() {
+ return [
+ beginEnvEnterKeymap,
+ latexInputHandler,
+ deletePairKeymap,
+ ]
+}