💄 header nav
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FileTree } from './components/FileTree/FileTree'
|
||||
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
||||
import { DocumentOutline } from './components/Editor/DocumentOutline'
|
||||
import { ChatPanel } from './components/AIChat/ChatPanel'
|
||||
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
||||
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
||||
@@ -10,7 +11,7 @@ import './styles/app.css'
|
||||
export default function App(): JSX.Element {
|
||||
const {
|
||||
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
||||
loadSession, revisionPanelOpen, toggleRevisionPanel
|
||||
loadSession, revisionPanelOpen, toggleRevisionPanel, outlineOpen
|
||||
} = useEditorStore()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(
|
||||
() => localStorage.getItem('sidebarOpen') !== 'false'
|
||||
@@ -87,7 +88,10 @@ export default function App(): JSX.Element {
|
||||
</aside>
|
||||
<main className="editor-area" style={{ position: 'relative' }}>
|
||||
<AnalysisToolbar />
|
||||
<MarkdownEditor />
|
||||
<div className="editor-body">
|
||||
{outlineOpen && <DocumentOutline />}
|
||||
<MarkdownEditor />
|
||||
</div>
|
||||
{revisionPanelOpen && <RevisionPanel />}
|
||||
</main>
|
||||
<aside className="chat-area">
|
||||
|
||||
89
src/renderer/components/Editor/DocumentOutline.css
Normal file
89
src/renderer/components/Editor/DocumentOutline.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.outline-panel {
|
||||
width: 160px;
|
||||
min-width: 160px;
|
||||
flex-shrink: 0;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.outline-header {
|
||||
padding: 14px 12px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outline-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.outline-empty {
|
||||
padding: 12px 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Base item style — reset button defaults */
|
||||
.outline-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 8px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.outline-item:hover {
|
||||
background: rgba(167, 139, 95, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Heading indent levels */
|
||||
.outline-item--h1 {
|
||||
padding-left: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.outline-item--h2 {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.outline-item--h3 {
|
||||
padding-left: 28px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
/* Divider row */
|
||||
.outline-divider {
|
||||
padding-left: 8px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.outline-divider:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
82
src/renderer/components/Editor/DocumentOutline.tsx
Normal file
82
src/renderer/components/Editor/DocumentOutline.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useMemo } from 'react'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import { currentEditorView } from './MarkdownEditor'
|
||||
import './DocumentOutline.css'
|
||||
|
||||
type OutlineItem =
|
||||
| { type: 'heading'; level: 1 | 2 | 3; text: string; offset: number; key: string }
|
||||
| { type: 'divider'; offset: number; nth: number; key: string }
|
||||
|
||||
function parseOutline(content: string): OutlineItem[] {
|
||||
const items: OutlineItem[] = []
|
||||
const lines = content.split('\n')
|
||||
let offset = 0
|
||||
let dividerCount = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(/^(#{1,3})\s+(.+)/)
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length as 1 | 2 | 3
|
||||
const text = headingMatch[2].trim()
|
||||
items.push({ type: 'heading', level, text, offset, key: `h-${offset}` })
|
||||
} else if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
||||
dividerCount++
|
||||
items.push({ type: 'divider', offset, nth: dividerCount, key: `d-${offset}` })
|
||||
}
|
||||
offset += line.length + 1
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function navigateTo(offset: number): void {
|
||||
const view = currentEditorView
|
||||
if (!view) return
|
||||
view.dispatch({
|
||||
selection: { anchor: offset },
|
||||
effects: EditorView.scrollIntoView(offset, { y: 'start' })
|
||||
})
|
||||
view.focus()
|
||||
}
|
||||
|
||||
export function DocumentOutline(): JSX.Element {
|
||||
const activeFileContent = useEditorStore((s) => s.activeFileContent)
|
||||
const items = useMemo(() => parseOutline(activeFileContent), [activeFileContent])
|
||||
|
||||
return (
|
||||
<div className="outline-panel">
|
||||
<div className="outline-header">OUTLINE</div>
|
||||
<div className="outline-list">
|
||||
{items.length === 0 ? (
|
||||
<div className="outline-empty">No headings</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
if (item.type === 'heading') {
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`outline-item outline-item--h${item.level}`}
|
||||
onClick={() => navigateTo(item.offset)}
|
||||
title={item.text}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
className="outline-item outline-divider"
|
||||
onClick={() => navigateTo(item.offset)}
|
||||
title={`Divider ${item.nth}`}
|
||||
>
|
||||
———
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -63,7 +63,9 @@ export function AnalysisToolbar(): JSX.Element {
|
||||
setProjectWordCount,
|
||||
fontSize,
|
||||
setFontSize,
|
||||
setRightPanelTab
|
||||
setRightPanelTab,
|
||||
outlineOpen,
|
||||
toggleOutline
|
||||
} = useEditorStore()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -297,6 +299,13 @@ export function AnalysisToolbar(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="toolbar-right">
|
||||
<button
|
||||
className={`toolbar-btn toolbar-btn-outline${outlineOpen ? ' active' : ''}`}
|
||||
onClick={toggleOutline}
|
||||
title={outlineOpen ? 'Hide document outline' : 'Show document outline'}
|
||||
>
|
||||
≡
|
||||
</button>
|
||||
<div className="toolbar-fontsize">
|
||||
<button
|
||||
className="toolbar-fontsize-btn"
|
||||
|
||||
@@ -141,6 +141,14 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Outline toggle button ───────────────────────────────────────── */
|
||||
.toolbar-btn-outline {
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Story Bible toolbar ─────────────────────────────────────────── */
|
||||
.toolbar-bible-label {
|
||||
font-size: 9px;
|
||||
|
||||
@@ -81,6 +81,10 @@ interface EditorState {
|
||||
rightPanelTab: 'chat' | 'feedback'
|
||||
setRightPanelTab: (tab: 'chat' | 'feedback') => void
|
||||
|
||||
// Document outline panel
|
||||
outlineOpen: boolean
|
||||
toggleOutline: () => void
|
||||
|
||||
// Session persistence
|
||||
loadSession: () => Promise<void>
|
||||
}
|
||||
@@ -522,6 +526,14 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
|
||||
rightPanelTab: 'chat',
|
||||
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
|
||||
|
||||
outlineOpen: localStorage.getItem('outlineOpen') === 'true',
|
||||
toggleOutline: () => set((s) => {
|
||||
const next = !s.outlineOpen
|
||||
localStorage.setItem('outlineOpen', String(next))
|
||||
return { outlineOpen: next }
|
||||
}),
|
||||
|
||||
revisions: [],
|
||||
setRevisions: (revisions) => set({ revisions }),
|
||||
|
||||
|
||||
@@ -140,6 +140,15 @@
|
||||
background: var(--editor-bg);
|
||||
}
|
||||
|
||||
/* Flex row containing the optional outline panel + the CodeMirror editor */
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ─── Chat area ────────────────────────────────────────────────── */
|
||||
.chat-area {
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user