feat: ship v1.2.0 Wave 1 foundation with book settings, chapter management, and focus mode
Deliver schema v9, book/chapter IPC extensions, BookSettingsTab, chapter meta/tags, AI session menu, focus mode and TTS, template context enrich, and Wave 1 E2E coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
-1
@@ -7,8 +7,9 @@ import schemaV5 from './schema-v5.sql?raw'
|
||||
import schemaV6 from './schema-v6.sql?raw'
|
||||
import schemaV7 from './schema-v7.sql?raw'
|
||||
import schemaV8 from './schema-v8.sql?raw'
|
||||
import schemaV9 from './schema-v9.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 8
|
||||
const CURRENT_VERSION = 9
|
||||
|
||||
function tryAlter(db: SqliteDb, sql: string): void {
|
||||
try {
|
||||
@@ -88,5 +89,13 @@ export function migrate(db: SqliteDb): void {
|
||||
if (current < 8) {
|
||||
db.exec(schemaV8)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(8, 'P5.3 P6.1 goals injection')
|
||||
current = 8
|
||||
}
|
||||
|
||||
if (current < 9) {
|
||||
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN published_at TEXT DEFAULT NULL')
|
||||
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN word_count_threshold INTEGER DEFAULT NULL')
|
||||
db.exec(schemaV9)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ChapterTag } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
function mapRow(row: Record<string, unknown>): ChapterTag {
|
||||
return {
|
||||
chapterId: row.chapter_id as string,
|
||||
tag: row.tag as string,
|
||||
color: (row.color as string) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
export class ChapterTagRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
list(chapterId: string): ChapterTag[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare('SELECT * FROM chapter_tags WHERE chapter_id = ? ORDER BY tag')
|
||||
.all(chapterId) as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
listAll(): ChapterTag[] {
|
||||
return (
|
||||
this.db.prepare('SELECT * FROM chapter_tags ORDER BY chapter_id, tag').all() as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
set(chapterId: string, tag: string, color = ''): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO chapter_tags (chapter_id, tag, color) VALUES (?, ?, ?)
|
||||
ON CONFLICT(chapter_id, tag) DO UPDATE SET color = excluded.color`
|
||||
)
|
||||
.run(chapterId, tag, color)
|
||||
}
|
||||
|
||||
remove(chapterId: string, tag: string): void {
|
||||
this.db.prepare('DELETE FROM chapter_tags WHERE chapter_id = ? AND tag = ?').run(chapterId, tag)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@ function mapRow(row: Record<string, unknown>): Chapter {
|
||||
cursorOffset: (row.cursor_offset as number) ?? 0,
|
||||
origin: ((row.origin as ChapterOrigin) ?? 'manual') as ChapterOrigin,
|
||||
publishStatus: ((row.publish_status as PublishStatus) ?? 'draft') as PublishStatus,
|
||||
povCharacterId: (row.pov_character_id as string) || null
|
||||
povCharacterId: (row.pov_character_id as string) || null,
|
||||
summary: (row.summary as string) ?? '',
|
||||
storyTime: (row.story_time as string) ?? null,
|
||||
publishedAt: (row.published_at as string) ?? null,
|
||||
wordCountThreshold: (row.word_count_threshold as number) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +72,10 @@ export class ChapterRepository {
|
||||
cursorOffset: number
|
||||
publishStatus: PublishStatus
|
||||
povCharacterId: string | null
|
||||
summary: string
|
||||
storyTime: string | null
|
||||
publishedAt: string | null
|
||||
wordCountThreshold: number | null
|
||||
}>
|
||||
): Chapter {
|
||||
const existing = this.get(id)
|
||||
@@ -82,7 +90,9 @@ export class ChapterRepository {
|
||||
.prepare(
|
||||
`UPDATE chapters SET
|
||||
title = ?, content = ?, status = ?, cursor_offset = ?,
|
||||
publish_status = ?, pov_character_id = ?, word_count = ?, updated_at = datetime('now')
|
||||
publish_status = ?, pov_character_id = ?, word_count = ?,
|
||||
summary = ?, story_time = ?, published_at = ?, word_count_threshold = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
@@ -93,13 +103,23 @@ export class ChapterRepository {
|
||||
patch.publishStatus ?? existing.publishStatus ?? 'draft',
|
||||
povCharacterId,
|
||||
wordCount,
|
||||
patch.summary ?? existing.summary ?? '',
|
||||
patch.storyTime !== undefined ? patch.storyTime : (existing.storyTime ?? null),
|
||||
patch.publishedAt !== undefined ? patch.publishedAt : (existing.publishedAt ?? null),
|
||||
patch.wordCountThreshold !== undefined
|
||||
? patch.wordCountThreshold
|
||||
: (existing.wordCountThreshold ?? null),
|
||||
id
|
||||
)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
setPublishStatus(id: string, publishStatus: PublishStatus): Chapter {
|
||||
return this.update(id, { publishStatus })
|
||||
const patch: Parameters<ChapterRepository['update']>[1] = { publishStatus }
|
||||
if (publishStatus === 'published') {
|
||||
patch.publishedAt = new Date().toISOString()
|
||||
}
|
||||
return this.update(id, patch)
|
||||
}
|
||||
|
||||
countByPublishStatus(status: PublishStatus): number {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS chapter_tags (
|
||||
chapter_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
color TEXT DEFAULT '',
|
||||
PRIMARY KEY (chapter_id, tag),
|
||||
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
@@ -19,6 +20,15 @@ import { wrap } from '../result'
|
||||
|
||||
const activeChats = new Map<string, AbortController>()
|
||||
|
||||
function formatSessionMarkdown(title: string, messages: { role: string; content: string; createdAt: string }[]): string {
|
||||
const lines = [`# ${title || 'AI 会话'}`, '']
|
||||
for (const msg of messages) {
|
||||
const label = msg.role === 'user' ? '用户' : msg.role === 'assistant' ? '助手' : msg.role
|
||||
lines.push(`## ${label}`, '', msg.content, '', `_${msg.createdAt}_`, '')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function registerAiHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService,
|
||||
@@ -67,6 +77,39 @@ export function registerAiHandlers(
|
||||
wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_SESSION_EXPORT,
|
||||
async (
|
||||
_e,
|
||||
{
|
||||
bookId,
|
||||
sessionId,
|
||||
format = 'markdown'
|
||||
}: { bookId: string; sessionId: string; format?: 'markdown' | 'txt' }
|
||||
) =>
|
||||
wrap(async () => {
|
||||
const repo = registry.getAiSessionRepo(bookId)
|
||||
const session = repo.get(sessionId)
|
||||
if (!session) throw new Error('AI session not found')
|
||||
const messages = repo.listMessages(sessionId)
|
||||
const text =
|
||||
format === 'markdown'
|
||||
? formatSessionMarkdown(session.title, messages)
|
||||
: messages
|
||||
.map((m) => `[${m.role}] ${m.createdAt}\n${m.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
const ext = format === 'markdown' ? 'md' : 'txt'
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
defaultPath: `${session.title || 'ai-session'}.${ext}`,
|
||||
filters: [{ name: format === 'markdown' ? 'Markdown' : 'Text', extensions: [ext] }]
|
||||
})
|
||||
if (canceled || !filePath) return null
|
||||
writeFileSync(filePath, text, 'utf8')
|
||||
return filePath
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_BUILD_CONTEXT,
|
||||
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) =>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import { BookPrefsRepository } from '../../db/repositories/book-prefs.repo'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerBookPrefsHandlers(registry: BookRegistryService): void {
|
||||
ipcMain.handle(
|
||||
IPC.BOOK_PREFS_GET,
|
||||
(_event, { bookId, key }: { bookId: string; key: string }) =>
|
||||
wrap(() => new BookPrefsRepository(registry.getDb(bookId)).get(key))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.BOOK_PREFS_SET,
|
||||
(_event, { bookId, key, value }: { bookId: string; key: string; value: string }) =>
|
||||
wrap(() => {
|
||||
new BookPrefsRepository(registry.getDb(bookId)).set(key, value)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { ipcMain, dialog, app } from 'electron'
|
||||
import { copyFileSync, mkdirSync } from 'fs'
|
||||
import { extname, join } from 'path'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { BookMeta, CreateBookParams } from '../../../shared/types'
|
||||
import type { CreateBookParams, UpdateBookMetaParams } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import type { WritingSessionService } from '../../services/writing-session.service'
|
||||
import { wrap } from '../result'
|
||||
@@ -35,17 +37,24 @@ export function registerBookHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.BOOK_UPDATE_META,
|
||||
(
|
||||
_event,
|
||||
{
|
||||
bookId,
|
||||
...patch
|
||||
}: {
|
||||
bookId: string
|
||||
lastChapterId?: string | null
|
||||
lastOpenedAt?: string
|
||||
status?: BookMeta['status']
|
||||
}
|
||||
) => wrap(() => registry.updateMeta(bookId, patch))
|
||||
(_event, { bookId, ...patch }: { bookId: string } & UpdateBookMetaParams) =>
|
||||
wrap(() => registry.updateMeta(bookId, patch))
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.BOOK_PICK_COVER, (_event, { bookId }: { bookId: string }) =>
|
||||
wrap(async () => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'webp'] }],
|
||||
properties: ['openFile']
|
||||
})
|
||||
if (canceled || !filePaths[0]) return null
|
||||
const src = filePaths[0]
|
||||
const ext = extname(src).toLowerCase() || '.jpg'
|
||||
const coversDir = join(app.getPath('userData'), 'covers')
|
||||
mkdirSync(coversDir, { recursive: true })
|
||||
const dest = join(coversDir, `${bookId}${ext}`)
|
||||
copyFileSync(src, dest)
|
||||
return registry.updateMeta(bookId, { coverPath: dest })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,12 @@ export function registerChapterHandlers(
|
||||
content,
|
||||
status,
|
||||
cursorOffset,
|
||||
povCharacterId
|
||||
povCharacterId,
|
||||
summary,
|
||||
storyTime,
|
||||
wordCountThreshold,
|
||||
publishStatus,
|
||||
publishedAt
|
||||
}: {
|
||||
bookId: string
|
||||
chapterId: string
|
||||
@@ -84,6 +89,11 @@ export function registerChapterHandlers(
|
||||
status?: ChapterStatus
|
||||
cursorOffset?: number
|
||||
povCharacterId?: string | null
|
||||
summary?: string
|
||||
storyTime?: string | null
|
||||
wordCountThreshold?: number | null
|
||||
publishStatus?: PublishStatus
|
||||
publishedAt?: string | null
|
||||
}
|
||||
) =>
|
||||
wrap(() => {
|
||||
@@ -92,7 +102,12 @@ export function registerChapterHandlers(
|
||||
content,
|
||||
status,
|
||||
cursorOffset,
|
||||
povCharacterId
|
||||
povCharacterId,
|
||||
summary,
|
||||
storyTime,
|
||||
wordCountThreshold,
|
||||
publishStatus,
|
||||
publishedAt
|
||||
})
|
||||
ftsSync.upsert(
|
||||
registry.getDb(bookId),
|
||||
@@ -146,4 +161,34 @@ export function registerChapterHandlers(
|
||||
{ bookId, chapterId, publishStatus }: { bookId: string; chapterId: string; publishStatus: PublishStatus }
|
||||
) => wrap(() => registry.getChapterRepo(bookId).setPublishStatus(chapterId, publishStatus))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.CHAPTER_TAG_LIST,
|
||||
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
|
||||
wrap(() => registry.getChapterTagRepo(bookId).list(chapterId))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.CHAPTER_TAG_SET,
|
||||
(
|
||||
_event,
|
||||
{
|
||||
bookId,
|
||||
chapterId,
|
||||
tag,
|
||||
color
|
||||
}: { bookId: string; chapterId: string; tag: string; color?: string }
|
||||
) =>
|
||||
wrap(() => {
|
||||
registry.getChapterTagRepo(bookId).set(chapterId, tag, color ?? '')
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.CHAPTER_TAG_REMOVE,
|
||||
(_event, { bookId, chapterId, tag }: { bookId: string; chapterId: string; tag: string }) =>
|
||||
wrap(() => {
|
||||
registry.getChapterTagRepo(bookId).remove(chapterId, tag)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { WritingSessionRepository } from '../db/repositories/writing-session.rep
|
||||
import { WritingSessionService } from '../services/writing-session.service'
|
||||
import { registerSettingsHandlers } from './handlers/settings.handler'
|
||||
import { registerBookHandlers } from './handlers/book.handler'
|
||||
import { registerBookPrefsHandlers } from './handlers/book-prefs.handler'
|
||||
import { registerChapterHandlers } from './handlers/chapter.handler'
|
||||
import { registerShortcutHandlers } from './handlers/shortcut.handler'
|
||||
import { registerOutlineHandlers } from './handlers/outline.handler'
|
||||
@@ -69,6 +70,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
|
||||
registerSettingsHandlers(settings)
|
||||
registerBookHandlers(books, writingSessions)
|
||||
registerBookPrefsHandlers(books)
|
||||
registerChapterHandlers(books, writingLogs, writingSessions)
|
||||
registerShortcutHandlers(shortcutManager)
|
||||
registerOutlineHandlers(books)
|
||||
|
||||
@@ -11,7 +11,8 @@ import { AiSessionRepository } from '../db/repositories/ai-session.repo'
|
||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
|
||||
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
|
||||
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
|
||||
import { ChapterTagRepository } from '../db/repositories/chapter-tag.repo'
|
||||
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume, UpdateBookMetaParams } from '../../shared/types'
|
||||
|
||||
interface RegistryFile {
|
||||
books: BookMeta[]
|
||||
@@ -90,7 +91,7 @@ export class BookRegistryService {
|
||||
this.writeRegistry(registry)
|
||||
}
|
||||
|
||||
updateMeta(bookId: string, patch: Partial<Pick<BookMeta, 'lastOpenedAt' | 'lastChapterId' | 'status'>>): BookMeta {
|
||||
updateMeta(bookId: string, patch: UpdateBookMetaParams): BookMeta {
|
||||
const registry = this.readRegistry()
|
||||
const idx = registry.books.findIndex((b) => b.id === bookId)
|
||||
if (idx === -1) throw new Error('Book not found')
|
||||
@@ -162,6 +163,10 @@ export class BookRegistryService {
|
||||
getBookmarkRepo(bookId: string): BookmarkRepository {
|
||||
return new BookmarkRepository(this.getDb(bookId))
|
||||
}
|
||||
|
||||
getChapterTagRepo(bookId: string): ChapterTagRepository {
|
||||
return new ChapterTagRepository(this.getDb(bookId))
|
||||
}
|
||||
}
|
||||
|
||||
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
|
||||
|
||||
@@ -52,7 +52,9 @@ export class CockpitService {
|
||||
}
|
||||
|
||||
shouldShowOnOpen(): boolean {
|
||||
return new BookPrefsRepository(this.db).get('cockpitSeen') !== 'true'
|
||||
const prefs = new BookPrefsRepository(this.db)
|
||||
if (prefs.get('alwaysShowCockpit') === 'true') return true
|
||||
return prefs.get('cockpitSeen') !== 'true'
|
||||
}
|
||||
|
||||
markSeen(): void {
|
||||
|
||||
+31
-2
@@ -36,6 +36,8 @@ import type {
|
||||
Snapshot,
|
||||
SnapshotType,
|
||||
UpdateChapterParams,
|
||||
UpdateBookMetaParams,
|
||||
ChapterTag,
|
||||
Volume,
|
||||
WordFreqResult,
|
||||
KnowledgeEntry,
|
||||
@@ -80,8 +82,16 @@ const electronAPI = {
|
||||
ipcRenderer.invoke(IPC.BOOK_OPEN, { bookId }),
|
||||
updateMeta: (
|
||||
bookId: string,
|
||||
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
|
||||
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch })
|
||||
patch: UpdateBookMetaParams
|
||||
): Promise<IpcResult<BookMeta>> => ipcRenderer.invoke(IPC.BOOK_UPDATE_META, { bookId, ...patch }),
|
||||
pickCover: (bookId: string): Promise<IpcResult<BookMeta | null>> =>
|
||||
ipcRenderer.invoke(IPC.BOOK_PICK_COVER, { bookId })
|
||||
},
|
||||
bookPrefs: {
|
||||
get: (bookId: string, key: string): Promise<IpcResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC.BOOK_PREFS_GET, { bookId, key }),
|
||||
set: (bookId: string, key: string, value: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.BOOK_PREFS_SET, { bookId, key, value })
|
||||
},
|
||||
volume: {
|
||||
create: (bookId: string, name: string): Promise<IpcResult<Volume>> =>
|
||||
@@ -120,6 +130,19 @@ const electronAPI = {
|
||||
): Promise<IpcResult<Chapter>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_SET_PUBLISH_STATUS, { bookId, chapterId, publishStatus })
|
||||
},
|
||||
chapterTag: {
|
||||
list: (bookId: string, chapterId: string): Promise<IpcResult<ChapterTag[]>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_TAG_LIST, { bookId, chapterId }),
|
||||
set: (
|
||||
bookId: string,
|
||||
chapterId: string,
|
||||
tag: string,
|
||||
color?: string
|
||||
): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_TAG_SET, { bookId, chapterId, tag, color }),
|
||||
remove: (bookId: string, chapterId: string, tag: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_TAG_REMOVE, { bookId, chapterId, tag })
|
||||
},
|
||||
outline: {
|
||||
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
|
||||
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
|
||||
@@ -277,6 +300,12 @@ const electronAPI = {
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }),
|
||||
sessionDelete: (bookId: string, sessionId: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }),
|
||||
sessionExport: (
|
||||
bookId: string,
|
||||
sessionId: string,
|
||||
format?: 'markdown' | 'txt'
|
||||
): Promise<IpcResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_EXPORT, { bookId, sessionId, format }),
|
||||
messageList: (bookId: string, sessionId: string): Promise<IpcResult<AiMessage[]>> =>
|
||||
ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }),
|
||||
chat: (
|
||||
|
||||
+33
-2
@@ -18,10 +18,14 @@ import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { SearchModal } from '@renderer/components/search/SearchModal'
|
||||
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
||||
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
||||
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
|
||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { getEditorTextForTts } from '@renderer/lib/editor-commands'
|
||||
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
@@ -30,6 +34,10 @@ function AppInner(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const view = useAppStore((s) => s.view)
|
||||
const toast = useAppStore((s) => s.toast)
|
||||
const focusMode = useAppStore((s) => s.focusMode)
|
||||
const namingCheckOpen = useAppStore((s) => s.namingCheckOpen)
|
||||
const setFocusMode = useAppStore((s) => s.setFocusMode)
|
||||
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
|
||||
@@ -158,27 +166,50 @@ function AppInner(): React.JSX.Element {
|
||||
useExportStore.getState().openModal()
|
||||
return
|
||||
}
|
||||
if (action === 'focusMode') {
|
||||
if (view !== 'editor') return
|
||||
setFocusMode(!useAppStore.getState().focusMode)
|
||||
return
|
||||
}
|
||||
if (action === 'toggleTTS') {
|
||||
if (view !== 'editor') return
|
||||
if (isSpeaking()) {
|
||||
stopSpeaking()
|
||||
showToast(t('tts.stopped'))
|
||||
return
|
||||
}
|
||||
const text = getEditorTextForTts()
|
||||
if (!text.trim()) {
|
||||
showToast(t('tts.noText'))
|
||||
return
|
||||
}
|
||||
speakText(text)
|
||||
showToast(t('tts.started'))
|
||||
return
|
||||
}
|
||||
showToast(t('feature.comingSoon'))
|
||||
})
|
||||
return unsub
|
||||
}, [view, activeTabId, openTabs, openBook, setView, showToast, t])
|
||||
}, [view, activeTabId, openTabs, openBook, setView, setFocusMode, showToast, t])
|
||||
|
||||
const handleOnboardingComplete = (): void => {
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="app">
|
||||
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
|
||||
<TopBar />
|
||||
<div id="main-area">
|
||||
{view === 'home' && <HomePage />}
|
||||
{view === 'editor' && <EditorLayout />}
|
||||
{view === 'settings' && <SettingsPage />}
|
||||
</div>
|
||||
<FocusModeOverlay />
|
||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||
<SearchModal />
|
||||
<InspirationModal />
|
||||
<ImportBookModal />
|
||||
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
|
||||
import { AutoPanel } from '@renderer/components/ai/AutoPanel'
|
||||
import { WizardPanel } from '@renderer/components/ai/WizardPanel'
|
||||
import { AiSessionMenu } from '@renderer/components/ai/AiSessionMenu'
|
||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||
import { useAutoStore } from '@renderer/stores/useAutoStore'
|
||||
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||||
@@ -75,7 +76,7 @@ export function AiPanel(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (bookId) {
|
||||
void loadSessions(bookId)
|
||||
void loadSessions(bookId, true)
|
||||
void checkGate(bookId)
|
||||
void checkAutoGate(bookId)
|
||||
void checkWizardGate(bookId)
|
||||
@@ -176,6 +177,10 @@ export function AiPanel(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
const activeSessions = sessions.filter((s) => !s.archived)
|
||||
const archivedSessions = sessions.filter((s) => s.archived)
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
|
||||
@@ -186,12 +191,26 @@ export function AiPanel(): React.JSX.Element {
|
||||
onChange={(e) => void selectSession(e.target.value)}
|
||||
>
|
||||
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
|
||||
{sessions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title || t('ai.untitledSession')}
|
||||
</option>
|
||||
))}
|
||||
{activeSessions.length > 0 && (
|
||||
<optgroup label={t('ai.session.active')}>
|
||||
{activeSessions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title || t('ai.untitledSession')}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{archivedSessions.length > 0 && (
|
||||
<optgroup label={t('ai.session.archived')}>
|
||||
{archivedSessions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title || t('ai.untitledSession')}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
<AiSessionMenu bookId={bookId} session={activeSession} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AiSession } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
|
||||
interface AiSessionMenuProps {
|
||||
bookId: string
|
||||
session: AiSession | null
|
||||
}
|
||||
|
||||
export function AiSessionMenu({ bookId, session }: AiSessionMenuProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const loadSessions = useAiStore((s) => s.loadSessions)
|
||||
const selectSession = useAiStore((s) => s.selectSession)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = (): void => {
|
||||
setOpen(false)
|
||||
setExportOpen(false)
|
||||
}
|
||||
window.addEventListener('click', close)
|
||||
return () => window.removeEventListener('click', close)
|
||||
}, [open])
|
||||
|
||||
if (!session) return null
|
||||
|
||||
const handleRename = async (): Promise<void> => {
|
||||
setOpen(false)
|
||||
const title = window.prompt(t('ai.session.renamePrompt'), session.title)
|
||||
if (!title?.trim() || title.trim() === session.title) return
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, session.id, { title: title.trim() })
|
||||
)
|
||||
await loadSessions(bookId, true)
|
||||
}
|
||||
|
||||
const handleArchiveToggle = async (): Promise<void> => {
|
||||
setOpen(false)
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, session.id, { archived: !session.archived })
|
||||
)
|
||||
await loadSessions(bookId, true)
|
||||
}
|
||||
|
||||
const handleExport = async (format: 'markdown' | 'txt'): Promise<void> => {
|
||||
setOpen(false)
|
||||
setExportOpen(false)
|
||||
await ipcCall(() => window.electronAPI.ai.sessionExport(bookId, session.id, format))
|
||||
}
|
||||
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
setOpen(false)
|
||||
if (!window.confirm(t('ai.session.deleteConfirm', { title: session.title || t('ai.untitledSession') }))) {
|
||||
return
|
||||
}
|
||||
await ipcCall(() => window.electronAPI.ai.sessionDelete(bookId, session.id))
|
||||
await loadSessions(bookId, true)
|
||||
const next = useAiStore.getState().sessions[0]
|
||||
if (next) await selectSession(next.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-session-menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn ai-session-menu-btn"
|
||||
data-testid="ai-session-menu-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen((v) => !v)
|
||||
}}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{open && (
|
||||
<div className="context-menu ai-session-menu" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" onClick={() => void handleRename()}>
|
||||
{t('ai.session.rename')}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleArchiveToggle()}>
|
||||
{session.archived ? t('ai.session.unarchive') : t('ai.session.archive')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExportOpen((v) => !v)
|
||||
}}
|
||||
>
|
||||
{t('ai.session.export')} ▾
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<div className="ai-session-export-submenu">
|
||||
<button type="button" onClick={() => void handleExport('markdown')}>
|
||||
{t('ai.session.exportMd')}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleExport('txt')}>
|
||||
{t('ai.session.exportTxt')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="danger" onClick={() => void handleDelete()}>
|
||||
{t('ai.session.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useBookPrefsStore } from '@renderer/stores/useBookPrefsStore'
|
||||
|
||||
export function BookSettingsTab(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const { currentBookId, books, loadBooks } = useBookStore()
|
||||
const { alwaysShowCockpit, load, setAlwaysShowCockpit } = useBookPrefsStore()
|
||||
|
||||
const book = useMemo(
|
||||
() => books.find((b) => b.id === currentBookId) ?? null,
|
||||
[books, currentBookId]
|
||||
)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState('玄幻')
|
||||
const [targetWordCount, setTargetWordCount] = useState('')
|
||||
const [synopsis, setSynopsis] = useState('')
|
||||
const [coverPath, setCoverPath] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!book) return
|
||||
setName(book.name)
|
||||
setCategory(book.category || '玄幻')
|
||||
setTargetWordCount(book.targetWordCount != null ? String(book.targetWordCount) : '')
|
||||
setSynopsis(book.synopsis ?? '')
|
||||
setCoverPath(book.coverPath ?? null)
|
||||
}, [book])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentBookId) void load(currentBookId)
|
||||
}, [currentBookId, load])
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!currentBookId || !name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.book.updateMeta(currentBookId, {
|
||||
name: name.trim(),
|
||||
category,
|
||||
targetWordCount: targetWordCount ? Number(targetWordCount) : null,
|
||||
synopsis: synopsis.trim(),
|
||||
coverPath
|
||||
})
|
||||
)
|
||||
setCoverPath(updated.coverPath ?? null)
|
||||
await loadBooks()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePickCover = async (): Promise<void> => {
|
||||
if (!currentBookId) return
|
||||
const updated = await ipcCall(() => window.electronAPI.book.pickCover(currentBookId))
|
||||
if (updated) {
|
||||
setCoverPath(updated.coverPath ?? null)
|
||||
await loadBooks()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
if (!currentBookId || !book) return
|
||||
if (!window.confirm(t('bookSettings.deleteConfirm', { name: book.name }))) return
|
||||
await ipcCall(() => window.electronAPI.book.delete(currentBookId))
|
||||
await loadBooks()
|
||||
setView('home')
|
||||
}
|
||||
|
||||
if (!currentBookId || !book) {
|
||||
return <div className="placeholder-box">{t('ai.noBook')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="book-settings-tab" data-testid="book-settings-tab">
|
||||
<h3 className="settings-subheading">{t('bookSettings.title')}</h3>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t('dialog.newBook.name')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="book-settings-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t('dialog.newBook.category')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="book-settings-category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="玄幻">玄幻</option>
|
||||
<option value="仙侠">仙侠</option>
|
||||
<option value="科幻">科幻</option>
|
||||
<option value="言情">言情</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t('dialog.newBook.target')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control--wide"
|
||||
data-testid="book-settings-target"
|
||||
value={targetWordCount}
|
||||
onChange={(e) => setTargetWordCount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t('bookSettings.synopsis')}</label>
|
||||
<textarea
|
||||
className="form-control form-control--wide"
|
||||
data-testid="book-settings-synopsis"
|
||||
rows={4}
|
||||
value={synopsis}
|
||||
onChange={(e) => setSynopsis(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">{t('bookSettings.cover')}</label>
|
||||
<div className="book-settings-cover-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
data-testid="book-settings-pick-cover"
|
||||
onClick={() => void handlePickCover()}
|
||||
>
|
||||
{t('bookSettings.pickCover')}
|
||||
</button>
|
||||
<span className="book-settings-cover-path">
|
||||
{coverPath ? coverPath.replace(/^.*[\\/]/, '') : t('bookSettings.noCover')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="book-settings-always-cockpit"
|
||||
checked={alwaysShowCockpit}
|
||||
onChange={(e) => void setAlwaysShowCockpit(currentBookId, e.target.checked)}
|
||||
/>
|
||||
{t('bookSettings.alwaysShowCockpit')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="book-settings-save"
|
||||
disabled={!name.trim() || saving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{t('dialog.save')}
|
||||
</button>
|
||||
|
||||
<div className="book-settings-danger" data-testid="book-settings-danger">
|
||||
<h4>{t('bookSettings.dangerZone')}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
data-testid="book-settings-delete"
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
{t('bookSettings.deleteBook')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Chapter, ChapterTag } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
|
||||
interface ChapterMetaPanelProps {
|
||||
open: boolean
|
||||
chapter: Chapter | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ChapterMetaPanel({
|
||||
open,
|
||||
chapter,
|
||||
onClose
|
||||
}: ChapterMetaPanelProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const currentBookId = useBookStore((s) => s.currentBookId)
|
||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||
|
||||
const [summary, setSummary] = useState('')
|
||||
const [storyTime, setStoryTime] = useState('')
|
||||
const [wordCountThreshold, setWordCountThreshold] = useState('')
|
||||
const [tags, setTags] = useState<ChapterTag[]>([])
|
||||
const [newTag, setNewTag] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !chapter || !currentBookId) return
|
||||
setSummary(chapter.summary ?? '')
|
||||
setStoryTime(chapter.storyTime ?? '')
|
||||
setWordCountThreshold(
|
||||
chapter.wordCountThreshold != null ? String(chapter.wordCountThreshold) : ''
|
||||
)
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.chapterTag.list(currentBookId, chapter.id)
|
||||
).then(setTags)
|
||||
}, [open, chapter, currentBookId])
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!currentBookId || !chapter) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.chapter.update({
|
||||
bookId: currentBookId,
|
||||
chapterId: chapter.id,
|
||||
summary: summary.trim(),
|
||||
storyTime: storyTime.trim() || null,
|
||||
wordCountThreshold: wordCountThreshold ? Number(wordCountThreshold) : null
|
||||
})
|
||||
)
|
||||
updateChapterLocal(updated)
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTag = async (): Promise<void> => {
|
||||
const tag = newTag.trim()
|
||||
if (!currentBookId || !chapter || !tag) return
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.chapterTag.set(currentBookId, chapter.id, tag)
|
||||
)
|
||||
setTags((prev) => [...prev.filter((t) => t.tag !== tag), { chapterId: chapter.id, tag, color: '' }])
|
||||
setNewTag('')
|
||||
}
|
||||
|
||||
const handleRemoveTag = async (tag: string): Promise<void> => {
|
||||
if (!currentBookId || !chapter) return
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.chapterTag.remove(currentBookId, chapter.id, tag)
|
||||
)
|
||||
setTags((prev) => prev.filter((t) => t.tag !== tag))
|
||||
}
|
||||
|
||||
if (!open || !chapter) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true" data-testid="chapter-meta-panel">
|
||||
<div className="modal-content chapter-meta-modal">
|
||||
<div className="modal-header">
|
||||
<h3>{t('chapterMeta.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label className="form-label">{t('chapterMeta.summary')}</label>
|
||||
<textarea
|
||||
className="form-control form-control--wide"
|
||||
data-testid="chapter-meta-summary"
|
||||
rows={3}
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
/>
|
||||
|
||||
<label className="form-label">{t('chapterMeta.storyTime')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="chapter-meta-story-time"
|
||||
value={storyTime}
|
||||
onChange={(e) => setStoryTime(e.target.value)}
|
||||
/>
|
||||
|
||||
<label className="form-label">{t('chapterMeta.wordThreshold')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control form-control--wide"
|
||||
data-testid="chapter-meta-threshold"
|
||||
value={wordCountThreshold}
|
||||
onChange={(e) => setWordCountThreshold(e.target.value)}
|
||||
/>
|
||||
|
||||
{chapter.publishStatus === 'published' && chapter.publishedAt && (
|
||||
<p className="chapter-meta-published" data-testid="chapter-meta-published">
|
||||
{t('chapterMeta.publishedAt', {
|
||||
date: new Date(chapter.publishedAt).toLocaleString('zh-CN')
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="form-label">{t('chapterMeta.tags')}</label>
|
||||
<div className="chapter-meta-tags">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag.tag} className="tag-chip" data-testid={`chapter-tag-${tag.tag}`}>
|
||||
{tag.tag}
|
||||
<button type="button" onClick={() => void handleRemoveTag(tag.tag)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="chapter-meta-tag-add">
|
||||
<input
|
||||
className="form-control"
|
||||
data-testid="chapter-meta-tag-input"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
placeholder={t('chapterMeta.tagPlaceholder')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void handleAddTag()
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleAddTag()}>
|
||||
{t('chapterMeta.addTag')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="chapter-meta-save"
|
||||
disabled={saving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{t('dialog.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { mergeChapterTemplates } from '@shared/builtin-templates'
|
||||
import {
|
||||
bodyPlainToChapterHtml,
|
||||
replaceChapterTemplate,
|
||||
type TemplateContext
|
||||
buildTemplateContext,
|
||||
replaceChapterTemplate
|
||||
} from '@shared/chapter-template'
|
||||
import type { ChapterTemplate } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
@@ -30,6 +30,9 @@ export function TemplateQuickCreateModal({
|
||||
activeVolumeId,
|
||||
volumes,
|
||||
chapters,
|
||||
outlines,
|
||||
settings,
|
||||
selectedChapterId,
|
||||
refreshChapters,
|
||||
setSelectedChapter
|
||||
} = useBookStore()
|
||||
@@ -52,33 +55,22 @@ export function TemplateQuickCreateModal({
|
||||
|
||||
const selectedTemplate = templates.find((tpl) => tpl.id === selectedId) ?? templates[0]
|
||||
|
||||
const buildContext = (title: string): TemplateContext => {
|
||||
const volChapters = chapters
|
||||
.filter((c) => c.volumeId === activeVolumeId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const chapterNumber = volChapters.length + 1
|
||||
const volumeName = volumes.find((v) => v.id === activeVolumeId)?.name ?? ''
|
||||
const previousChapterTitle =
|
||||
volChapters.length > 0 ? (volChapters[volChapters.length - 1]?.title ?? '') : ''
|
||||
return {
|
||||
chapterNumber,
|
||||
chapterTitle: title,
|
||||
penName,
|
||||
date: new Date().toLocaleDateString('zh-CN'),
|
||||
volumeName,
|
||||
previousChapterTitle,
|
||||
outlineItem: '',
|
||||
characterList: ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!currentBookId || !activeVolumeId || !selectedTemplate || !chapterTitle.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await flushEditorSave()
|
||||
const title = chapterTitle.trim()
|
||||
const ctx = buildContext(title)
|
||||
const ctx = buildTemplateContext({
|
||||
chapterTitle: title,
|
||||
penName,
|
||||
volumes,
|
||||
chapters,
|
||||
outlines,
|
||||
settings,
|
||||
activeVolumeId,
|
||||
selectedChapterId
|
||||
})
|
||||
const bodyPlain = replaceChapterTemplate(selectedTemplate.body, ctx)
|
||||
const contentHtml = bodyPlainToChapterHtml(bodyPlain)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor'
|
||||
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
|
||||
import { SettingMention } from '@renderer/extensions/mention'
|
||||
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands'
|
||||
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert, registerEditorGetText } from '@renderer/lib/editor-commands'
|
||||
function countPlain(text: string): number {
|
||||
const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '')
|
||||
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
||||
@@ -289,6 +289,15 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
registerEditorGetText(null)
|
||||
return
|
||||
}
|
||||
registerEditorGetText(() => editor.getText())
|
||||
return () => registerEditorGetText(null)
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !bookId || !target || target.kind !== 'chapter') {
|
||||
registerEditorInsert(null, () => {})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
|
||||
export function FocusModeOverlay(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const focusMode = useAppStore((s) => s.focusMode)
|
||||
const setFocusMode = useAppStore((s) => s.setFocusMode)
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusMode) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') setFocusMode(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [focusMode, setFocusMode])
|
||||
|
||||
if (!focusMode) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="focus-mode-exit"
|
||||
data-testid="focus-mode-exit"
|
||||
onClick={() => setFocusMode(false)}
|
||||
>
|
||||
{t('focus.exit')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -59,7 +59,13 @@ export function HomePage(): React.JSX.Element {
|
||||
>
|
||||
{t('home.importBook')}
|
||||
</button>
|
||||
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-large"
|
||||
data-testid="home-export-all"
|
||||
disabled
|
||||
title={t('home.exportAllWave2')}
|
||||
>
|
||||
{t('home.exportAll')}
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -26,6 +26,8 @@ import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
|
||||
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
|
||||
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
|
||||
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
|
||||
import { ChapterMetaPanel } from '@renderer/components/chapter/ChapterMetaPanel'
|
||||
import { VolumeContextMenu } from '@renderer/components/volume/VolumeContextMenu'
|
||||
import { ExportModal } from '@renderer/components/export/ExportModal'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import {
|
||||
@@ -121,6 +123,12 @@ export function EditorLayout(): React.JSX.Element {
|
||||
|
||||
const [bridgeOpen, setBridgeOpen] = useState(false)
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
||||
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
|
||||
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
|
||||
null
|
||||
)
|
||||
const [renamingChapterId, setRenamingChapterId] = useState<string | null>(null)
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
|
||||
|
||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||
@@ -237,6 +245,36 @@ export function EditorLayout(): React.JSX.Element {
|
||||
setActiveVolume(vol.id)
|
||||
}
|
||||
|
||||
const commitChapterRename = async (chapterId: string): Promise<void> => {
|
||||
if (!currentBookId) return
|
||||
const ch = chapters.find((c) => c.id === chapterId)
|
||||
const title = renameDraft.trim()
|
||||
setRenamingChapterId(null)
|
||||
if (!ch || !title || title === ch.title) return
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.chapter.update({ bookId: currentBookId, chapterId, title })
|
||||
)
|
||||
updateChapterLocal(updated)
|
||||
}
|
||||
|
||||
const handleDeleteChapter = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
|
||||
e.stopPropagation()
|
||||
if (!currentBookId) return
|
||||
const ch = chapters.find((c) => c.id === chapterId)
|
||||
if (!ch || !window.confirm(t('chapter.deleteConfirm', { title: ch.title }))) return
|
||||
await flushEditorSave()
|
||||
await ipcCall(() => window.electronAPI.chapter.delete(currentBookId, chapterId))
|
||||
await refreshChapters()
|
||||
if (selectedChapterId === chapterId) {
|
||||
const remaining = chapters.filter((c) => c.id !== chapterId)
|
||||
const next = remaining.find((c) => c.volumeId === ch.volumeId) ?? remaining[0]
|
||||
if (next) {
|
||||
setSelectedChapter(next.id)
|
||||
await switchTarget({ kind: 'chapter', id: next.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
|
||||
|
||||
const handleChapterDropOnItem = async (
|
||||
@@ -309,6 +347,12 @@ export function EditorLayout(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
|
||||
const chapterOverThreshold =
|
||||
currentChapter?.wordCountThreshold != null &&
|
||||
wordCount.chapter > currentChapter.wordCountThreshold
|
||||
const volumeMenuVolume = volumeMenu
|
||||
? volumes.find((v) => v.id === volumeMenu.volumeId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div id="editor-layout" data-book-id={currentBookId ?? ''}>
|
||||
@@ -337,6 +381,10 @@ export function EditorLayout(): React.JSX.Element {
|
||||
className="vol-header"
|
||||
data-volume-id={vol.id}
|
||||
onClick={() => setActiveVolume(vol.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
setVolumeMenu({ volumeId: vol.id, x: e.clientX, y: e.clientY })
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -371,12 +419,44 @@ export function EditorLayout(): React.JSX.Element {
|
||||
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
|
||||
}}
|
||||
onClick={() => void handleSelectChapter(ch.id)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRenamingChapterId(ch.id)
|
||||
setRenameDraft(ch.title)
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span>{idx + 1}.</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
|
||||
{renamingChapterId === ch.id ? (
|
||||
<input
|
||||
className="chapter-rename-input"
|
||||
data-testid={`chapter-rename-${ch.id}`}
|
||||
value={renameDraft}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onBlur={() => void commitChapterRename(ch.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void commitChapterRename(ch.id)
|
||||
if (e.key === 'Escape') setRenamingChapterId(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{ch.title}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ch-delete-btn"
|
||||
title={t('chapter.delete')}
|
||||
data-testid={`chapter-delete-${ch.id}`}
|
||||
onClick={(e) => void handleDeleteChapter(ch.id, e)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ch-publish-btn"
|
||||
@@ -560,7 +640,27 @@ export function EditorLayout(): React.JSX.Element {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm status-meta-btn"
|
||||
data-testid="chapter-meta-open"
|
||||
onClick={() => setChapterMetaOpen(true)}
|
||||
>
|
||||
{t('chapterMeta.open')}
|
||||
</button>
|
||||
{currentChapter?.publishStatus === 'published' && currentChapter.publishedAt && (
|
||||
<span className="status-published-at" data-testid="status-published-at">
|
||||
{t('chapterMeta.publishedAt', {
|
||||
date: new Date(currentChapter.publishedAt).toLocaleString('zh-CN')
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={chapterOverThreshold ? 'status-word-over-threshold' : undefined}
|
||||
data-testid="status-chapter-words"
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
@@ -598,6 +698,22 @@ export function EditorLayout(): React.JSX.Element {
|
||||
open={templateModalOpen}
|
||||
onClose={() => setTemplateModalOpen(false)}
|
||||
/>
|
||||
<ChapterMetaPanel
|
||||
open={chapterMetaOpen}
|
||||
chapter={currentChapter}
|
||||
onClose={() => setChapterMetaOpen(false)}
|
||||
/>
|
||||
{volumeMenu && volumeMenuVolume && currentBookId && (
|
||||
<VolumeContextMenu
|
||||
bookId={currentBookId}
|
||||
volume={volumeMenuVolume}
|
||||
hasChapters={chapters.some((c) => c.volumeId === volumeMenuVolume.id)}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
onChanged={refreshChapters}
|
||||
/>
|
||||
)}
|
||||
<ExportModal />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||||
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
||||
import { AiPanel } from '@renderer/components/ai/AiPanel'
|
||||
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
|
||||
import { BookSettingsTab } from '@renderer/components/book/BookSettingsTab'
|
||||
|
||||
export function RightPanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
@@ -54,7 +55,7 @@ export function RightPanel(): React.JSX.Element {
|
||||
<WordFreqPanel />
|
||||
</div>
|
||||
<div className={`panel-content ${rightPanel === 'book-settings' ? 'active' : ''}`}>
|
||||
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
||||
<BookSettingsTab />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||
import { useWritingStatsStore } from '@renderer/stores/useWritingStatsStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
|
||||
export function TopBar(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const focusMode = useAppStore((s) => s.focusMode)
|
||||
const setFocusMode = useAppStore((s) => s.setFocusMode)
|
||||
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
|
||||
const currentBookId = useBookStore((s) => s.currentBookId)
|
||||
const openCockpit = useCockpitStore((s) => s.openModal)
|
||||
const loadCockpitSummary = useCockpitStore((s) => s.loadSummary)
|
||||
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
|
||||
const writingStats = useWritingStatsStore((s) => s.stats)
|
||||
const dailyWordGoal = useSettingsStore((s) => s.dailyWordGoal)
|
||||
const goalProgress =
|
||||
dailyWordGoal > 0 && writingStats ? Math.min(writingStats.goalProgress, 1) : 0
|
||||
const progressClass =
|
||||
goalProgress >= 1 ? 'goal-met' : goalProgress >= 0.8 ? 'goal-near' : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (currentBookId) void useWritingStatsStore.getState().refresh(currentBookId)
|
||||
}, [currentBookId])
|
||||
|
||||
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
|
||||
e.stopPropagation()
|
||||
@@ -63,6 +77,21 @@ export function TopBar(): React.JSX.Element {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{dailyWordGoal > 0 && currentBookId && (
|
||||
<div
|
||||
className={`top-daily-progress ${progressClass}`}
|
||||
data-testid="top-daily-progress"
|
||||
title={t('status.dailyGoal', {
|
||||
current: writingStats?.todayWords ?? 0,
|
||||
goal: dailyWordGoal
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="top-daily-progress-fill"
|
||||
style={{ width: `${goalProgress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="top-spacer" />
|
||||
<button
|
||||
type="button"
|
||||
@@ -81,7 +110,14 @@ export function TopBar(): React.JSX.Element {
|
||||
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
|
||||
⚙
|
||||
</button>
|
||||
<button type="button" className="top-btn" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||
<button
|
||||
type="button"
|
||||
className="top-btn"
|
||||
title={t('focus.toggle')}
|
||||
data-testid="topbar-focus-mode"
|
||||
disabled={!currentBookId}
|
||||
onClick={() => setFocusMode(!focusMode)}
|
||||
>
|
||||
👁
|
||||
</button>
|
||||
<button type="button" className="top-btn" onClick={() => window.electronAPI.window.minimize()}>—</button>
|
||||
|
||||
@@ -252,7 +252,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v1.1.0
|
||||
{t('app.name')} v1.2.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Volume } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
|
||||
interface VolumeContextMenuProps {
|
||||
bookId: string
|
||||
volume: Volume
|
||||
hasChapters: boolean
|
||||
x: number
|
||||
y: number
|
||||
onClose: () => void
|
||||
onChanged: () => Promise<void>
|
||||
}
|
||||
|
||||
export function VolumeContextMenu({
|
||||
bookId,
|
||||
volume,
|
||||
hasChapters,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
onChanged
|
||||
}: VolumeContextMenuProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
|
||||
useEffect(() => {
|
||||
const close = (): void => onClose()
|
||||
window.addEventListener('click', close)
|
||||
return () => window.removeEventListener('click', close)
|
||||
}, [onClose])
|
||||
|
||||
const handleRename = async (): Promise<void> => {
|
||||
const name = window.prompt(t('volume.renamePrompt'), volume.name)
|
||||
onClose()
|
||||
if (!name?.trim() || name.trim() === volume.name) return
|
||||
await ipcCall(() => window.electronAPI.volume.update(bookId, volume.id, { name: name.trim() }))
|
||||
await onChanged()
|
||||
}
|
||||
|
||||
const handleDescription = async (): Promise<void> => {
|
||||
const description = window.prompt(t('volume.descriptionPrompt'), volume.description ?? '')
|
||||
onClose()
|
||||
if (description === null) return
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.volume.update(bookId, volume.id, { description: description.trim() })
|
||||
)
|
||||
await onChanged()
|
||||
}
|
||||
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
onClose()
|
||||
if (hasChapters) {
|
||||
showToast(t('volume.deleteHasChapters'))
|
||||
return
|
||||
}
|
||||
if (!window.confirm(t('volume.deleteConfirm', { name: volume.name }))) return
|
||||
await ipcCall(() => window.electronAPI.volume.delete(bookId, volume.id))
|
||||
await onChanged()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="context-menu"
|
||||
data-testid="volume-context-menu"
|
||||
style={{ left: x, top: y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={() => void handleRename()}>
|
||||
{t('volume.rename')}
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleDescription()}>
|
||||
{t('volume.editDescription')}
|
||||
</button>
|
||||
<button type="button" className="danger" onClick={() => void handleDelete()}>
|
||||
{t('volume.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { highlightTokenInEditor } from '@renderer/lib/editor-commands'
|
||||
|
||||
export function WordFreqPanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const target = useEditStore((s) => s.target)
|
||||
@@ -54,7 +54,8 @@ export function WordFreqPanel(): React.JSX.Element {
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => showToast(t('feature.comingSoon'))}
|
||||
data-testid="wordfreq-naming-check"
|
||||
onClick={() => setNamingCheckOpen(true)}
|
||||
>
|
||||
{t('wordfreq.aiNaming')}
|
||||
</button>
|
||||
|
||||
@@ -13,6 +13,7 @@ let insertLandmarkFn: ((payload: LandmarkInsert) => void) | null = null
|
||||
let highlightTokenFn: ((token: string) => void) | null = null
|
||||
let editorInsertContext: EditorInsertContext | null = null
|
||||
let insertTextFn: ((text: string) => void) | null = null
|
||||
let getEditorTextFn: (() => string) | null = null
|
||||
|
||||
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
|
||||
insertLandmarkFn = fn
|
||||
@@ -30,6 +31,16 @@ export function registerEditorInsert(
|
||||
insertTextFn = insert
|
||||
}
|
||||
|
||||
export function registerEditorGetText(fn: (() => string) | null): void {
|
||||
getEditorTextFn = fn
|
||||
}
|
||||
|
||||
export function getEditorTextForTts(): string {
|
||||
const selection = window.getSelection()?.toString().trim()
|
||||
if (selection) return selection
|
||||
return getEditorTextFn?.() ?? ''
|
||||
}
|
||||
|
||||
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
|
||||
insertLandmarkFn?.(payload)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function speakText(text: string, lang = 'zh-CN'): void {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
const u = new SpeechSynthesisUtterance(trimmed)
|
||||
u.lang = lang
|
||||
window.speechSynthesis.cancel()
|
||||
window.speechSynthesis.speak(u)
|
||||
}
|
||||
|
||||
export function stopSpeaking(): void {
|
||||
window.speechSynthesis.cancel()
|
||||
}
|
||||
|
||||
export function isSpeaking(): boolean {
|
||||
return window.speechSynthesis.speaking
|
||||
}
|
||||
@@ -19,7 +19,7 @@ interface AiStore {
|
||||
listenersAttached: boolean
|
||||
mount: () => void
|
||||
unmount: () => void
|
||||
loadSessions: (bookId: string) => Promise<void>
|
||||
loadSessions: (bookId: string, includeArchived?: boolean) => Promise<void>
|
||||
selectSession: (sessionId: string) => Promise<void>
|
||||
createSession: (bookId: string) => Promise<void>
|
||||
sendMessage: (content: string, prompt?: string) => Promise<void>
|
||||
@@ -63,8 +63,10 @@ export const useAiStore = create<AiStore>((set, get) => ({
|
||||
set({ listenersAttached: false })
|
||||
},
|
||||
|
||||
loadSessions: async (bookId) => {
|
||||
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
|
||||
loadSessions: async (bookId, includeArchived = true) => {
|
||||
const sessions = await ipcCall(() =>
|
||||
window.electronAPI.ai.sessionList(bookId, includeArchived)
|
||||
)
|
||||
const activeSessionId = get().activeSessionId
|
||||
const stillActive = activeSessionId && sessions.some((s) => s.id === activeSessionId)
|
||||
set({ sessions })
|
||||
@@ -157,7 +159,7 @@ export const useAiStore = create<AiStore>((set, get) => ({
|
||||
try {
|
||||
await ipcCall(() => window.electronAPI.ai.chat(bookId, sessionId, trimmed, prompt))
|
||||
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
|
||||
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
|
||||
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId, true))
|
||||
set({ messages, sessions, streamingMessageId: null, streamingBuffer: '' })
|
||||
} finally {
|
||||
set({ sending: false })
|
||||
|
||||
@@ -10,6 +10,8 @@ interface AppState {
|
||||
landmarksModalOpen: boolean
|
||||
inspirationModalOpen: boolean
|
||||
importBookModalOpen: boolean
|
||||
focusMode: boolean
|
||||
namingCheckOpen: boolean
|
||||
toast: string | null
|
||||
setView: (view: AppView) => void
|
||||
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
||||
@@ -18,6 +20,8 @@ interface AppState {
|
||||
setLandmarksModalOpen: (open: boolean) => void
|
||||
setInspirationModalOpen: (open: boolean) => void
|
||||
setImportBookModalOpen: (open: boolean) => void
|
||||
setFocusMode: (on: boolean) => void
|
||||
setNamingCheckOpen: (open: boolean) => void
|
||||
showToast: (message: string) => void
|
||||
clearToast: () => void
|
||||
}
|
||||
@@ -30,6 +34,8 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
landmarksModalOpen: false,
|
||||
inspirationModalOpen: false,
|
||||
importBookModalOpen: false,
|
||||
focusMode: false,
|
||||
namingCheckOpen: false,
|
||||
toast: null,
|
||||
setView: (view) => set({ view }),
|
||||
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
||||
@@ -38,6 +44,8 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
||||
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
||||
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
|
||||
setFocusMode: (focusMode) => set({ focusMode }),
|
||||
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
|
||||
showToast: (toast) => {
|
||||
set({ toast })
|
||||
setTimeout(() => set({ toast: null }), 2500)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { create } from 'zustand'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface BookPrefsState {
|
||||
alwaysShowCockpit: boolean
|
||||
loadedBookId: string | null
|
||||
load: (bookId: string) => Promise<void>
|
||||
setAlwaysShowCockpit: (bookId: string, value: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const useBookPrefsStore = create<BookPrefsState>((set, get) => ({
|
||||
alwaysShowCockpit: false,
|
||||
loadedBookId: null,
|
||||
load: async (bookId) => {
|
||||
const value = await ipcCall(() =>
|
||||
window.electronAPI.bookPrefs.get(bookId, 'alwaysShowCockpit')
|
||||
)
|
||||
set({ alwaysShowCockpit: value === 'true', loadedBookId: bookId })
|
||||
},
|
||||
setAlwaysShowCockpit: async (bookId, value) => {
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.bookPrefs.set(bookId, 'alwaysShowCockpit', value ? 'true' : 'false')
|
||||
)
|
||||
if (get().loadedBookId === bookId) {
|
||||
set({ alwaysShowCockpit: value })
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -67,6 +67,59 @@
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.top-daily-progress {
|
||||
width: 72px;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.top-daily-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.top-daily-progress.goal-near .top-daily-progress-fill {
|
||||
background: #e67e22;
|
||||
}
|
||||
|
||||
.top-daily-progress.goal-met .top-daily-progress-fill {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
#app.focus-mode #left-sidebar,
|
||||
#app.focus-mode #right-panel,
|
||||
#app.focus-mode #reference-panel.open {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#app.focus-mode #topbar .tab-bar,
|
||||
#app.focus-mode #topbar .top-btn:not([data-testid='topbar-focus-mode']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.focus-mode-exit {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10001;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.focus-mode-exit:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.top-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -1070,6 +1123,171 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ch-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
opacity: 0;
|
||||
color: var(--red, #c44);
|
||||
}
|
||||
|
||||
.chapter-item:hover .ch-delete-btn {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ch-delete-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chapter-rename-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
min-width: 140px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.context-menu button:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.context-menu button.danger {
|
||||
color: var(--red, #c44);
|
||||
}
|
||||
|
||||
.chapter-meta-modal .modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chapter-meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-chip button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chapter-meta-tag-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chapter-meta-published,
|
||||
.status-published-at {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-word-over-threshold {
|
||||
color: #e67e22;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-meta-btn {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.ai-session-menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ai-session-menu-btn {
|
||||
padding: 4px 8px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.ai-session-menu {
|
||||
right: 0;
|
||||
left: auto;
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.ai-session-export-submenu {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.ai-session-export-submenu button {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.book-settings-tab {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.book-settings-cover-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.book-settings-cover-path {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.book-settings-danger {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: #fff;
|
||||
background: var(--red, #c44);
|
||||
}
|
||||
|
||||
.status-stock-warning {
|
||||
color: var(--warning, #e6a23c);
|
||||
margin-left: 12px;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Chapter, OutlineItem, SettingEntry, Volume } from './types'
|
||||
|
||||
export interface TemplateContext {
|
||||
chapterNumber: number
|
||||
chapterTitle: string
|
||||
@@ -20,6 +22,39 @@ const VARS = [
|
||||
'characterList'
|
||||
] as const
|
||||
|
||||
export function buildTemplateContext(input: {
|
||||
chapterTitle: string
|
||||
penName: string
|
||||
volumes: Volume[]
|
||||
chapters: Chapter[]
|
||||
outlines: OutlineItem[]
|
||||
settings: SettingEntry[]
|
||||
activeVolumeId: string | null
|
||||
selectedChapterId?: string | null
|
||||
}): TemplateContext {
|
||||
const volChapters = input.chapters
|
||||
.filter((c) => c.volumeId === input.activeVolumeId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const chapterNumber = volChapters.length + 1
|
||||
const outlineItem =
|
||||
input.outlines.find((o) => o.chapterId === input.selectedChapterId)?.title ?? ''
|
||||
const characterList = input.settings
|
||||
.filter((s) => s.type === 'character')
|
||||
.slice(0, 8)
|
||||
.map((s) => s.name)
|
||||
.join('、')
|
||||
return {
|
||||
chapterNumber,
|
||||
chapterTitle: input.chapterTitle,
|
||||
penName: input.penName,
|
||||
date: new Date().toLocaleDateString('zh-CN'),
|
||||
volumeName: input.volumes.find((v) => v.id === input.activeVolumeId)?.name ?? '',
|
||||
previousChapterTitle: volChapters.at(-1)?.title ?? '',
|
||||
outlineItem,
|
||||
characterList
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceChapterTemplate(body: string, ctx: TemplateContext): string {
|
||||
let out = body
|
||||
for (const key of VARS) {
|
||||
|
||||
Vendored
+18
-4
@@ -32,6 +32,8 @@ import type {
|
||||
Snapshot,
|
||||
SnapshotType,
|
||||
UpdateChapterParams,
|
||||
UpdateBookMetaParams,
|
||||
ChapterTag,
|
||||
Volume,
|
||||
WordFreqResult,
|
||||
KnowledgeEntry,
|
||||
@@ -73,10 +75,12 @@ export interface ElectronAPI {
|
||||
create: (params: CreateBookParams) => Promise<IpcResult<BookMeta>>
|
||||
delete: (bookId: string) => Promise<IpcResult<void>>
|
||||
open: (bookId: string) => Promise<IpcResult<BookOpenResult>>
|
||||
updateMeta: (
|
||||
bookId: string,
|
||||
patch: { lastChapterId?: string | null; status?: BookMeta['status'] }
|
||||
) => Promise<IpcResult<BookMeta>>
|
||||
updateMeta: (bookId: string, patch: UpdateBookMetaParams) => Promise<IpcResult<BookMeta>>
|
||||
pickCover: (bookId: string) => Promise<IpcResult<BookMeta | null>>
|
||||
}
|
||||
bookPrefs: {
|
||||
get: (bookId: string, key: string) => Promise<IpcResult<string | null>>
|
||||
set: (bookId: string, key: string, value: string) => Promise<IpcResult<void>>
|
||||
}
|
||||
volume: {
|
||||
create: (bookId: string, name: string) => Promise<IpcResult<Volume>>
|
||||
@@ -105,6 +109,11 @@ export interface ElectronAPI {
|
||||
publishStatus: PublishStatus
|
||||
) => Promise<IpcResult<Chapter>>
|
||||
}
|
||||
chapterTag: {
|
||||
list: (bookId: string, chapterId: string) => Promise<IpcResult<ChapterTag[]>>
|
||||
set: (bookId: string, chapterId: string, tag: string, color?: string) => Promise<IpcResult<void>>
|
||||
remove: (bookId: string, chapterId: string, tag: string) => Promise<IpcResult<void>>
|
||||
}
|
||||
outline: {
|
||||
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
|
||||
create: (
|
||||
@@ -221,6 +230,11 @@ export interface ElectronAPI {
|
||||
patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }>
|
||||
) => Promise<IpcResult<AiSession>>
|
||||
sessionDelete: (bookId: string, sessionId: string) => Promise<IpcResult<void>>
|
||||
sessionExport: (
|
||||
bookId: string,
|
||||
sessionId: string,
|
||||
format?: 'markdown' | 'txt'
|
||||
) => Promise<IpcResult<string | null>>
|
||||
messageList: (bookId: string, sessionId: string) => Promise<IpcResult<AiMessage[]>>
|
||||
chat: (
|
||||
bookId: string,
|
||||
|
||||
@@ -6,6 +6,7 @@ export const IPC = {
|
||||
BOOK_DELETE: 'book:delete',
|
||||
BOOK_OPEN: 'book:open',
|
||||
BOOK_UPDATE_META: 'book:updateMeta',
|
||||
BOOK_PICK_COVER: 'book:pickCover',
|
||||
VOLUME_CREATE: 'volume:create',
|
||||
VOLUME_UPDATE: 'volume:update',
|
||||
VOLUME_DELETE: 'volume:delete',
|
||||
@@ -143,5 +144,11 @@ export const IPC = {
|
||||
BRIDGE_GET: 'bridge:get',
|
||||
BRIDGE_DISMISS: 'bridge:dismiss',
|
||||
BRIDGE_SHOULD_PROMPT: 'bridge:shouldPrompt',
|
||||
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus'
|
||||
CHAPTER_SET_PUBLISH_STATUS: 'chapter:setPublishStatus',
|
||||
CHAPTER_TAG_LIST: 'chapter:tagList',
|
||||
CHAPTER_TAG_SET: 'chapter:tagSet',
|
||||
CHAPTER_TAG_REMOVE: 'chapter:tagRemove',
|
||||
BOOK_PREFS_GET: 'book:prefsGet',
|
||||
BOOK_PREFS_SET: 'book:prefsSet',
|
||||
AI_SESSION_EXPORT: 'ai:sessionExport'
|
||||
} as const
|
||||
|
||||
@@ -301,6 +301,25 @@ export interface BookMeta {
|
||||
lastOpenedAt: string
|
||||
lastChapterId: string | null
|
||||
createdAt: string
|
||||
coverPath?: string | null
|
||||
synopsis?: string
|
||||
}
|
||||
|
||||
export interface UpdateBookMetaParams {
|
||||
name?: string
|
||||
category?: string
|
||||
targetWordCount?: number | null
|
||||
coverPath?: string | null
|
||||
synopsis?: string
|
||||
lastChapterId?: string | null
|
||||
lastOpenedAt?: string
|
||||
status?: BookStatus
|
||||
}
|
||||
|
||||
export interface ChapterTag {
|
||||
chapterId: string
|
||||
tag: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface CreateBookParams {
|
||||
@@ -331,6 +350,8 @@ export interface Chapter {
|
||||
povCharacterId?: string | null
|
||||
summary?: string
|
||||
storyTime?: string | null
|
||||
publishedAt?: string | null
|
||||
wordCountThreshold?: number | null
|
||||
}
|
||||
|
||||
export interface OutlineItem {
|
||||
@@ -422,6 +443,11 @@ export interface UpdateChapterParams {
|
||||
status?: ChapterStatus
|
||||
cursorOffset?: number
|
||||
povCharacterId?: string | null
|
||||
summary?: string
|
||||
storyTime?: string | null
|
||||
wordCountThreshold?: number | null
|
||||
publishStatus?: PublishStatus
|
||||
publishedAt?: string | null
|
||||
}
|
||||
|
||||
export type AiBackend = 'lmstudio' | 'openai' | 'anthropic'
|
||||
|
||||
Reference in New Issue
Block a user