💄 header nav
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { FileTree } from './components/FileTree/FileTree'
|
import { FileTree } from './components/FileTree/FileTree'
|
||||||
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
import { MarkdownEditor } from './components/Editor/MarkdownEditor'
|
||||||
|
import { DocumentOutline } from './components/Editor/DocumentOutline'
|
||||||
import { ChatPanel } from './components/AIChat/ChatPanel'
|
import { ChatPanel } from './components/AIChat/ChatPanel'
|
||||||
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
||||||
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
||||||
@@ -10,7 +11,7 @@ import './styles/app.css'
|
|||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
||||||
loadSession, revisionPanelOpen, toggleRevisionPanel
|
loadSession, revisionPanelOpen, toggleRevisionPanel, outlineOpen
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(
|
const [sidebarOpen, setSidebarOpen] = useState(
|
||||||
() => localStorage.getItem('sidebarOpen') !== 'false'
|
() => localStorage.getItem('sidebarOpen') !== 'false'
|
||||||
@@ -87,7 +88,10 @@ export default function App(): JSX.Element {
|
|||||||
</aside>
|
</aside>
|
||||||
<main className="editor-area" style={{ position: 'relative' }}>
|
<main className="editor-area" style={{ position: 'relative' }}>
|
||||||
<AnalysisToolbar />
|
<AnalysisToolbar />
|
||||||
<MarkdownEditor />
|
<div className="editor-body">
|
||||||
|
{outlineOpen && <DocumentOutline />}
|
||||||
|
<MarkdownEditor />
|
||||||
|
</div>
|
||||||
{revisionPanelOpen && <RevisionPanel />}
|
{revisionPanelOpen && <RevisionPanel />}
|
||||||
</main>
|
</main>
|
||||||
<aside className="chat-area">
|
<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,
|
setProjectWordCount,
|
||||||
fontSize,
|
fontSize,
|
||||||
setFontSize,
|
setFontSize,
|
||||||
setRightPanelTab
|
setRightPanelTab,
|
||||||
|
outlineOpen,
|
||||||
|
toggleOutline
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -297,6 +299,13 @@ export function AnalysisToolbar(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-right">
|
<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">
|
<div className="toolbar-fontsize">
|
||||||
<button
|
<button
|
||||||
className="toolbar-fontsize-btn"
|
className="toolbar-fontsize-btn"
|
||||||
|
|||||||
@@ -141,6 +141,14 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Outline toggle button ───────────────────────────────────────── */
|
||||||
|
.toolbar-btn-outline {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Story Bible toolbar ─────────────────────────────────────────── */
|
/* ── Story Bible toolbar ─────────────────────────────────────────── */
|
||||||
.toolbar-bible-label {
|
.toolbar-bible-label {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ interface EditorState {
|
|||||||
rightPanelTab: 'chat' | 'feedback'
|
rightPanelTab: 'chat' | 'feedback'
|
||||||
setRightPanelTab: (tab: 'chat' | 'feedback') => void
|
setRightPanelTab: (tab: 'chat' | 'feedback') => void
|
||||||
|
|
||||||
|
// Document outline panel
|
||||||
|
outlineOpen: boolean
|
||||||
|
toggleOutline: () => void
|
||||||
|
|
||||||
// Session persistence
|
// Session persistence
|
||||||
loadSession: () => Promise<void>
|
loadSession: () => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -522,6 +526,14 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
|
|
||||||
rightPanelTab: 'chat',
|
rightPanelTab: 'chat',
|
||||||
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
|
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: [],
|
revisions: [],
|
||||||
setRevisions: (revisions) => set({ revisions }),
|
setRevisions: (revisions) => set({ revisions }),
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,15 @@
|
|||||||
background: var(--editor-bg);
|
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 ────────────────────────────────────────────────── */
|
||||||
.chat-area {
|
.chat-area {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user