summaryrefslogtreecommitdiff
path: root/src/renderer/src/components/OutlineView.tsx
blob: 56901e8641897a02497373fe1b718586e2a6db2a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
// Copyright (c) 2026 Yuren Hao
// Licensed under AGPL-3.0 - see LICENSE file

import { useMemo, useCallback, useState } from 'react'
import { useAppStore } from '../stores/appStore'

/** LaTeX sectioning commands in hierarchical order (lowest number = highest level). */
const SECTION_LEVELS: Record<string, number> = {
  '\\part': 0,
  '\\chapter': 1,
  '\\section': 2,
  '\\subsection': 3,
  '\\subsubsection': 4,
  '\\paragraph': 5,
}

/**
 * Regex to match LaTeX sectioning commands.
 * Captures: (1) the command name, (2) optional *, (3) the title inside braces.
 * Handles \section{Title}, \section*{Title}, etc.
 */
const SECTION_REGEX = /\\(part|chapter|section|subsection|subsubsection|paragraph)\*?\s*\{([^}]*)\}/g

interface OutlineEntry {
  /** The sectioning command without backslash, e.g. "section" */
  command: string
  /** The hierarchy level (0 = part, 5 = paragraph) */
  level: number
  /** The title text from inside the braces */
  title: string
  /** 1-based line number in the document */
  line: number
}

/**
 * Parse LaTeX document content and extract sectioning commands.
 */
function parseOutline(content: string): OutlineEntry[] {
  const entries: OutlineEntry[] = []
  const lines = content.split('\n')

  for (let i = 0; i < lines.length; i++) {
    const lineText = lines[i]
    // Skip commented lines
    const trimmed = lineText.trimStart()
    if (trimmed.startsWith('%')) continue

    // Reset regex lastIndex for each line
    SECTION_REGEX.lastIndex = 0
    let match: RegExpExecArray | null
    while ((match = SECTION_REGEX.exec(lineText)) !== null) {
      // Check that the command is not inside a comment (% before the match on the same line)
      const beforeMatch = lineText.slice(0, match.index)
      if (beforeMatch.includes('%')) continue

      const command = match[1]
      const fullCommand = '\\' + command
      const level = SECTION_LEVELS[fullCommand]
      if (level === undefined) continue

      entries.push({
        command,
        level,
        title: match[2].trim(),
        line: i + 1, // 1-based
      })
    }
  }

  return entries
}

/**
 * Compute the visual indentation depth for each entry.
 * Instead of using absolute levels (which would leave gaps, e.g. if
 * a document uses \section and \subsubsection but not \subsection),
 * we compute relative depth based on the set of levels actually present.
 */
function computeDepths(entries: OutlineEntry[]): number[] {
  if (entries.length === 0) return []

  // Collect distinct levels present, sorted ascending
  const presentLevels = [...new Set(entries.map((e) => e.level))].sort((a, b) => a - b)
  const levelToDepth = new Map<number, number>()
  presentLevels.forEach((lvl, idx) => levelToDepth.set(lvl, idx))

  return entries.map((e) => levelToDepth.get(e.level) ?? 0)
}

/** Icon for each section level */
function sectionIcon(level: number): string {
  switch (level) {
    case 0: return 'P'   // \part
    case 1: return 'C'   // \chapter
    case 2: return 'S'   // \section
    case 3: return 'Ss'  // \subsection
    case 4: return 'Sss' // \subsubsection
    case 5: return 'p'   // \paragraph
    default: return '#'
  }
}

interface OutlineItemProps {
  entry: OutlineEntry
  depth: number
  isActive: boolean
  onClick: () => void
}

function OutlineItem({ entry, depth, isActive, onClick }: OutlineItemProps) {
  return (
    <div
      className={`outline-item ${isActive ? 'active' : ''}`}
      style={{ paddingLeft: depth * 16 + 12 }}
      onClick={onClick}
      title={`\\${entry.command}{${entry.title}} — line ${entry.line}`}
    >
      <span className="outline-item-icon">{sectionIcon(entry.level)}</span>
      <span className="outline-item-title">{entry.title || '(untitled)'}</span>
      <span className="outline-item-line">{entry.line}</span>
    </div>
  )
}

export default function OutlineView() {
  const activeTab = useAppStore((s) => s.activeTab)
  const fileContents = useAppStore((s) => s.fileContents)
  const setPendingGoTo = useAppStore((s) => s.setPendingGoTo)

  const [collapsed, setCollapsed] = useState(false)
  const [activeLineIndex, setActiveLineIndex] = useState<number | null>(null)

  const content = activeTab ? fileContents[activeTab] ?? '' : ''

  // Parse outline entries from current document content.
  // This recomputes whenever content changes, providing real-time updates.
  const entries = useMemo(() => parseOutline(content), [content])
  const depths = useMemo(() => computeDepths(entries), [entries])

  const handleItemClick = useCallback(
    (entry: OutlineEntry, index: number) => {
      if (!activeTab) return
      setActiveLineIndex(index)
      // Use the pendingGoTo mechanism to scroll the editor to the line
      setPendingGoTo({ file: activeTab, line: entry.line })
    },
    [activeTab, setPendingGoTo]
  )

  const isTexFile = activeTab?.endsWith('.tex') || activeTab?.endsWith('.ltx') || activeTab?.endsWith('.sty') || activeTab?.endsWith('.cls')

  return (
    <div className="outline-view">
      <div className="outline-header" onClick={() => setCollapsed(!collapsed)}>
        <span className="outline-toggle">{collapsed ? '>' : 'v'}</span>
        <span>OUTLINE</span>
        {entries.length > 0 && (
          <span className="outline-count">{entries.length}</span>
        )}
      </div>
      {!collapsed && (
        <div className="outline-content">
          {!activeTab && (
            <div className="outline-empty">No file open</div>
          )}
          {activeTab && !isTexFile && (
            <div className="outline-empty">Not a LaTeX file</div>
          )}
          {activeTab && isTexFile && entries.length === 0 && (
            <div className="outline-empty">No sections found</div>
          )}
          {entries.map((entry, i) => (
            <OutlineItem
              key={`${entry.line}-${entry.command}`}
              entry={entry}
              depth={depths[i]}
              isActive={activeLineIndex === i}
              onClick={() => handleItemClick(entry, i)}
            />
          ))}
        </div>
      )}
    </div>
  )
}