feat: deliver P2 v0.2.0 with unified editor, search, and snapshots
Implement schema v2 migration, outline/setting/inspiration CRUD, unified TipTap editing, reference panel with mentions, FTS global search, chapter version history, writing landmarks, word frequency analysis, light theme contrast fixes, and full unit/E2E test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+62
-1
@@ -14,7 +14,12 @@ import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { SearchModal } from '@renderer/components/search/SearchModal'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
function AppInner(): React.JSX.Element {
|
||||
@@ -40,11 +45,35 @@ function AppInner(): React.JSX.Element {
|
||||
if (loaded && !onboardingCompleted) setShowOnboarding(true)
|
||||
}, [loaded, onboardingCompleted])
|
||||
|
||||
useEffect(() => {
|
||||
const openSearch = (): void => useSearchStore.getState().setOpen(true)
|
||||
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
|
||||
const onInsertLandmark = (e: Event): void => {
|
||||
const label = (e as CustomEvent<{ label: string }>).detail?.label
|
||||
if (!label) return
|
||||
const { currentBookId } = useBookStore.getState()
|
||||
const target = useEditStore.getState().target
|
||||
if (!currentBookId || target?.kind !== 'chapter') return
|
||||
insertLandmarkInEditor({ label, landmarkType: 'todo' })
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label, 'todo')
|
||||
)
|
||||
}
|
||||
window.addEventListener('bilin:e2e-open-search', openSearch)
|
||||
window.addEventListener('bilin:e2e-open-landmarks', openLandmarks)
|
||||
window.addEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
|
||||
return () => {
|
||||
window.removeEventListener('bilin:e2e-open-search', openSearch)
|
||||
window.removeEventListener('bilin:e2e-open-landmarks', openLandmarks)
|
||||
window.removeEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI.onShortcutTriggered(async (action) => {
|
||||
if (action === 'saveChapter') {
|
||||
try {
|
||||
await flushEditorSave()
|
||||
await flushEditorSave(true)
|
||||
} catch {
|
||||
showToast(t('toast.saveFailed'))
|
||||
}
|
||||
@@ -75,6 +104,37 @@ function AppInner(): React.JSX.Element {
|
||||
} else setView('home')
|
||||
return
|
||||
}
|
||||
if (action === 'globalSearch') {
|
||||
useSearchStore.getState().setOpen(true)
|
||||
return
|
||||
}
|
||||
if (action === 'openReference') {
|
||||
useReferenceStore.getState().setOpen(true)
|
||||
return
|
||||
}
|
||||
if (action === 'openLandmarks') {
|
||||
useAppStore.getState().setLandmarksModalOpen(true)
|
||||
return
|
||||
}
|
||||
if (action === 'insertLandmark') {
|
||||
if (view !== 'editor') return
|
||||
document.querySelector<HTMLButtonElement>('[data-testid="insert-landmark"]')?.click()
|
||||
return
|
||||
}
|
||||
if (action === 'captureInspiration') {
|
||||
if (view !== 'editor') return
|
||||
const { currentBookId, addInspirationLocal } = useBookStore.getState()
|
||||
if (!currentBookId) return
|
||||
void (async () => {
|
||||
const item = await ipcCall(() =>
|
||||
window.electronAPI.inspiration.create(currentBookId, t('inspiration.newItem'))
|
||||
)
|
||||
addInspirationLocal(item)
|
||||
useAppStore.getState().setSidebarPanel('inspiration')
|
||||
await useEditStore.getState().switchTarget({ kind: 'inspiration', id: item.id })
|
||||
})()
|
||||
return
|
||||
}
|
||||
showToast(t('feature.comingSoon'))
|
||||
})
|
||||
return unsub
|
||||
@@ -93,6 +153,7 @@ function AppInner(): React.JSX.Element {
|
||||
{view === 'settings' && <SettingsPage />}
|
||||
</div>
|
||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||
<SearchModal />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,145 +2,353 @@ import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useEffect, useRef, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import type { Chapter } from '@shared/types'
|
||||
import type { EditTarget } from '@shared/types'
|
||||
import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } from '@renderer/atoms/editorAtoms'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { flushEditSession, registerEditorFlush } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
|
||||
import { SettingMention } from '@renderer/extensions/mention'
|
||||
import { registerHighlightToken, registerInsertLandmark } from '@renderer/lib/editor-commands'
|
||||
function countPlain(text: string): number {
|
||||
const stripped = text.replace(/\s+/g, '')
|
||||
const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '')
|
||||
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
||||
const latin = stripped.match(/[a-zA-Z0-9]+/g)
|
||||
return (cjk?.length ?? 0) + (latin?.length ?? 0)
|
||||
}
|
||||
|
||||
let persistOnNextSave = false
|
||||
|
||||
function findTokenRange(doc: { descendants: (f: (node: { isText?: boolean; text?: string | null }, pos: number) => boolean | void) => void }, token: string): { from: number; to: number } | null {
|
||||
let found: { from: number; to: number } | null = null
|
||||
doc.descendants((node, pos) => {
|
||||
if (found || !node.isText || !node.text) return
|
||||
const idx = node.text.indexOf(token)
|
||||
if (idx >= 0) {
|
||||
found = { from: pos + idx, to: pos + idx + token.length }
|
||||
return false
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
const simplifiedKit = StarterKit.configure({
|
||||
heading: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
blockquote: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
strike: false,
|
||||
code: false
|
||||
})
|
||||
|
||||
interface TipTapEditorProps {
|
||||
chapter: Chapter | null
|
||||
target: EditTarget | null
|
||||
bookId: string | null
|
||||
}
|
||||
|
||||
export function TipTapEditor({ chapter, bookId }: TipTapEditorProps): React.JSX.Element {
|
||||
function useTargetContent(target: EditTarget | null): string {
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const outlines = useBookStore((s) => s.outlines)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const inspirations = useBookStore((s) => s.inspirations)
|
||||
|
||||
if (!target) return ''
|
||||
if (target.kind === 'chapter') {
|
||||
return chapters.find((c) => c.id === target.id)?.content ?? ''
|
||||
}
|
||||
if (target.kind === 'outline') {
|
||||
return outlines.find((o) => o.id === target.id)?.description ?? ''
|
||||
}
|
||||
if (target.kind === 'setting') {
|
||||
return settings.find((s) => s.id === target.id)?.description ?? ''
|
||||
}
|
||||
return inspirations.find((i) => i.id === target.id)?.content ?? ''
|
||||
}
|
||||
|
||||
export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation()
|
||||
const setDirty = useSetAtom(editorDirtyAtom)
|
||||
const setSaving = useSetAtom(editorSavingAtom)
|
||||
const setLastSaved = useSetAtom(lastSavedAtAtom)
|
||||
const setWordCount = useSetAtom(wordCountAtom)
|
||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||
const updateOutlineLocal = useBookStore((s) => s.updateOutlineLocal)
|
||||
const updateSettingLocal = useBookStore((s) => s.updateSettingLocal)
|
||||
const updateInspirationLocal = useBookStore((s) => s.updateInspirationLocal)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const chapterIdRef = useRef<string | null>(null)
|
||||
const targetKeyRef = useRef<string | null>(null)
|
||||
const [mentionOpen, setMentionOpen] = useState(false)
|
||||
const [mentionQuery, setMentionQuery] = useState('')
|
||||
const [mentionFrom, setMentionFrom] = useState(0)
|
||||
const content = useTargetContent(target)
|
||||
const isChapter = target?.kind === 'chapter'
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Placeholder.configure({ placeholder: t('editor.placeholder') })
|
||||
],
|
||||
content: chapter?.content ?? '',
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
setDirty(true)
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
void (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave?.()
|
||||
}, 2000)
|
||||
const text = ed.getText()
|
||||
const volId = chapter?.volumeId
|
||||
const chapterCount = countPlain(text)
|
||||
const volumeTotal = chapters
|
||||
.filter((c) => c.volumeId === volId)
|
||||
.reduce((sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount), 0)
|
||||
const bookTotal = chapters.reduce(
|
||||
(sum, c) => sum + (c.id === chapter?.id ? chapterCount : c.wordCount),
|
||||
0
|
||||
)
|
||||
setWordCount({ chapter: chapterCount, volume: volumeTotal, book: bookTotal })
|
||||
},
|
||||
editorProps: {
|
||||
handlePaste: (view, event) => {
|
||||
if (event.shiftKey) return false
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
if (text) {
|
||||
view.dispatch(view.state.tr.insertText(text))
|
||||
const extensions = useMemo(
|
||||
() =>
|
||||
isChapter
|
||||
? [
|
||||
StarterKit,
|
||||
Underline,
|
||||
LandmarkMark,
|
||||
SettingMention,
|
||||
Placeholder.configure({ placeholder: t('editor.placeholder') })
|
||||
]
|
||||
: [simplifiedKit, Placeholder.configure({ placeholder: t('editor.placeholder') })],
|
||||
[isChapter, t]
|
||||
)
|
||||
|
||||
const mentionCandidates = useMemo(() => {
|
||||
const q = mentionQuery.toLowerCase()
|
||||
return settings
|
||||
.filter((s) => !q || s.name.toLowerCase().includes(q))
|
||||
.slice(0, 8)
|
||||
}, [settings, mentionQuery])
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
content: '',
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
setDirty(true)
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
void flushEditorSave()
|
||||
}, 2000)
|
||||
const { from } = ed.state.selection
|
||||
const textBefore = ed.state.doc.textBetween(Math.max(0, from - 40), from, '\n', '\n')
|
||||
const match = textBefore.match(/@([\u4e00-\u9fff\w]*)$/)
|
||||
if (match && isChapter) {
|
||||
setMentionQuery(match[1])
|
||||
setMentionFrom(from - match[0].length)
|
||||
setMentionOpen(true)
|
||||
} else {
|
||||
setMentionOpen(false)
|
||||
}
|
||||
const text = ed.getText()
|
||||
const chapterCount = countPlain(text)
|
||||
if (isChapter && target?.kind === 'chapter') {
|
||||
const ch = chapters.find((c) => c.id === target.id)
|
||||
const volId = ch?.volumeId
|
||||
const volumeTotal = chapters
|
||||
.filter((c) => c.volumeId === volId)
|
||||
.reduce((sum, c) => sum + (c.id === target.id ? chapterCount : c.wordCount), 0)
|
||||
const bookTotal = chapters.reduce(
|
||||
(sum, c) => sum + (c.id === target.id ? chapterCount : c.wordCount),
|
||||
0
|
||||
)
|
||||
setWordCount({ chapter: chapterCount, volume: volumeTotal, book: bookTotal })
|
||||
} else {
|
||||
setWordCount({ chapter: chapterCount, volume: chapterCount, book: chapterCount })
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
dragover: (_view, event) => {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
if (event.shiftKey) return false
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
if (text) {
|
||||
view.dispatch(view.state.tr.insertText(text))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleDrop: (view, event) => {
|
||||
const text = event.dataTransfer?.getData('text/plain')
|
||||
if (!text) return false
|
||||
event.preventDefault()
|
||||
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||
const pos = coords?.pos ?? view.state.selection.from
|
||||
view.dispatch(view.state.tr.insertText(text, pos))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}, [i18n.language])
|
||||
},
|
||||
[target?.kind, target?.id, i18n.language]
|
||||
)
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!bookId || !chapter || !editor) return
|
||||
if (!bookId || !target || !editor) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const html = editor.getHTML()
|
||||
const text = editor.getText()
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.chapter.update({
|
||||
bookId,
|
||||
chapterId: chapter.id,
|
||||
content: html,
|
||||
cursorOffset: editor.state.selection.anchor
|
||||
})
|
||||
)
|
||||
updateChapterLocal(updated)
|
||||
|
||||
if (target.kind === 'chapter') {
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.chapter.update({
|
||||
bookId,
|
||||
chapterId: target.id,
|
||||
content: html,
|
||||
cursorOffset: editor.state.selection.anchor
|
||||
})
|
||||
)
|
||||
updateChapterLocal(updated)
|
||||
if (persistOnNextSave) {
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.snapshot.create(bookId, target.id, 'persist', '', html)
|
||||
)
|
||||
}
|
||||
const volId = updated.volumeId
|
||||
const volumeTotal = chapters
|
||||
.map((c) => (c.id === updated.id ? updated : c))
|
||||
.filter((c) => c.volumeId === volId)
|
||||
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
|
||||
const bookTotal = chapters
|
||||
.map((c) => (c.id === updated.id ? updated : c))
|
||||
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
|
||||
setWordCount({ chapter: countPlain(text), volume: volumeTotal, book: bookTotal })
|
||||
} else if (target.kind === 'outline') {
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.outline.update(bookId, target.id, { description: html })
|
||||
)
|
||||
updateOutlineLocal(updated)
|
||||
setWordCount({ chapter: countPlain(text), volume: countPlain(text), book: countPlain(text) })
|
||||
} else if (target.kind === 'setting') {
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.setting.update(bookId, target.id, { description: html })
|
||||
)
|
||||
updateSettingLocal(updated)
|
||||
setWordCount({ chapter: countPlain(text), volume: countPlain(text), book: countPlain(text) })
|
||||
} else if (target.kind === 'inspiration') {
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.inspiration.update(bookId, target.id, { content: html })
|
||||
)
|
||||
updateInspirationLocal(updated)
|
||||
setWordCount({ chapter: countPlain(text), volume: countPlain(text), book: countPlain(text) })
|
||||
}
|
||||
|
||||
setDirty(false)
|
||||
setLastSaved(new Date())
|
||||
const volId = updated.volumeId
|
||||
const volumeTotal = chapters
|
||||
.map((c) => (c.id === updated.id ? updated : c))
|
||||
.filter((c) => c.volumeId === volId)
|
||||
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
|
||||
const bookTotal = chapters
|
||||
.map((c) => (c.id === updated.id ? updated : c))
|
||||
.reduce((sum, c) => sum + (c.id === updated.id ? countPlain(text) : c.wordCount), 0)
|
||||
setWordCount({ chapter: countPlain(text), volume: volumeTotal, book: bookTotal })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [bookId, chapter, chapters, editor, setDirty, setLastSaved, setSaving, setWordCount, updateChapterLocal])
|
||||
}, [
|
||||
bookId,
|
||||
target,
|
||||
editor,
|
||||
chapters,
|
||||
setDirty,
|
||||
setLastSaved,
|
||||
setSaving,
|
||||
setWordCount,
|
||||
updateChapterLocal,
|
||||
updateOutlineLocal,
|
||||
updateSettingLocal,
|
||||
updateInspirationLocal
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
;(window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave = save
|
||||
registerEditorFlush(save)
|
||||
return () => {
|
||||
registerEditorFlush(async () => {})
|
||||
clearTimeout(timerRef.current)
|
||||
delete (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
|
||||
}
|
||||
}, [save])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !chapter) return
|
||||
if (chapterIdRef.current !== chapter.id) {
|
||||
void (async () => {
|
||||
await flushEditorSave()
|
||||
chapterIdRef.current = chapter.id
|
||||
editor.commands.setContent(chapter.content || '')
|
||||
const pos = Math.min(chapter.cursorOffset ?? 0, editor.state.doc.content.size)
|
||||
editor.commands.focus(pos)
|
||||
setDirty(false)
|
||||
setWordCount({
|
||||
chapter: chapter.wordCount,
|
||||
volume: chapters.filter((c) => c.volumeId === chapter.volumeId).reduce((s, c) => s + c.wordCount, 0),
|
||||
book: chapters.reduce((s, c) => s + c.wordCount, 0)
|
||||
})
|
||||
})()
|
||||
if (!editor) return
|
||||
registerInsertLandmark(({ label, landmarkType = 'todo' }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setMark('landmark', { label, landmarkType })
|
||||
.insertContent(`[TODO: ${label}]`)
|
||||
.unsetMark('landmark')
|
||||
.insertContent(' ')
|
||||
.run()
|
||||
})
|
||||
registerHighlightToken((token) => {
|
||||
const range = findTokenRange(editor.state.doc, token)
|
||||
if (!range) return
|
||||
editor.chain().focus().setTextSelection(range).run()
|
||||
document.querySelector('.editor-content-wrap')?.setAttribute('data-highlight-token', token)
|
||||
})
|
||||
return () => {
|
||||
registerInsertLandmark(() => {})
|
||||
registerHighlightToken(() => {})
|
||||
}
|
||||
}, [chapter, editor, chapters, setDirty, setWordCount])
|
||||
}, [editor])
|
||||
|
||||
if (!chapter) {
|
||||
useEffect(() => {
|
||||
if (!editor || !target) return
|
||||
const key = `${target.kind}:${target.id}`
|
||||
if (targetKeyRef.current === key) return
|
||||
targetKeyRef.current = key
|
||||
|
||||
editor.commands.setContent(content || '')
|
||||
if (target.kind === 'chapter') {
|
||||
const ch = chapters.find((c) => c.id === target.id)
|
||||
const pos = Math.min(ch?.cursorOffset ?? 0, editor.state.doc.content.size)
|
||||
editor.commands.focus(pos)
|
||||
setWordCount({
|
||||
chapter: ch?.wordCount ?? 0,
|
||||
volume: chapters.filter((c) => c.volumeId === ch?.volumeId).reduce((s, c) => s + c.wordCount, 0),
|
||||
book: chapters.reduce((s, c) => s + c.wordCount, 0)
|
||||
})
|
||||
} else {
|
||||
editor.commands.focus('end')
|
||||
const wc = countPlain(editor.getText())
|
||||
setWordCount({ chapter: wc, volume: wc, book: wc })
|
||||
}
|
||||
setDirty(false)
|
||||
}, [target, content, editor, chapters, setDirty, setWordCount])
|
||||
|
||||
const applyMention = (id: string, label: string): void => {
|
||||
if (!editor) return
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: mentionFrom, to: editor.state.selection.from })
|
||||
.insertContent(
|
||||
`<span data-setting-mention="true" class="setting-mention" data-id="${id}" data-label="${label}">@${label}</span> `
|
||||
)
|
||||
.run()
|
||||
setMentionOpen(false)
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return <div className="placeholder-box">{t('editor.placeholder')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor-content-wrap">
|
||||
<EditorContent editor={editor} />
|
||||
{mentionOpen && mentionCandidates.length > 0 && (
|
||||
<div className="mention-dropdown" data-testid="mention-dropdown">
|
||||
{mentionCandidates.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="mention-item"
|
||||
data-testid={`mention-item-${s.id}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
applyMention(s.id, s.name)
|
||||
}}
|
||||
>
|
||||
@{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function flushEditorSave(): Promise<void> {
|
||||
const fn = (window as unknown as { __bilinSave?: () => Promise<void> }).__bilinSave
|
||||
if (fn) await fn()
|
||||
export async function flushEditorSave(persist = false): Promise<void> {
|
||||
persistOnNextSave = persist
|
||||
await flushEditSession()
|
||||
persistOnNextSave = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function InspirationList(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const inspirations = useBookStore((s) => s.inspirations)
|
||||
const addInspirationLocal = useBookStore((s) => s.addInspirationLocal)
|
||||
const target = useEditStore((s) => s.target)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
|
||||
const activeId = target?.kind === 'inspiration' ? target.id : null
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!bookId) return
|
||||
const item = await ipcCall(() =>
|
||||
window.electronAPI.inspiration.create(bookId, t('inspiration.newItem'))
|
||||
)
|
||||
addInspirationLocal(item)
|
||||
await switchTarget({ kind: 'inspiration', id: item.id })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-list" data-testid="inspiration-list">
|
||||
{inspirations.length === 0 && (
|
||||
<div className="sidebar-empty">{t('inspiration.empty')}</div>
|
||||
)}
|
||||
{inspirations.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`inspiration-item ${activeId === item.id ? 'active' : ''}`}
|
||||
onClick={() => void switchTarget({ kind: 'inspiration', id: item.id })}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void switchTarget({ kind: 'inspiration', id: item.id })}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid={`inspiration-item-${item.id}`}
|
||||
>
|
||||
<div className="inspiration-title">{item.title || t('inspiration.untitled')}</div>
|
||||
<div className="inspiration-preview">
|
||||
{item.content.replace(/<[^>]+>/g, '').slice(0, 40) || t('inspiration.noContent')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="sidebar-footer">
|
||||
<button type="button" data-testid="inspiration-new" onClick={() => void handleCreate()}>
|
||||
+ {t('inspiration.new')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Bookmark } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface LandmarkModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function LandmarkModal({ open, onClose }: LandmarkModalProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId) return
|
||||
void ipcCall(() => window.electronAPI.bookmark.list(bookId)).then(setBookmarks)
|
||||
}, [open, bookId])
|
||||
|
||||
const jumpTo = async (bm: Bookmark): Promise<void> => {
|
||||
setSelectedChapter(bm.chapterId)
|
||||
await switchTarget({ kind: 'chapter', id: bm.chapterId })
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" id="landmarksModal" data-testid="landmarks-modal">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>{t('landmark.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="search-results">
|
||||
{bookmarks.map((bm) => {
|
||||
const ch = chapters.find((c) => c.id === bm.chapterId)
|
||||
return (
|
||||
<div
|
||||
key={bm.id}
|
||||
className="search-result"
|
||||
onClick={() => void jumpTo(bm)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid={`landmark-item-${bm.id}`}
|
||||
>
|
||||
<div className="sr-type">
|
||||
{bm.landmarkType} · {ch?.title ?? bm.chapterId}
|
||||
</div>
|
||||
<div>{bm.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{bookmarks.length === 0 && <div className="sidebar-empty">{t('landmark.empty')}</div>}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useOutlineStore } from '@renderer/stores/useOutlineStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||
import { RightPanel } from '@renderer/components/layout/RightPanel'
|
||||
import { ReferencePanel } from '@renderer/components/reference/ReferencePanel'
|
||||
import { OutlineTree } from '@renderer/components/outline/OutlineTree'
|
||||
import { OutlineCompareView } from '@renderer/components/outline/OutlineCompareView'
|
||||
import { SettingList } from '@renderer/components/setting/SettingList'
|
||||
import { InspirationList } from '@renderer/components/inspiration/InspirationList'
|
||||
import { VersionModal } from '@renderer/components/version/VersionModal'
|
||||
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
|
||||
import {
|
||||
editorDirtyAtom,
|
||||
editorSavingAtom,
|
||||
@@ -12,11 +24,40 @@ import {
|
||||
wordCountAtom
|
||||
} from '@renderer/atoms/editorAtoms'
|
||||
|
||||
function useDocumentTitle(): string {
|
||||
const target = useEditStore((s) => s.target)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const outlines = useBookStore((s) => s.outlines)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const inspirations = useBookStore((s) => s.inspirations)
|
||||
|
||||
return useMemo(() => {
|
||||
if (!target) return '—'
|
||||
if (target.kind === 'chapter') {
|
||||
return chapters.find((c) => c.id === target.id)?.title ?? '—'
|
||||
}
|
||||
if (target.kind === 'outline') {
|
||||
return outlines.find((o) => o.id === target.id)?.title ?? '—'
|
||||
}
|
||||
if (target.kind === 'setting') {
|
||||
return settings.find((s) => s.id === target.id)?.name ?? '—'
|
||||
}
|
||||
return inspirations.find((i) => i.id === target.id)?.title ?? '—'
|
||||
}, [target, chapters, outlines, settings, inspirations])
|
||||
}
|
||||
|
||||
export function EditorLayout(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const sidebarPanel = useAppStore((s) => s.setSidebarPanel)
|
||||
const setSidebarPanel = useAppStore((s) => s.setSidebarPanel)
|
||||
const activePanel = useAppStore((s) => s.sidebarPanel)
|
||||
const versionModalOpen = useAppStore((s) => s.versionModalOpen)
|
||||
const landmarksModalOpen = useAppStore((s) => s.landmarksModalOpen)
|
||||
const setVersionModalOpen = useAppStore((s) => s.setVersionModalOpen)
|
||||
const setLandmarksModalOpen = useAppStore((s) => s.setLandmarksModalOpen)
|
||||
const compareMode = useOutlineStore((s) => s.compareMode)
|
||||
const referenceOpen = useReferenceStore((s) => s.open)
|
||||
const setReferenceOpen = useReferenceStore((s) => s.setOpen)
|
||||
const {
|
||||
currentBookId,
|
||||
volumes,
|
||||
@@ -27,18 +68,38 @@ export function EditorLayout(): React.JSX.Element {
|
||||
setActiveVolume,
|
||||
refreshChapters
|
||||
} = useBookStore()
|
||||
const target = useEditStore((s) => s.target)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const setTarget = useEditStore((s) => s.setTarget)
|
||||
|
||||
const dirty = useAtomValue(editorDirtyAtom)
|
||||
const saving = useAtomValue(editorSavingAtom)
|
||||
const lastSaved = useAtomValue(lastSavedAtAtom)
|
||||
const wordCount = useAtomValue(wordCountAtom)
|
||||
const documentTitle = useDocumentTitle()
|
||||
|
||||
const currentChapter = chapters.find((c) => c.id === selectedChapterId) ?? null
|
||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||
const isChapterTarget = target?.kind === 'chapter'
|
||||
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChapterId && (!target || target.kind === 'chapter')) {
|
||||
setTarget({ kind: 'chapter', id: selectedChapterId })
|
||||
}
|
||||
}, [selectedChapterId, setTarget])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentBookId || target?.kind !== 'chapter') return
|
||||
const chapterId = target.id
|
||||
void ipcCall(() => window.electronAPI.snapshot.startAutoSave(currentBookId, chapterId))
|
||||
return () => {
|
||||
void ipcCall(() => window.electronAPI.snapshot.stopAutoSave(currentBookId, chapterId))
|
||||
}
|
||||
}, [currentBookId, target?.kind, target?.id])
|
||||
|
||||
const handleSelectChapter = async (chapterId: string): Promise<void> => {
|
||||
await flushEditorSave()
|
||||
setSelectedChapter(chapterId)
|
||||
await switchTarget({ kind: 'chapter', id: chapterId })
|
||||
}
|
||||
|
||||
const handleNewChapter = async (): Promise<void> => {
|
||||
@@ -49,6 +110,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
)
|
||||
await refreshChapters()
|
||||
setSelectedChapter(ch.id)
|
||||
await switchTarget({ kind: 'chapter', id: ch.id })
|
||||
}
|
||||
|
||||
const handleNewVolume = async (): Promise<void> => {
|
||||
@@ -60,10 +122,24 @@ export function EditorLayout(): React.JSX.Element {
|
||||
setActiveVolume(vol.id)
|
||||
}
|
||||
|
||||
const handleInsertLandmark = async (): Promise<void> => {
|
||||
if (!currentBookId || target?.kind !== 'chapter') {
|
||||
showToast(t('landmark.chapterOnly'))
|
||||
return
|
||||
}
|
||||
const label = prompt(t('landmark.prompt'))
|
||||
if (!label?.trim()) return
|
||||
insertLandmarkInEditor({ label: label.trim(), landmarkType: 'todo' })
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), 'todo')
|
||||
)
|
||||
showToast(t('landmark.created'))
|
||||
}
|
||||
|
||||
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||
|
||||
return (
|
||||
<div id="editor-layout">
|
||||
<div id="editor-layout" data-book-id={currentBookId ?? ''}>
|
||||
<div id="left-sidebar">
|
||||
<div className="sidebar-header">{bookMeta?.name ?? '—'}</div>
|
||||
<div className="sidebar-nav">
|
||||
@@ -71,8 +147,9 @@ export function EditorLayout(): React.JSX.Element {
|
||||
<button
|
||||
key={panel}
|
||||
type="button"
|
||||
data-testid={`sidebar-tab-${panel}`}
|
||||
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
|
||||
onClick={() => sidebarPanel(panel)}
|
||||
onClick={() => setSidebarPanel(panel)}
|
||||
>
|
||||
{panel === 'chapters' && t('sidebar.chapters')}
|
||||
{panel === 'outline' && t('sidebar.outline')}
|
||||
@@ -96,7 +173,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
.map((ch, idx) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className={`chapter-item ${selectedChapterId === ch.id ? 'active' : ''}`}
|
||||
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''}`}
|
||||
onClick={() => void handleSelectChapter(ch.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
||||
role="button"
|
||||
@@ -109,41 +186,90 @@ export function EditorLayout(): React.JSX.Element {
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{activePanel !== 'chapters' && (
|
||||
<div className="sidebar-panel active">
|
||||
<div className="placeholder-box" style={{ padding: 20 }}>
|
||||
{t('feature.comingSoon')}
|
||||
{activePanel === 'chapters' && (
|
||||
<div className="sidebar-footer">
|
||||
<button type="button" onClick={() => void handleNewChapter()}>
|
||||
+ {t('editor.newChapter')}
|
||||
</button>
|
||||
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
||||
+ {t('editor.newVolume')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-footer">
|
||||
<button type="button" onClick={() => void handleNewChapter()}>
|
||||
+ {t('editor.newChapter')}
|
||||
</button>
|
||||
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
||||
+ {t('editor.newVolume')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`sidebar-panel ${activePanel === 'outline' ? 'active' : ''}`}>
|
||||
{activePanel === 'outline' && <OutlineTree />}
|
||||
</div>
|
||||
<div className={`sidebar-panel ${activePanel === 'setting' ? 'active' : ''}`}>
|
||||
{activePanel === 'setting' && <SettingList />}
|
||||
</div>
|
||||
<div className={`sidebar-panel ${activePanel === 'inspiration' ? 'active' : ''}`}>
|
||||
{activePanel === 'inspiration' && <InspirationList />}
|
||||
</div>
|
||||
</div>
|
||||
<div id="editor-area">
|
||||
<div id="editor-toolbar">
|
||||
<button type="button" className="tool-btn" onClick={() => showToast(t('feature.comingSoon'))} title="引用">
|
||||
📎
|
||||
</button>
|
||||
<span className="editor-label">
|
||||
{currentChapter ? currentChapter.title : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<TipTapEditor chapter={currentChapter} bookId={currentBookId} />
|
||||
<div id="editor-statusbar">
|
||||
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
||||
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
||||
</div>
|
||||
{compareMode ? (
|
||||
<OutlineCompareView />
|
||||
) : (
|
||||
<>
|
||||
<div id="editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="tool-btn"
|
||||
title={t('reference.title')}
|
||||
data-testid="open-reference"
|
||||
onClick={() => setReferenceOpen(true)}
|
||||
>
|
||||
📎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tool-btn"
|
||||
title={t('version.title')}
|
||||
data-testid="open-version"
|
||||
onClick={() => setVersionModalOpen(true)}
|
||||
>
|
||||
📜
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tool-btn"
|
||||
title={t('landmark.insert')}
|
||||
data-testid="insert-landmark"
|
||||
onClick={() => void handleInsertLandmark()}
|
||||
>
|
||||
📌
|
||||
</button>
|
||||
<span className="editor-label">{documentTitle}</span>
|
||||
</div>
|
||||
<TipTapEditor
|
||||
key={target ? `${target.kind}:${target.id}` : 'none'}
|
||||
target={target}
|
||||
bookId={currentBookId}
|
||||
/>
|
||||
<div id="editor-statusbar">
|
||||
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
|
||||
{isChapterTarget ? (
|
||||
<>
|
||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
||||
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t('editor.words', { count: wordCount.chapter })}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<RightPanel />
|
||||
<ReferencePanel />
|
||||
{!referenceOpen && <RightPanel />}
|
||||
<VersionModal
|
||||
open={versionModalOpen}
|
||||
onClose={() => setVersionModalOpen(false)}
|
||||
chapterId={chapterId}
|
||||
/>
|
||||
<LandmarkModal open={landmarksModalOpen} onClose={() => setLandmarksModalOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
||||
|
||||
export function RightPanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
@@ -20,6 +21,7 @@ export function RightPanel(): React.JSX.Element {
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
|
||||
data-testid={`panel-tab-${tab.id}`}
|
||||
onClick={() => setRightPanel(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -30,11 +32,15 @@ export function RightPanel(): React.JSX.Element {
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('panel.aiPlaceholder')}</p>
|
||||
<input className="ai-input-disabled" disabled placeholder={t('feature.comingSoon')} />
|
||||
</div>
|
||||
{(['knowledge', 'wordfreq', 'book-settings'] as const).map((id) => (
|
||||
<div key={id} className={`panel-content ${rightPanel === id ? 'active' : ''}`}>
|
||||
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={`panel-content ${rightPanel === 'knowledge' ? 'active' : ''}`}>
|
||||
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
||||
</div>
|
||||
<div className={`panel-content ${rightPanel === 'wordfreq' ? 'active' : ''}`}>
|
||||
<WordFreqPanel />
|
||||
</div>
|
||||
<div className={`panel-content ${rightPanel === 'book-settings' ? 'active' : ''}`}>
|
||||
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,10 +54,11 @@ export function OnboardingWizard({ onComplete }: OnboardingWizardProps): React.J
|
||||
<h2>{t('onboarding.penName')}</h2>
|
||||
<input
|
||||
data-testid="onboarding-pen-name"
|
||||
className="form-control form-control--wide"
|
||||
value={penName}
|
||||
onChange={(e) => setPenName(e.target.value)}
|
||||
placeholder={t('onboarding.penNamePlaceholder')}
|
||||
style={{ width: '100%', marginTop: 12, padding: 10, borderRadius: 6, border: '1px solid var(--border)', background: 'var(--bg-tertiary)', color: 'inherit' }}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<input type="checkbox" checked={createSample} onChange={(e) => setCreateSample(e.target.checked)} />
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useOutlineStore, filterOutlines } from '@renderer/stores/useOutlineStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { buildOutlineTree, calcDeviation, flattenOutlineTree } from '@renderer/lib/outline-utils'
|
||||
|
||||
export function OutlineCompareView(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const outlines = useBookStore((s) => s.outlines)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const filter = useOutlineStore((s) => s.filter)
|
||||
const compareSelectedId = useOutlineStore((s) => s.compareSelectedId)
|
||||
const setCompareSelectedId = useOutlineStore((s) => s.setCompareSelectedId)
|
||||
const setCompareMode = useOutlineStore((s) => s.setCompareMode)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
|
||||
const filtered = useMemo(() => filterOutlines(outlines, filter), [outlines, filter])
|
||||
const flat = useMemo(() => flattenOutlineTree(buildOutlineTree(filtered)), [filtered])
|
||||
const selected =
|
||||
flat.find((o) => o.id === compareSelectedId) ?? flat[0] ?? null
|
||||
|
||||
const linkedChapter = selected?.chapterId
|
||||
? chapters.find((c) => c.id === selected.chapterId) ?? null
|
||||
: null
|
||||
|
||||
const actualWords = linkedChapter?.wordCount ?? 0
|
||||
const expectedWords = selected?.expectedWordCount ?? null
|
||||
const deviation = calcDeviation(expectedWords, actualWords)
|
||||
const deviationHigh = deviation !== null && Math.abs(deviation) > 20
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
setCompareSelectedId(id)
|
||||
}
|
||||
|
||||
const handleJumpEditor = (): void => {
|
||||
if (!selected) return
|
||||
setCompareMode(false)
|
||||
if (selected.chapterId) {
|
||||
void switchTarget({ kind: 'chapter', id: selected.chapterId })
|
||||
} else {
|
||||
void switchTarget({ kind: 'outline', id: selected.id })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="outline-compare-view" data-testid="outline-compare-view">
|
||||
<div className="compare-toolbar">
|
||||
<span>{t('outline.compareTitle')}</span>
|
||||
<button type="button" className="btn" onClick={() => setCompareMode(false)}>
|
||||
{t('outline.exitCompare')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="compare-body">
|
||||
<div className="compare-left">
|
||||
{flat.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`outline-item ${selected?.id === item.id ? 'active' : ''} ${!item.chapterId ? 'uncovered' : ''}`}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{!item.chapterId && <span className="outline-warn">⚠</span>}
|
||||
<span className="outline-title">{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
{flat.length === 0 && <div className="sidebar-empty">{t('outline.emptyFilter')}</div>}
|
||||
</div>
|
||||
<div className="compare-right">
|
||||
{linkedChapter ? (
|
||||
<div
|
||||
className="compare-chapter-content"
|
||||
dangerouslySetInnerHTML={{ __html: linkedChapter.content || `<p>${t('outline.noContent')}</p>` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="placeholder-box">{t('outline.notWritten')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="compare-footer">
|
||||
<span>{t('outline.expected')}: {expectedWords ?? '—'}</span>
|
||||
<span>{t('outline.actual')}: {actualWords}</span>
|
||||
<span className={deviationHigh ? 'compare-deviation-warn' : ''}>
|
||||
{t('outline.deviation')}: {deviation !== null ? `${deviation}%` : '—'}
|
||||
</span>
|
||||
<button type="button" className="btn primary" onClick={handleJumpEditor}>
|
||||
{t('outline.jumpEditor')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { OutlineItem } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useOutlineStore, filterOutlines } from '@renderer/stores/useOutlineStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { buildOutlineTree } from '@renderer/lib/outline-utils'
|
||||
|
||||
interface OutlineNodeProps {
|
||||
item: OutlineItem
|
||||
depth: number
|
||||
activeId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onRename: (id: string, title: string) => void
|
||||
}
|
||||
|
||||
function OutlineNode({ item, depth, activeId, onSelect, onRename }: OutlineNodeProps): React.JSX.Element {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [title, setTitle] = useState(item.title)
|
||||
const uncovered = !item.chapterId
|
||||
|
||||
const commitTitle = (): void => {
|
||||
setEditing(false)
|
||||
const trimmed = title.trim()
|
||||
if (trimmed && trimmed !== item.title) onRename(item.id, trimmed)
|
||||
else setTitle(item.title)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`outline-item ${activeId === item.id ? 'active' : ''} ${uncovered ? 'uncovered' : ''}`}
|
||||
style={{ paddingLeft: 12 + depth * 14 }}
|
||||
onClick={() => onSelect(item.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSelect(item.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid={`outline-item-${item.id}`}
|
||||
data-uncovered={uncovered ? 'true' : 'false'}
|
||||
title={uncovered ? '未关联章节' : undefined}
|
||||
>
|
||||
{uncovered && (
|
||||
<span className="outline-warn" data-testid="outline-warn">
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
{editing ? (
|
||||
<input
|
||||
className="form-control outline-title-input"
|
||||
value={title}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={() => commitTitle()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle()
|
||||
if (e.key === 'Escape') {
|
||||
setTitle(item.title)
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="outline-title"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.children?.map((child) => (
|
||||
<OutlineNode
|
||||
key={child.id}
|
||||
item={child}
|
||||
depth={depth + 1}
|
||||
activeId={activeId}
|
||||
onSelect={onSelect}
|
||||
onRename={onRename}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function OutlineTree(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const outlines = useBookStore((s) => s.outlines)
|
||||
const addOutlineLocal = useBookStore((s) => s.addOutlineLocal)
|
||||
const updateOutlineLocal = useBookStore((s) => s.updateOutlineLocal)
|
||||
const target = useEditStore((s) => s.target)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const filter = useOutlineStore((s) => s.filter)
|
||||
const setFilter = useOutlineStore((s) => s.setFilter)
|
||||
const setCompareMode = useOutlineStore((s) => s.setCompareMode)
|
||||
const setCompareSelectedId = useOutlineStore((s) => s.setCompareSelectedId)
|
||||
|
||||
const filtered = useMemo(() => filterOutlines(outlines, filter), [outlines, filter])
|
||||
const tree = useMemo(() => buildOutlineTree(filtered), [filtered])
|
||||
const activeId = target?.kind === 'outline' ? target.id : null
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
void switchTarget({ kind: 'outline', id })
|
||||
}
|
||||
|
||||
const handleRename = async (id: string, title: string): Promise<void> => {
|
||||
if (!bookId) return
|
||||
const updated = await ipcCall(() => window.electronAPI.outline.update(bookId, id, { title }))
|
||||
updateOutlineLocal(updated)
|
||||
}
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!bookId) {
|
||||
showToast(t('toast.noBookOpen'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const item = await ipcCall(() =>
|
||||
window.electronAPI.outline.create(bookId, t('outline.newItem'))
|
||||
)
|
||||
addOutlineLocal(item)
|
||||
await switchTarget({ kind: 'outline', id: item.id })
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompare = (): void => {
|
||||
setCompareSelectedId(filtered[0]?.id ?? null)
|
||||
setCompareMode(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="outline-toolbar">
|
||||
<select
|
||||
data-testid="outline-filter"
|
||||
className="form-control"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as typeof filter)}
|
||||
>
|
||||
<option value="all">{t('outline.filterAll')}</option>
|
||||
<option value="uncovered">{t('outline.filterUncovered')}</option>
|
||||
<option value="covered">{t('outline.filterCovered')}</option>
|
||||
</select>
|
||||
<button type="button" className="btn" data-testid="outline-compare" onClick={handleCompare}>
|
||||
⇔ {t('outline.compare')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-list" data-testid="outline-tree">
|
||||
{tree.length === 0 && <div className="sidebar-empty">{t('outline.empty')}</div>}
|
||||
{tree.map((item) => (
|
||||
<OutlineNode
|
||||
key={item.id}
|
||||
item={item}
|
||||
depth={0}
|
||||
activeId={activeId}
|
||||
onSelect={handleSelect}
|
||||
onRename={(id, title) => void handleRename(id, title)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="sidebar-footer">
|
||||
<button type="button" data-testid="outline-new" onClick={() => void handleCreate()}>
|
||||
+ {t('outline.new')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
|
||||
export function ReferencePanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const open = useReferenceStore((s) => s.open)
|
||||
const tab = useReferenceStore((s) => s.tab)
|
||||
const query = useReferenceStore((s) => s.query)
|
||||
const setTab = useReferenceStore((s) => s.setTab)
|
||||
const setQuery = useReferenceStore((s) => s.setQuery)
|
||||
const setOpen = useReferenceStore((s) => s.setOpen)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const outlines = useBookStore((s) => s.outlines)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const inspirations = useBookStore((s) => s.inspirations)
|
||||
|
||||
const items = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const match = (title: string, body: string) =>
|
||||
!q || title.toLowerCase().includes(q) || body.toLowerCase().includes(q)
|
||||
|
||||
if (tab === 'setting') {
|
||||
return settings
|
||||
.filter((s) => match(s.name, s.description))
|
||||
.map((s) => ({ id: s.id, kind: 'setting' as const, title: s.name, preview: s.description }))
|
||||
}
|
||||
if (tab === 'outline') {
|
||||
return outlines
|
||||
.filter((o) => match(o.title, o.description))
|
||||
.map((o) => ({ id: o.id, kind: 'outline' as const, title: o.title, preview: o.description }))
|
||||
}
|
||||
return inspirations
|
||||
.filter((i) => match(i.title, i.content))
|
||||
.map((i) => ({ id: i.id, kind: 'inspiration' as const, title: i.title, preview: i.content }))
|
||||
}, [tab, query, settings, outlines, inspirations])
|
||||
|
||||
if (!open) return <div id="reference-panel" />
|
||||
|
||||
return (
|
||||
<div id="reference-panel" className="open" data-testid="reference-panel">
|
||||
<div className="ref-header">
|
||||
<span>{t('reference.title')}</span>
|
||||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
placeholder={t('reference.search')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="ref-tabs">
|
||||
{(['setting', 'outline', 'inspiration'] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`ref-tab ${tab === id ? 'active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{t(`reference.tab.${id}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ref-list">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="ref-item"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
const plain = item.preview.replace(/<[^>]+>/g, '').slice(0, 200)
|
||||
e.dataTransfer.setData('text/plain', plain)
|
||||
}}
|
||||
onClick={() => void switchTarget({ kind: item.kind, id: item.id })}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid={`ref-item-${item.id}`}
|
||||
>
|
||||
<div className="ref-item-title">{item.title}</div>
|
||||
<div className="ref-item-preview">
|
||||
{item.preview.replace(/<[^>]+>/g, '').slice(0, 40)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SearchResult } from '@shared/types'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function SearchModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useSearchStore((s) => s.open)
|
||||
const query = useSearchStore((s) => s.query)
|
||||
const regex = useSearchStore((s) => s.regex)
|
||||
const caseSensitive = useSearchStore((s) => s.caseSensitive)
|
||||
const setOpen = useSearchStore((s) => s.setOpen)
|
||||
const setQuery = useSearchStore((s) => s.setQuery)
|
||||
const setRegex = useSearchStore((s) => s.setRegex)
|
||||
const setCaseSensitive = useSearchStore((s) => s.setCaseSensitive)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [selectedIdx, setSelectedIdx] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
void ipcCall(() => window.electronAPI.search.getHistory()).then(setHistory)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId || !query.trim()) {
|
||||
setResults([])
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.search.query(bookId, query, { regex, caseSensitive })
|
||||
).then(setResults)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open, bookId, query, regex, caseSensitive])
|
||||
|
||||
const jumpTo = async (hit: SearchResult): Promise<void> => {
|
||||
if (!bookId) return
|
||||
await ipcCall(() => window.electronAPI.search.saveHistory(query))
|
||||
setOpen(false)
|
||||
if (hit.kind === 'chapter' || hit.kind === 'bookmark') {
|
||||
const chapterId = hit.chapterId ?? hit.id
|
||||
setSelectedChapter(chapterId)
|
||||
await switchTarget({ kind: 'chapter', id: chapterId })
|
||||
} else if (hit.kind === 'outline') {
|
||||
await switchTarget({ kind: 'outline', id: hit.id })
|
||||
} else if (hit.kind === 'setting') {
|
||||
await switchTarget({ kind: 'setting', id: hit.id })
|
||||
} else if (hit.kind === 'inspiration') {
|
||||
await switchTarget({ kind: 'inspiration', id: hit.id })
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" id="searchModal" data-testid="search-modal">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>{t('search.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="search-input"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('search.placeholder')}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') setSelectedIdx((i) => Math.min(i + 1, results.length - 1))
|
||||
if (e.key === 'ArrowUp') setSelectedIdx((i) => Math.max(i - 1, 0))
|
||||
if (e.key === 'Enter' && results[selectedIdx]) void jumpTo(results[selectedIdx])
|
||||
}}
|
||||
/>
|
||||
<div className="search-options">
|
||||
<label>
|
||||
<input type="checkbox" checked={regex} onChange={(e) => setRegex(e.target.checked)} />
|
||||
{t('search.regex')}
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={caseSensitive}
|
||||
onChange={(e) => setCaseSensitive(e.target.checked)}
|
||||
/>
|
||||
{t('search.caseSensitive')}
|
||||
</label>
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<div className="search-history">
|
||||
{history.slice(0, 5).map((h) => (
|
||||
<button key={h} type="button" className="btn" onClick={() => setQuery(h)}>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="search-results">
|
||||
{results.map((hit, idx) => (
|
||||
<div
|
||||
key={`${hit.kind}-${hit.id}`}
|
||||
className={`search-result ${idx === selectedIdx ? 'active' : ''}`}
|
||||
onClick={() => void jumpTo(hit)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="sr-type">
|
||||
{hit.kind} · {hit.title}
|
||||
</div>
|
||||
<div>{hit.snippet}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { SettingType } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
const SETTING_TYPES: SettingType[] = [
|
||||
'character',
|
||||
'location',
|
||||
'item',
|
||||
'skill',
|
||||
'faction',
|
||||
'custom'
|
||||
]
|
||||
|
||||
export function SettingList(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const addSettingLocal = useBookStore((s) => s.addSettingLocal)
|
||||
const target = useEditStore((s) => s.target)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [newType, setNewType] = useState<SettingType>('character')
|
||||
const [newName, setNewName] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
|
||||
const grouped = SETTING_TYPES.map((type) => ({
|
||||
type,
|
||||
items: settings.filter((s) => s.type === type)
|
||||
})).filter((g) => g.items.length > 0 || !collapsed[g.type])
|
||||
|
||||
const activeId = target?.kind === 'setting' ? target.id : null
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!bookId || !newName.trim()) return
|
||||
const item = await ipcCall(() =>
|
||||
window.electronAPI.setting.create(bookId, newType, newName.trim())
|
||||
)
|
||||
addSettingLocal(item)
|
||||
setShowDialog(false)
|
||||
setNewName('')
|
||||
await switchTarget({ kind: 'setting', id: item.id })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-list" data-testid="setting-list">
|
||||
{settings.length === 0 && (
|
||||
<div className="sidebar-empty">{t('setting.empty')}</div>
|
||||
)}
|
||||
{SETTING_TYPES.map((type) => {
|
||||
const items = settings.filter((s) => s.type === type)
|
||||
if (items.length === 0) return null
|
||||
const isCollapsed = collapsed[type]
|
||||
return (
|
||||
<div key={type} className="setting-group">
|
||||
<div
|
||||
className="setting-group-header"
|
||||
onClick={() => setCollapsed((c) => ({ ...c, [type]: !c[type] }))}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isCollapsed ? '▶' : '▼'} {t(`setting.type.${type}`)}
|
||||
<span className="setting-group-count">{items.length}</span>
|
||||
</div>
|
||||
{!isCollapsed &&
|
||||
items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`setting-item ${activeId === item.id ? 'active' : ''}`}
|
||||
onClick={() => void switchTarget({ kind: 'setting', id: item.id })}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void switchTarget({ kind: 'setting', id: item.id })}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid={`setting-item-${item.id}`}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="sidebar-footer">
|
||||
<button type="button" data-testid="setting-new" onClick={() => setShowDialog(true)}>
|
||||
+ {t('setting.new')}
|
||||
</button>
|
||||
</div>
|
||||
{showDialog && (
|
||||
<div className="dialog-overlay" onClick={() => setShowDialog(false)}>
|
||||
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t('setting.new')}</h3>
|
||||
<label className="form-label">
|
||||
{t('setting.typeLabel')}
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as SettingType)}
|
||||
>
|
||||
{SETTING_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`setting.type.${type}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-label">
|
||||
{t('setting.nameLabel')}
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
data-testid="setting-name-input"
|
||||
/>
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="btn" onClick={() => setShowDialog(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={() => void handleCreate()}>
|
||||
{t('dialog.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -48,25 +48,25 @@ export function SettingsPage(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-content">
|
||||
<h2 style={{ marginBottom: 20 }}>{t('settings.title')}</h2>
|
||||
<h2 className="settings-heading">{t('settings.title')}</h2>
|
||||
{section === 'general' && (
|
||||
<>
|
||||
<div className="setting-item">
|
||||
<span>{t('settings.penName')}</span>
|
||||
<input
|
||||
data-testid="settings-pen-name"
|
||||
className="form-control form-control--narrow"
|
||||
value={settings.penName}
|
||||
onChange={(e) => void settings.update({ penName: e.target.value })}
|
||||
style={{ width: 160, padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<span>{t('settings.theme')}</span>
|
||||
<select
|
||||
data-testid="settings-theme"
|
||||
className="form-control"
|
||||
value={settings.theme}
|
||||
onChange={(e) => void settings.update({ theme: e.target.value as ThemeId })}
|
||||
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
||||
>
|
||||
{THEMES.map((th) => (
|
||||
<option key={th.id} value={th.id}>
|
||||
@@ -79,9 +79,9 @@ export function SettingsPage(): React.JSX.Element {
|
||||
<span>{t('settings.language')}</span>
|
||||
<select
|
||||
data-testid="settings-language"
|
||||
className="form-control"
|
||||
value={settings.language}
|
||||
onChange={(e) => void settings.update({ language: e.target.value as Language })}
|
||||
style={{ padding: '6px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--bg-surface)', color: 'inherit' }}
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DiffMatchPatch from 'diff-match-patch'
|
||||
import type { Snapshot } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
|
||||
interface VersionModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
chapterId: string | null
|
||||
}
|
||||
|
||||
function stripHtml(text: string): string {
|
||||
return text.replace(/<[^>]+>/g, '')
|
||||
}
|
||||
|
||||
function renderDiff(oldText: string, newText: string): React.JSX.Element {
|
||||
const dmp = new DiffMatchPatch()
|
||||
const diffs = dmp.diff_main(oldText, newText)
|
||||
dmp.diff_cleanupSemantic(diffs)
|
||||
return (
|
||||
<div className="diff-view">
|
||||
{diffs.map(([op, text], i) => {
|
||||
if (op === 1) return <ins key={i}>{text}</ins>
|
||||
if (op === -1) return <del key={i}>{text}</del>
|
||||
return <span key={i}>{text}</span>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VersionModal({ open, onClose, chapterId }: VersionModalProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
const currentChapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
|
||||
const selected = snapshots.find((s) => s.id === selectedId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId || !chapterId) return
|
||||
void ipcCall(() => window.electronAPI.snapshot.list(bookId, chapterId)).then((list) => {
|
||||
setSnapshots(list)
|
||||
setSelectedId(list[0]?.id ?? null)
|
||||
})
|
||||
}, [open, bookId, chapterId])
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!bookId || !chapterId || !currentChapter) return
|
||||
await flushEditorSave()
|
||||
const snap = await ipcCall(() =>
|
||||
window.electronAPI.snapshot.create(bookId, chapterId, 'manual', t('version.manual'), currentChapter.content)
|
||||
)
|
||||
setSnapshots((s) => [snap, ...s])
|
||||
setSelectedId(snap.id)
|
||||
}
|
||||
|
||||
const handleRestore = async (): Promise<void> => {
|
||||
if (!bookId || !chapterId || !selectedId) return
|
||||
if (!confirm(t('version.restoreConfirm'))) return
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.snapshot.restore(bookId, chapterId, selectedId)
|
||||
)
|
||||
updateChapterLocal(updated)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" id="versionModal" data-testid="version-modal">
|
||||
<div className="modal-content modal-content--wide">
|
||||
<div className="modal-header">
|
||||
<h3>{t('version.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="version-layout">
|
||||
<div className="version-list">
|
||||
{snapshots.map((snap) => (
|
||||
<button
|
||||
key={snap.id}
|
||||
type="button"
|
||||
className={`version-item ${selectedId === snap.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedId(snap.id)}
|
||||
>
|
||||
<div>{snap.name || snap.type}</div>
|
||||
<div className="version-time">{new Date(snap.createdAt).toLocaleString()}</div>
|
||||
</button>
|
||||
))}
|
||||
{snapshots.length === 0 && <div className="sidebar-empty">{t('version.empty')}</div>}
|
||||
</div>
|
||||
<div className="version-diff">
|
||||
{selected && currentChapter
|
||||
? renderDiff(stripHtml(selected.content), stripHtml(currentChapter.content))
|
||||
: t('version.selectHint')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => void handleCreate()}>
|
||||
{t('version.create')}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={() => void handleRestore()}>
|
||||
{t('version.restore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { highlightTokenInEditor } from '@renderer/lib/editor-commands'
|
||||
|
||||
export function WordFreqPanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const target = useEditStore((s) => s.target)
|
||||
const [words, setWords] = useState<{ token: string; count: number }[]>([])
|
||||
|
||||
const chapterWords =
|
||||
target?.kind === 'chapter'
|
||||
? (chapters.find((c) => c.id === target.id)?.wordCount ?? 0)
|
||||
: chapters.reduce((s, c) => s + c.wordCount, 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookId) return
|
||||
const scope =
|
||||
target?.kind === 'chapter'
|
||||
? { kind: 'chapter' as const, chapterId: target.id }
|
||||
: { kind: 'book' as const }
|
||||
void ipcCall(() => window.electronAPI.wordfreq.analyze(bookId, scope)).then((r) => setWords(r.words))
|
||||
}, [bookId, target?.kind, target?.id, chapterWords])
|
||||
|
||||
return (
|
||||
<div className="wordfreq-panel" data-testid="wordfreq-panel">
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('wordfreq.hint')}</p>
|
||||
<div className="wordfreq-list">
|
||||
{words.map((w) => {
|
||||
const perThousand = chapterWords > 0 ? (w.count / chapterWords) * 1000 : 0
|
||||
const overuse = perThousand > 5
|
||||
return (
|
||||
<div
|
||||
key={w.token}
|
||||
className={`wordfreq-item ${overuse ? 'overuse' : ''}`}
|
||||
data-testid={`wordfreq-${w.token}`}
|
||||
onClick={() => highlightTokenInEditor(w.token)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span>{w.token}</span>
|
||||
<span>{w.count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => showToast(t('feature.comingSoon'))}
|
||||
>
|
||||
{t('wordfreq.aiNaming')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
|
||||
export const LandmarkMark = Mark.create({
|
||||
name: 'landmark',
|
||||
inclusive: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
label: { default: '' },
|
||||
landmarkType: { default: 'todo' }
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-landmark]' }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const label = HTMLAttributes.label ?? ''
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-landmark': 'true',
|
||||
class: 'landmark-mark',
|
||||
'data-testid': 'landmark-mark'
|
||||
}),
|
||||
`[TODO: ${label}]`
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Mark, mergeAttributes } from '@tiptap/core'
|
||||
|
||||
export const SettingMention = Mark.create({
|
||||
name: 'settingMention',
|
||||
inclusive: false,
|
||||
addAttributes() {
|
||||
return {
|
||||
id: { default: null },
|
||||
label: { default: null }
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-setting-mention]' }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const label = HTMLAttributes.label ?? ''
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-setting-mention': 'true',
|
||||
class: 'setting-mention',
|
||||
'data-testid': 'setting-mention'
|
||||
}),
|
||||
`@${label}`
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface LandmarkInsert {
|
||||
label: string
|
||||
landmarkType?: string
|
||||
}
|
||||
|
||||
let insertLandmarkFn: ((payload: LandmarkInsert) => void) | null = null
|
||||
let highlightTokenFn: ((token: string) => void) | null = null
|
||||
|
||||
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
|
||||
insertLandmarkFn = fn
|
||||
}
|
||||
|
||||
export function registerHighlightToken(fn: (token: string) => void): void {
|
||||
highlightTokenFn = fn
|
||||
}
|
||||
|
||||
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
|
||||
insertLandmarkFn?.(payload)
|
||||
}
|
||||
|
||||
export function highlightTokenInEditor(token: string): void {
|
||||
highlightTokenFn?.(token)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { OutlineItem } from '@shared/types'
|
||||
|
||||
export function buildOutlineTree(items: OutlineItem[]): OutlineItem[] {
|
||||
const byParent = new Map<string | null, OutlineItem[]>()
|
||||
for (const item of items) {
|
||||
const key = item.parentId
|
||||
if (!byParent.has(key)) byParent.set(key, [])
|
||||
byParent.get(key)!.push({ ...item, children: [] })
|
||||
}
|
||||
const attach = (parentId: string | null): OutlineItem[] => {
|
||||
const nodes = byParent.get(parentId) ?? []
|
||||
nodes.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
for (const node of nodes) {
|
||||
node.children = attach(node.id)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
return attach(null)
|
||||
}
|
||||
|
||||
export function flattenOutlineTree(items: OutlineItem[]): OutlineItem[] {
|
||||
const result: OutlineItem[] = []
|
||||
const walk = (nodes: OutlineItem[]): void => {
|
||||
for (const node of nodes) {
|
||||
result.push(node)
|
||||
if (node.children?.length) walk(node.children)
|
||||
}
|
||||
}
|
||||
walk(items)
|
||||
return result
|
||||
}
|
||||
|
||||
export function calcDeviation(expected: number | null, actual: number): number | null {
|
||||
if (!expected || expected <= 0) return null
|
||||
return Math.round(((actual - expected) / expected) * 100)
|
||||
}
|
||||
@@ -6,10 +6,14 @@ interface AppState {
|
||||
view: AppView
|
||||
sidebarPanel: 'chapters' | 'outline' | 'setting' | 'inspiration'
|
||||
rightPanel: 'ai' | 'knowledge' | 'wordfreq' | 'book-settings'
|
||||
versionModalOpen: boolean
|
||||
landmarksModalOpen: boolean
|
||||
toast: string | null
|
||||
setView: (view: AppView) => void
|
||||
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
||||
setRightPanel: (panel: AppState['rightPanel']) => void
|
||||
setVersionModalOpen: (open: boolean) => void
|
||||
setLandmarksModalOpen: (open: boolean) => void
|
||||
showToast: (message: string) => void
|
||||
clearToast: () => void
|
||||
}
|
||||
@@ -18,10 +22,14 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
view: 'home',
|
||||
sidebarPanel: 'chapters',
|
||||
rightPanel: 'ai',
|
||||
versionModalOpen: false,
|
||||
landmarksModalOpen: false,
|
||||
toast: null,
|
||||
setView: (view) => set({ view }),
|
||||
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
||||
setRightPanel: (rightPanel) => set({ rightPanel }),
|
||||
setVersionModalOpen: (versionModalOpen) => set({ versionModalOpen }),
|
||||
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
||||
showToast: (toast) => {
|
||||
set({ toast })
|
||||
setTimeout(() => set({ toast: null }), 2500)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import type { BookMeta, Volume, Chapter } from '@shared/types'
|
||||
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface BookState {
|
||||
@@ -7,6 +7,9 @@ interface BookState {
|
||||
currentBookId: string | null
|
||||
volumes: Volume[]
|
||||
chapters: Chapter[]
|
||||
outlines: OutlineItem[]
|
||||
settings: SettingEntry[]
|
||||
inspirations: InspirationEntry[]
|
||||
selectedChapterId: string | null
|
||||
activeVolumeId: string | null
|
||||
loadBooks: () => Promise<void>
|
||||
@@ -14,7 +17,17 @@ interface BookState {
|
||||
setSelectedChapter: (chapterId: string) => void
|
||||
setActiveVolume: (volumeId: string) => void
|
||||
refreshChapters: () => Promise<void>
|
||||
refreshBookData: () => Promise<void>
|
||||
updateChapterLocal: (chapter: Chapter) => void
|
||||
updateOutlineLocal: (item: OutlineItem) => void
|
||||
addOutlineLocal: (item: OutlineItem) => void
|
||||
removeOutlineLocal: (id: string) => void
|
||||
updateSettingLocal: (item: SettingEntry) => void
|
||||
addSettingLocal: (item: SettingEntry) => void
|
||||
removeSettingLocal: (id: string) => void
|
||||
updateInspirationLocal: (item: InspirationEntry) => void
|
||||
addInspirationLocal: (item: InspirationEntry) => void
|
||||
removeInspirationLocal: (id: string) => void
|
||||
}
|
||||
|
||||
export const useBookStore = create<BookState>((set, get) => ({
|
||||
@@ -22,6 +35,9 @@ export const useBookStore = create<BookState>((set, get) => ({
|
||||
currentBookId: null,
|
||||
volumes: [],
|
||||
chapters: [],
|
||||
outlines: [],
|
||||
settings: [],
|
||||
inspirations: [],
|
||||
selectedChapterId: null,
|
||||
activeVolumeId: null,
|
||||
loadBooks: async () => {
|
||||
@@ -40,6 +56,9 @@ export const useBookStore = create<BookState>((set, get) => ({
|
||||
currentBookId: bookId,
|
||||
volumes: result.volumes,
|
||||
chapters: result.chapters,
|
||||
outlines: result.outlines,
|
||||
settings: result.settings,
|
||||
inspirations: result.inspirations,
|
||||
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
|
||||
selectedChapterId: lastChapter
|
||||
})
|
||||
@@ -53,14 +72,56 @@ export const useBookStore = create<BookState>((set, get) => ({
|
||||
},
|
||||
setActiveVolume: (volumeId) => set({ activeVolumeId: volumeId }),
|
||||
refreshChapters: async () => {
|
||||
await get().refreshBookData()
|
||||
},
|
||||
refreshBookData: async () => {
|
||||
const bookId = get().currentBookId
|
||||
if (!bookId) return
|
||||
const result = await ipcCall(() => window.electronAPI.book.open(bookId))
|
||||
set({ volumes: result.volumes, chapters: result.chapters })
|
||||
set({
|
||||
volumes: result.volumes,
|
||||
chapters: result.chapters,
|
||||
outlines: result.outlines,
|
||||
settings: result.settings,
|
||||
inspirations: result.inspirations
|
||||
})
|
||||
},
|
||||
updateChapterLocal: (chapter) => {
|
||||
set((s) => ({
|
||||
chapters: s.chapters.map((c) => (c.id === chapter.id ? chapter : c))
|
||||
}))
|
||||
},
|
||||
updateOutlineLocal: (item) => {
|
||||
set((s) => ({
|
||||
outlines: s.outlines.map((o) => (o.id === item.id ? item : o))
|
||||
}))
|
||||
},
|
||||
addOutlineLocal: (item) => {
|
||||
set((s) => ({ outlines: [...s.outlines, item] }))
|
||||
},
|
||||
removeOutlineLocal: (id) => {
|
||||
set((s) => ({ outlines: s.outlines.filter((o) => o.id !== id) }))
|
||||
},
|
||||
updateSettingLocal: (item) => {
|
||||
set((s) => ({
|
||||
settings: s.settings.map((x) => (x.id === item.id ? item : x))
|
||||
}))
|
||||
},
|
||||
addSettingLocal: (item) => {
|
||||
set((s) => ({ settings: [...s.settings, item] }))
|
||||
},
|
||||
removeSettingLocal: (id) => {
|
||||
set((s) => ({ settings: s.settings.filter((x) => x.id !== id) }))
|
||||
},
|
||||
updateInspirationLocal: (item) => {
|
||||
set((s) => ({
|
||||
inspirations: s.inspirations.map((x) => (x.id === item.id ? item : x))
|
||||
}))
|
||||
},
|
||||
addInspirationLocal: (item) => {
|
||||
set((s) => ({ inspirations: [...s.inspirations, item] }))
|
||||
},
|
||||
removeInspirationLocal: (id) => {
|
||||
set((s) => ({ inspirations: s.inspirations.filter((x) => x.id !== id) }))
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from 'zustand'
|
||||
import type { EditTarget } from '@shared/types'
|
||||
|
||||
interface EditState {
|
||||
target: EditTarget | null
|
||||
switchTarget: (next: EditTarget) => Promise<void>
|
||||
setTarget: (next: EditTarget | null) => void
|
||||
}
|
||||
|
||||
let flushFn: (() => Promise<void>) | null = null
|
||||
|
||||
export function registerEditorFlush(fn: () => Promise<void>): void {
|
||||
flushFn = fn
|
||||
}
|
||||
|
||||
export async function flushEditSession(): Promise<void> {
|
||||
if (flushFn) await flushFn()
|
||||
}
|
||||
|
||||
export const useEditStore = create<EditState>((set) => ({
|
||||
target: null,
|
||||
switchTarget: async (next) => {
|
||||
await flushEditSession()
|
||||
set({ target: next })
|
||||
},
|
||||
setTarget: (next) => set({ target: next })
|
||||
}))
|
||||
@@ -0,0 +1,28 @@
|
||||
import { create } from 'zustand'
|
||||
import type { OutlineItem } from '@shared/types'
|
||||
|
||||
export type OutlineFilter = 'all' | 'uncovered' | 'covered'
|
||||
|
||||
interface OutlineState {
|
||||
compareMode: boolean
|
||||
filter: OutlineFilter
|
||||
compareSelectedId: string | null
|
||||
setCompareMode: (open: boolean) => void
|
||||
setFilter: (filter: OutlineFilter) => void
|
||||
setCompareSelectedId: (id: string | null) => void
|
||||
}
|
||||
|
||||
export function filterOutlines(items: OutlineItem[], filter: OutlineFilter): OutlineItem[] {
|
||||
if (filter === 'all') return items
|
||||
if (filter === 'uncovered') return items.filter((o) => !o.chapterId)
|
||||
return items.filter((o) => Boolean(o.chapterId))
|
||||
}
|
||||
|
||||
export const useOutlineStore = create<OutlineState>((set) => ({
|
||||
compareMode: false,
|
||||
filter: 'all',
|
||||
compareSelectedId: null,
|
||||
setCompareMode: (compareMode) => set({ compareMode }),
|
||||
setFilter: (filter) => set({ filter }),
|
||||
setCompareSelectedId: (compareSelectedId) => set({ compareSelectedId })
|
||||
}))
|
||||
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
type ReferenceTab = 'setting' | 'outline' | 'inspiration'
|
||||
|
||||
interface ReferenceState {
|
||||
open: boolean
|
||||
tab: ReferenceTab
|
||||
query: string
|
||||
pinnedIds: string[]
|
||||
setOpen: (open: boolean) => void
|
||||
setTab: (tab: ReferenceTab) => void
|
||||
setQuery: (query: string) => void
|
||||
togglePin: (id: string) => void
|
||||
}
|
||||
|
||||
export const useReferenceStore = create<ReferenceState>((set, get) => ({
|
||||
open: false,
|
||||
tab: 'setting',
|
||||
query: '',
|
||||
pinnedIds: [],
|
||||
setOpen: (open) => set({ open }),
|
||||
setTab: (tab) => set({ tab }),
|
||||
setQuery: (query) => set({ query }),
|
||||
togglePin: (id) => {
|
||||
const pinned = get().pinnedIds
|
||||
set({
|
||||
pinnedIds: pinned.includes(id) ? pinned.filter((x) => x !== id) : [...pinned, id]
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface SearchState {
|
||||
open: boolean
|
||||
query: string
|
||||
regex: boolean
|
||||
caseSensitive: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
setQuery: (query: string) => void
|
||||
setRegex: (regex: boolean) => void
|
||||
setCaseSensitive: (caseSensitive: boolean) => void
|
||||
}
|
||||
|
||||
export const useSearchStore = create<SearchState>((set) => ({
|
||||
open: false,
|
||||
query: '',
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
setOpen: (open) => set({ open }),
|
||||
setQuery: (query) => set({ query }),
|
||||
setRegex: (regex) => set({ regex }),
|
||||
setCaseSensitive: (caseSensitive) => set({ caseSensitive })
|
||||
}))
|
||||
@@ -33,6 +33,25 @@ input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
color: var(--input-text);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
color: var(--input-text);
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
.form-control--narrow {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.form-control--wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -52,7 +71,7 @@ textarea {
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
.window-close:hover {
|
||||
background: var(--red) !important;
|
||||
color: #fff !important;
|
||||
color: var(--text-on-accent) !important;
|
||||
}
|
||||
|
||||
#main-area {
|
||||
@@ -128,7 +128,7 @@
|
||||
.btn-large.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.book-grid {
|
||||
@@ -175,6 +175,7 @@
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@@ -212,7 +213,121 @@
|
||||
}
|
||||
|
||||
.sidebar-panel.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-empty {
|
||||
padding: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.outline-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.outline-item.uncovered {
|
||||
border-left-color: var(--orange);
|
||||
}
|
||||
|
||||
.outline-item.active,
|
||||
.setting-item.active,
|
||||
.inspiration-item.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.outline-warn {
|
||||
font-size: 10px;
|
||||
color: var(--orange);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outline-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outline-title-input {
|
||||
flex: 1;
|
||||
padding: 2px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.setting-group-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setting-group-count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
padding: 7px 12px 7px 24px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inspiration-item {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inspiration-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.inspiration-preview {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.vol-header {
|
||||
@@ -309,7 +424,9 @@
|
||||
}
|
||||
|
||||
.editor-content-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--editor-bg);
|
||||
}
|
||||
@@ -424,6 +541,10 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-heading {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -513,3 +634,354 @@
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.outline-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.outline-toolbar select {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.outline-toolbar .btn {
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#outline-compare-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compare-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compare-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compare-left {
|
||||
width: 35%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.compare-right {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.compare-chapter-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.compare-footer {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.compare-deviation-warn {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#reference-panel {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#reference-panel.open {
|
||||
width: 240px;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ref-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ref-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ref-tab {
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
font-size: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ref-tab.active {
|
||||
color: var(--accent-light);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ref-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ref-item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: grab;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ref-item-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ref-item-preview {
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
width: min(560px, 92vw);
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-content--wide {
|
||||
width: min(720px, 95vw);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.search-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 8px 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.search-history {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-result.active,
|
||||
.search-result:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sr-type {
|
||||
font-size: 10px;
|
||||
color: var(--accent-light);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.version-layout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.version-list {
|
||||
width: 180px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.version-item.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.version-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.version-diff {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.diff-view ins {
|
||||
background: color-mix(in srgb, var(--green) 25%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.diff-view del {
|
||||
background: color-mix(in srgb, var(--red) 20%, transparent);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.wordfreq-panel {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wordfreq-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wordfreq-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.wordfreq-item.overuse {
|
||||
background: color-mix(in srgb, var(--orange) 15%, transparent);
|
||||
}
|
||||
|
||||
.landmark-mark {
|
||||
background: color-mix(in srgb, var(--orange) 20%, transparent);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.setting-mention {
|
||||
color: var(--accent-light);
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mention-dropdown {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 8px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.mention-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mention-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
--text-secondary: #b8aca0;
|
||||
--text-muted: #786858;
|
||||
--text-dim: #584838;
|
||||
--text-on-accent: #ffffff;
|
||||
--input-text: var(--text-primary);
|
||||
--input-bg: var(--bg-surface);
|
||||
--input-border: var(--border);
|
||||
--accent: #c0504a;
|
||||
--accent-glow: rgba(192, 80, 74, 0.25);
|
||||
--accent-light: #d4706a;
|
||||
@@ -33,13 +37,18 @@
|
||||
--bg-tertiary: #242820;
|
||||
--bg-surface: #282c24;
|
||||
--bg-hover: #2e322a;
|
||||
--bg-active: #343830;
|
||||
--bg-card: #20241c;
|
||||
--text-primary: #e0e4d8;
|
||||
--text-secondary: #b0b8a8;
|
||||
--text-muted: #687858;
|
||||
--text-dim: #506040;
|
||||
--accent: #7a9a60;
|
||||
--accent-glow: rgba(122, 154, 96, 0.25);
|
||||
--accent-light: #98b878;
|
||||
--accent-dark: #5a7848;
|
||||
--border: #303828;
|
||||
--border-light: #404830;
|
||||
--editor-bg: #1a1e18;
|
||||
--editor-text: #e0e4d8;
|
||||
}
|
||||
@@ -48,13 +57,20 @@
|
||||
--bg-primary: #1a1e24;
|
||||
--bg-secondary: #1e2228;
|
||||
--bg-tertiary: #242830;
|
||||
--bg-surface: #282c34;
|
||||
--bg-hover: #2e3238;
|
||||
--bg-active: #343840;
|
||||
--bg-card: #202428;
|
||||
--text-primary: #d8dce4;
|
||||
--text-secondary: #a8b0b8;
|
||||
--text-muted: #687078;
|
||||
--text-dim: #505860;
|
||||
--accent: #8098b8;
|
||||
--accent-glow: rgba(128, 152, 184, 0.25);
|
||||
--accent-light: #a0b4d0;
|
||||
--accent-dark: #607890;
|
||||
--border: #2a3038;
|
||||
--border-light: #3a4048;
|
||||
--editor-bg: #1a1e24;
|
||||
--editor-text: #d8dce4;
|
||||
}
|
||||
@@ -63,14 +79,27 @@
|
||||
--bg-primary: #f4efe4;
|
||||
--bg-secondary: #faf6ee;
|
||||
--bg-tertiary: #efe8d8;
|
||||
--bg-surface: #f8f4ea;
|
||||
--bg-hover: #e8e0d0;
|
||||
--bg-active: #e0d8c8;
|
||||
--bg-card: #fcf6ec;
|
||||
--text-primary: #3a3028;
|
||||
--text-secondary: #6a5a48;
|
||||
--text-muted: #988878;
|
||||
--text-dim: #b8a898;
|
||||
--text-on-accent: #ffffff;
|
||||
--input-text: #3a3028;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #d0c8b8;
|
||||
--accent: #8b4513;
|
||||
--accent-glow: rgba(139, 69, 19, 0.15);
|
||||
--accent-light: #a86030;
|
||||
--accent-dark: #6b3509;
|
||||
--green: #507850;
|
||||
--orange: #a07040;
|
||||
--red: #a04030;
|
||||
--border: #d8d0c0;
|
||||
--border-light: #c8c0b0;
|
||||
--editor-bg: #fdf9f2;
|
||||
--editor-text: #3a3028;
|
||||
}
|
||||
@@ -79,12 +108,27 @@
|
||||
--bg-primary: #eff0f2;
|
||||
--bg-secondary: #f5f6f8;
|
||||
--bg-tertiary: #eaeaee;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-hover: #e2e3e8;
|
||||
--bg-active: #d8dae0;
|
||||
--bg-card: #f8f8fa;
|
||||
--text-primary: #2c3038;
|
||||
--text-secondary: #585c68;
|
||||
--text-muted: #787c88;
|
||||
--text-dim: #989ca8;
|
||||
--text-on-accent: #ffffff;
|
||||
--input-text: #2c3038;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #c8cad0;
|
||||
--accent: #6878a0;
|
||||
--accent-glow: rgba(104, 120, 160, 0.15);
|
||||
--accent-light: #8898b8;
|
||||
--accent-dark: #506080;
|
||||
--green: #508860;
|
||||
--orange: #a08050;
|
||||
--red: #a05050;
|
||||
--border: #d8dae0;
|
||||
--border-light: #c8cad0;
|
||||
--editor-bg: #f8f9fc;
|
||||
--editor-text: #2c3038;
|
||||
}
|
||||
@@ -93,12 +137,27 @@
|
||||
--bg-primary: #eef0e4;
|
||||
--bg-secondary: #f4f6ec;
|
||||
--bg-tertiary: #e6e8d8;
|
||||
--bg-surface: #fafcf4;
|
||||
--bg-hover: #dee2d0;
|
||||
--bg-active: #d4d8c8;
|
||||
--bg-card: #f2f4e8;
|
||||
--text-primary: #2d3a28;
|
||||
--text-secondary: #4a5840;
|
||||
--text-muted: #6a7860;
|
||||
--text-dim: #8a9880;
|
||||
--text-on-accent: #ffffff;
|
||||
--input-text: #2d3a28;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #c0c8b0;
|
||||
--accent: #688848;
|
||||
--accent-glow: rgba(104, 136, 72, 0.15);
|
||||
--accent-light: #88a868;
|
||||
--accent-dark: #506838;
|
||||
--green: #508848;
|
||||
--orange: #908050;
|
||||
--red: #985848;
|
||||
--border: #d0d8c0;
|
||||
--border-light: #c0c8b0;
|
||||
--editor-bg: #f6faf0;
|
||||
--editor-text: #2d3a28;
|
||||
}
|
||||
@@ -107,13 +166,20 @@
|
||||
--bg-primary: #1f1a16;
|
||||
--bg-secondary: #241e1a;
|
||||
--bg-tertiary: #2a2420;
|
||||
--bg-surface: #2e2824;
|
||||
--bg-hover: #342e28;
|
||||
--bg-active: #3a3428;
|
||||
--bg-card: #26201c;
|
||||
--text-primary: #e8dcc8;
|
||||
--text-secondary: #b8a890;
|
||||
--text-muted: #887868;
|
||||
--text-dim: #685848;
|
||||
--accent: #c88850;
|
||||
--accent-glow: rgba(200, 136, 80, 0.25);
|
||||
--accent-light: #d8a870;
|
||||
--accent-dark: #a87040;
|
||||
--border: #383028;
|
||||
--border-light: #484038;
|
||||
--editor-bg: #1f1a16;
|
||||
--editor-text: #e8dcc8;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user