🎉 initial commit
This commit is contained in:
196
src/renderer/components/AIChat/Chat.css
Normal file
196
src/renderer/components/AIChat/Chat.css
Normal file
@@ -0,0 +1,196 @@
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--sidebar-bg);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 14px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-clear-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
padding: 2px 7px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-clear-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-serif);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
margin: 24px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-message-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chat-message-user .chat-message-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-message-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--message-bg);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.chat-message-user .chat-message-content {
|
||||
background: var(--user-message-bg);
|
||||
}
|
||||
|
||||
.chat-streaming-cursor {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.chat-typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
background: var(--message-bg);
|
||||
border-radius: 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.chat-typing span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
animation: bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.chat-typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); }
|
||||
40% { transform: translateY(-5px); }
|
||||
}
|
||||
|
||||
.chat-error {
|
||||
background: rgba(200, 60, 60, 0.15);
|
||||
border: 1px solid rgba(200, 60, 60, 0.4);
|
||||
border-radius: 6px;
|
||||
color: #e07070;
|
||||
font-size: 12px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-textarea {
|
||||
flex: 1;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
padding: 8px 10px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.chat-textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chat-textarea:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #1a1208;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.chat-send-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-send-btn:not(:disabled):hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
60
src/renderer/components/AIChat/ChatInput.tsx
Normal file
60
src/renderer/components/AIChat/ChatInput.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState, useRef, type KeyboardEvent } from 'react'
|
||||
|
||||
interface Props {
|
||||
onSend: (text: string) => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
||||
const [value, setValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const submit = (): void => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || disabled) return
|
||||
onSend(trimmed)
|
||||
setValue('')
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = (): void => {
|
||||
const el = textareaRef.current
|
||||
if (el) {
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 160)}px`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-input-area">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="chat-textarea"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={disabled ? 'Open a chapter first…' : 'Ask about this chapter… (Enter to send)'}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="chat-send-btn"
|
||||
onClick={submit}
|
||||
disabled={disabled || !value.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/renderer/components/AIChat/ChatMessageItem.tsx
Normal file
18
src/renderer/components/AIChat/ChatMessageItem.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ChatMessage } from '../../types/editor'
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage
|
||||
}
|
||||
|
||||
export function ChatMessageItem({ message }: Props): JSX.Element {
|
||||
return (
|
||||
<div className={`chat-message chat-message-${message.role}`}>
|
||||
<div className="chat-message-label">
|
||||
{message.role === 'user' ? 'You' : 'Editor AI'}
|
||||
</div>
|
||||
<div className="chat-message-content">
|
||||
{message.content || <span className="chat-streaming-cursor">▍</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
src/renderer/components/AIChat/ChatPanel.tsx
Normal file
117
src/renderer/components/AIChat/ChatPanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import { ChatMessageItem } from './ChatMessageItem'
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
||||
import './Chat.css'
|
||||
|
||||
export function ChatPanel(): JSX.Element {
|
||||
const {
|
||||
chatHistory,
|
||||
isAILoading,
|
||||
aiError,
|
||||
activeFileContent,
|
||||
activeFilePath,
|
||||
analysisMode,
|
||||
addUserMessage,
|
||||
startAssistantMessage,
|
||||
appendToLastAssistantMessage,
|
||||
setAILoading,
|
||||
setAIError,
|
||||
setAnnotations,
|
||||
clearChat
|
||||
} = useEditorStore()
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const sendMessage = async (text: string): Promise<void> => {
|
||||
if (!activeFilePath || isAILoading) return
|
||||
|
||||
setAIError(null)
|
||||
addUserMessage(text)
|
||||
startAssistantMessage()
|
||||
setAILoading(true)
|
||||
|
||||
try {
|
||||
const mode = analysisMode === 'none' ? 'chat' : analysisMode
|
||||
await window.api.streamAIMessage(
|
||||
{
|
||||
mode,
|
||||
documentContent: activeFileContent,
|
||||
documentPath: activeFilePath,
|
||||
conversationHistory: chatHistory
|
||||
.slice(-10)
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
userMessage: text
|
||||
},
|
||||
(chunk: string) => {
|
||||
appendToLastAssistantMessage(chunk)
|
||||
}
|
||||
)
|
||||
|
||||
// After streaming, parse AI response for annotations
|
||||
const currentHistory = useEditorStore.getState().chatHistory
|
||||
const lastMsg = currentHistory[currentHistory.length - 1]
|
||||
if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) {
|
||||
const annotations = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent)
|
||||
if (annotations.length > 0) {
|
||||
setAnnotations(annotations)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'An error occurred'
|
||||
setAIError(message)
|
||||
} finally {
|
||||
setAILoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom when new content arrives
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [chatHistory])
|
||||
|
||||
const hasFile = Boolean(activeFilePath)
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-header">
|
||||
<span>AI Editor</span>
|
||||
{chatHistory.length > 0 && (
|
||||
<button className="chat-clear-btn" onClick={clearChat} title="Clear conversation">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="chat-messages" ref={scrollRef}>
|
||||
{!hasFile && (
|
||||
<p className="chat-placeholder">Open a chapter to start a conversation about it.</p>
|
||||
)}
|
||||
{hasFile && chatHistory.length === 0 && (
|
||||
<p className="chat-placeholder">
|
||||
Ask anything about the current chapter — passive voice, plot, character, style...
|
||||
</p>
|
||||
)}
|
||||
{chatHistory.map((msg) => (
|
||||
<ChatMessageItem key={msg.id} message={msg} />
|
||||
))}
|
||||
{isAILoading && chatHistory[chatHistory.length - 1]?.content === '' && (
|
||||
<div className="chat-typing">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
{aiError && (
|
||||
<div className="chat-error">{aiError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatInput onSend={sendMessage} disabled={!hasFile || isAILoading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/renderer/components/Editor/Editor.css
Normal file
38
src/renderer/components/Editor/Editor.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.editor-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codemirror-host {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.codemirror-host .cm-editor {
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 15px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.editor-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-empty-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
172
src/renderer/components/Editor/MarkdownEditor.tsx
Normal file
172
src/renderer/components/Editor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { EditorView, Decoration, type DecorationSet } from '@codemirror/view'
|
||||
import { EditorState, StateField, StateEffect, RangeSetBuilder } from '@codemirror/state'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
|
||||
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
|
||||
import { keymap } from '@codemirror/view'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import type { TextAnnotation } from '../../types/editor'
|
||||
import './Editor.css'
|
||||
|
||||
// StateEffect to push new annotations into the editor
|
||||
export const setAnnotationsEffect = StateEffect.define<TextAnnotation[]>()
|
||||
|
||||
// StateField tracks the decoration set derived from annotations
|
||||
const annotationField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(deco, tr) {
|
||||
deco = deco.map(tr.changes)
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setAnnotationsEffect)) {
|
||||
const builder = new RangeSetBuilder<Decoration>()
|
||||
const sorted = [...effect.value].sort((a, b) => a.from - b.from)
|
||||
for (const ann of sorted) {
|
||||
const docLen = tr.newDoc.length
|
||||
const from = Math.max(0, Math.min(ann.from, docLen))
|
||||
const to = Math.max(from, Math.min(ann.to, docLen))
|
||||
if (from < to) {
|
||||
builder.add(
|
||||
from,
|
||||
to,
|
||||
Decoration.mark({
|
||||
class: `annotation annotation-${ann.type}`,
|
||||
attributes: { 'data-id': ann.id, title: ann.message }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
return builder.finish()
|
||||
}
|
||||
}
|
||||
return deco
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f)
|
||||
})
|
||||
|
||||
// Dark gothic theme for CodeMirror
|
||||
const hohoffTheme = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '15px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--text-primary)'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'var(--font-serif)',
|
||||
lineHeight: '1.8',
|
||||
overflow: 'auto'
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '24px 32px',
|
||||
maxWidth: '740px',
|
||||
margin: '0 auto',
|
||||
caretColor: 'var(--accent)'
|
||||
},
|
||||
'.cm-cursor': { borderLeftColor: 'var(--accent)' },
|
||||
'.cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.2)' },
|
||||
'&.cm-focused .cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.3)' },
|
||||
'.cm-line': { padding: '0' },
|
||||
'.cm-gutters': { display: 'none' },
|
||||
'.cm-activeLine': { backgroundColor: 'transparent' },
|
||||
'.cm-activeLineGutter': { backgroundColor: 'transparent' },
|
||||
|
||||
// Markdown heading styles
|
||||
'.tok-heading': { fontWeight: '700', color: 'var(--heading-color)' },
|
||||
'.tok-heading1': { fontSize: '1.4em' },
|
||||
'.tok-heading2': { fontSize: '1.2em' },
|
||||
'.tok-emphasis': { fontStyle: 'italic' },
|
||||
'.tok-strong': { fontWeight: '700' },
|
||||
|
||||
// Annotation highlight styles
|
||||
'.annotation-passive_voice': {
|
||||
backgroundColor: 'rgba(255, 200, 0, 0.18)',
|
||||
borderBottom: '2px solid rgba(255, 200, 0, 0.7)',
|
||||
borderRadius: '2px'
|
||||
},
|
||||
'.annotation-consistency': {
|
||||
backgroundColor: 'rgba(220, 80, 80, 0.18)',
|
||||
borderBottom: '2px solid rgba(220, 80, 80, 0.7)',
|
||||
borderRadius: '2px'
|
||||
},
|
||||
'.annotation-style': {
|
||||
backgroundColor: 'rgba(80, 160, 255, 0.18)',
|
||||
borderBottom: '2px solid rgba(80, 160, 255, 0.7)',
|
||||
borderRadius: '2px'
|
||||
}
|
||||
},
|
||||
{ dark: true }
|
||||
)
|
||||
|
||||
export function MarkdownEditor(): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const { activeFilePath, activeFileContent, setContent, annotations } = useEditorStore()
|
||||
|
||||
// Initialize CodeMirror once
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: '',
|
||||
extensions: [
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
markdown(),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
annotationField,
|
||||
hohoffTheme,
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
setContent(update.state.doc.toString())
|
||||
}
|
||||
})
|
||||
]
|
||||
}),
|
||||
parent: containerRef.current
|
||||
})
|
||||
|
||||
viewRef.current = view
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// When the active file changes, replace editor content
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
const current = view.state.doc.toString()
|
||||
if (current !== activeFileContent) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: activeFileContent }
|
||||
})
|
||||
// Scroll to top on file switch
|
||||
view.dispatch({ selection: { anchor: 0 } })
|
||||
view.scrollDOM.scrollTop = 0
|
||||
}
|
||||
}, [activeFilePath]) // Only sync on file switch
|
||||
|
||||
// Push annotation decorations into CodeMirror
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (!view) return
|
||||
view.dispatch({ effects: setAnnotationsEffect.of(annotations) })
|
||||
}, [annotations])
|
||||
|
||||
return (
|
||||
<div className="editor-container">
|
||||
{!activeFilePath && (
|
||||
<div className="editor-empty">
|
||||
<p>Select a chapter from the sidebar to begin editing.</p>
|
||||
<p className="editor-empty-hint">Your draft files will never be modified without saving (Cmd+S).</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="codemirror-host" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/renderer/components/FileTree/FileTree.css
Normal file
80
src/renderer/components/FileTree/FileTree.css
Normal file
@@ -0,0 +1,80 @@
|
||||
.file-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-tree-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;
|
||||
}
|
||||
|
||||
.file-tree-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.tree-dir-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tree-dir-header:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tree-arrow {
|
||||
font-size: 10px;
|
||||
width: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-file {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
border-radius: 3px;
|
||||
margin: 0 4px;
|
||||
width: calc(100% - 8px);
|
||||
}
|
||||
|
||||
.tree-file:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tree-file.active {
|
||||
color: var(--accent);
|
||||
background: var(--active-bg);
|
||||
}
|
||||
18
src/renderer/components/FileTree/FileTree.tsx
Normal file
18
src/renderer/components/FileTree/FileTree.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import { FileTreeNode } from './FileTreeNode'
|
||||
import './FileTree.css'
|
||||
|
||||
export function FileTree(): JSX.Element {
|
||||
const { fileTree } = useEditorStore()
|
||||
|
||||
return (
|
||||
<nav className="file-tree">
|
||||
<div className="file-tree-header">HOHOFF</div>
|
||||
<div className="file-tree-list">
|
||||
{fileTree.map((node) => (
|
||||
<FileTreeNode key={node.path} node={node} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
51
src/renderer/components/FileTree/FileTreeNode.tsx
Normal file
51
src/renderer/components/FileTree/FileTreeNode.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import type { FileNode } from '../../types/editor'
|
||||
|
||||
interface Props {
|
||||
node: FileNode
|
||||
depth: number
|
||||
}
|
||||
|
||||
export function FileTreeNode({ node, depth }: Props): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const { activeFilePath, setActiveFile } = useEditorStore()
|
||||
|
||||
const openFile = async (): Promise<void> => {
|
||||
if (node.type === 'file') {
|
||||
const content = await window.api.readFile(node.path)
|
||||
setActiveFile(node.path, content)
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'directory') {
|
||||
return (
|
||||
<div className="tree-dir">
|
||||
<button
|
||||
className="tree-dir-header"
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="tree-arrow">{expanded ? '▾' : '▸'}</span>
|
||||
{node.name}
|
||||
</button>
|
||||
{expanded && node.children?.map((child) => (
|
||||
<FileTreeNode key={child.path} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = activeFilePath === node.path
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`tree-file${isActive ? ' active' : ''}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 20}px` }}
|
||||
onClick={openFile}
|
||||
title={node.name}
|
||||
>
|
||||
{node.name}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
146
src/renderer/components/Toolbar/AnalysisToolbar.tsx
Normal file
146
src/renderer/components/Toolbar/AnalysisToolbar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEditorStore } from '../../store/editorStore'
|
||||
import { detectPassiveVoice } from '../../utils/passiveVoice'
|
||||
import { parseAnnotationsFromAIResponse } from '../../utils/annotationParser'
|
||||
import './Toolbar.css'
|
||||
|
||||
export function AnalysisToolbar(): JSX.Element {
|
||||
const {
|
||||
activeFilePath,
|
||||
activeFileContent,
|
||||
isDirty,
|
||||
analysisMode,
|
||||
annotations,
|
||||
isAILoading,
|
||||
setAnnotations,
|
||||
clearAnnotations,
|
||||
setAnalysisMode,
|
||||
addUserMessage,
|
||||
startAssistantMessage,
|
||||
appendToLastAssistantMessage,
|
||||
setAILoading,
|
||||
setAIError,
|
||||
chatHistory
|
||||
} = useEditorStore()
|
||||
|
||||
const hasFile = Boolean(activeFilePath)
|
||||
|
||||
const runPassiveVoice = (): void => {
|
||||
if (!hasFile) return
|
||||
const found = detectPassiveVoice(activeFileContent)
|
||||
setAnnotations(found)
|
||||
setAnalysisMode('passive_voice')
|
||||
}
|
||||
|
||||
const runAIAnalysis = async (mode: 'consistency' | 'style'): Promise<void> => {
|
||||
if (!activeFilePath || isAILoading) return
|
||||
|
||||
setAnalysisMode(mode)
|
||||
setAIError(null)
|
||||
|
||||
const prompt =
|
||||
mode === 'consistency'
|
||||
? 'Please check this chapter for consistency issues (character names, timeline, repeated phrases).'
|
||||
: 'Please analyze the style and pacing of this chapter and suggest improvements.'
|
||||
|
||||
addUserMessage(prompt)
|
||||
startAssistantMessage()
|
||||
setAILoading(true)
|
||||
|
||||
try {
|
||||
await window.api.streamAIMessage(
|
||||
{
|
||||
mode,
|
||||
documentContent: activeFileContent,
|
||||
documentPath: activeFilePath,
|
||||
conversationHistory: chatHistory
|
||||
.slice(-10)
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
userMessage: prompt
|
||||
},
|
||||
(chunk: string) => {
|
||||
appendToLastAssistantMessage(chunk)
|
||||
}
|
||||
)
|
||||
|
||||
// Parse annotations from response
|
||||
const currentHistory = useEditorStore.getState().chatHistory
|
||||
const lastMsg = currentHistory[currentHistory.length - 1]
|
||||
if (lastMsg?.role === 'assistant' && lastMsg.content.length > 0) {
|
||||
const newAnnotations = parseAnnotationsFromAIResponse(lastMsg.content, activeFileContent)
|
||||
if (newAnnotations.length > 0) setAnnotations(newAnnotations)
|
||||
}
|
||||
} catch (err) {
|
||||
setAIError(err instanceof Error ? err.message : 'Analysis failed')
|
||||
} finally {
|
||||
setAILoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passiveCount = annotations.filter((a) => a.type === 'passive_voice').length
|
||||
const otherCount = annotations.filter((a) => a.type !== 'passive_voice').length
|
||||
|
||||
return (
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-left">
|
||||
<button
|
||||
className={`toolbar-btn${analysisMode === 'passive_voice' ? ' active' : ''}`}
|
||||
onClick={runPassiveVoice}
|
||||
disabled={!hasFile}
|
||||
title="Highlight passive voice sentences instantly (no AI required)"
|
||||
>
|
||||
Passive Voice
|
||||
{analysisMode === 'passive_voice' && passiveCount > 0 && (
|
||||
<span className="toolbar-badge">{passiveCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`toolbar-btn${analysisMode === 'consistency' ? ' active' : ''}`}
|
||||
onClick={() => runAIAnalysis('consistency')}
|
||||
disabled={!hasFile || isAILoading}
|
||||
title="Check character names, timeline, and repeated phrases via AI"
|
||||
>
|
||||
{isAILoading && analysisMode === 'consistency' ? 'Checking…' : 'Consistency'}
|
||||
{analysisMode === 'consistency' && otherCount > 0 && (
|
||||
<span className="toolbar-badge">{otherCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`toolbar-btn${analysisMode === 'style' ? ' active' : ''}`}
|
||||
onClick={() => runAIAnalysis('style')}
|
||||
disabled={!hasFile || isAILoading}
|
||||
title="Pacing, sentence variety, show-don't-tell feedback via AI"
|
||||
>
|
||||
{isAILoading && analysisMode === 'style' ? 'Analyzing…' : 'Style'}
|
||||
{analysisMode === 'style' && otherCount > 0 && (
|
||||
<span className="toolbar-badge">{otherCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{annotations.length > 0 && (
|
||||
<button
|
||||
className="toolbar-btn toolbar-btn-clear"
|
||||
onClick={clearAnnotations}
|
||||
title="Remove all highlights"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="toolbar-right">
|
||||
{isDirty && (
|
||||
<span className="toolbar-dirty" title="Unsaved changes — press Cmd+S to save">
|
||||
●
|
||||
</span>
|
||||
)}
|
||||
{activeFilePath && (
|
||||
<span className="toolbar-filename">
|
||||
{activeFilePath.split('/').pop()?.replace(/\.md$/, '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
src/renderer/components/Toolbar/Toolbar.css
Normal file
97
src/renderer/components/Toolbar/Toolbar.css
Normal file
@@ -0,0 +1,97 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
background: var(--toolbar-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
padding: 4px 11px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(167, 139, 95, 0.1);
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.toolbar-btn-clear {
|
||||
border-color: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.toolbar-btn-clear:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.toolbar-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent);
|
||||
color: #1a1208;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
padding: 0 5px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.toolbar-dirty {
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-filename {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-serif);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 260px;
|
||||
}
|
||||
Reference in New Issue
Block a user