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:
2026-07-06 14:13:27 +08:00
parent 91c93954df
commit aac51bf183
72 changed files with 5790 additions and 203 deletions
+30 -7
View File
@@ -1,7 +1,25 @@
import type { SqliteDb } from './types'
import schemaV1 from './schema-v1.sql?raw'
import schemaV2 from './schema-v2.sql?raw'
const CURRENT_VERSION = 1
const CURRENT_VERSION = 2
function tryAlter(db: SqliteDb, sql: string): void {
try {
db.exec(sql)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!msg.includes('duplicate column')) throw err
}
}
function applyV2(db: SqliteDb): void {
tryAlter(db, "ALTER TABLE chapters ADD COLUMN publish_status TEXT NOT NULL DEFAULT 'draft'")
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN pov_character_id TEXT DEFAULT NULL')
tryAlter(db, "ALTER TABLE chapters ADD COLUMN summary TEXT DEFAULT ''")
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN story_time TEXT DEFAULT NULL')
db.exec(schemaV2)
}
export function migrate(db: SqliteDb): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
@@ -11,12 +29,17 @@ export function migrate(db: SqliteDb): void {
)`)
const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number | null }
const current = row?.v ?? 0
let current = row?.v ?? 0
if (current >= CURRENT_VERSION) return
db.exec(schemaV1)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(
CURRENT_VERSION,
'initial schema'
)
if (current < 1) {
db.exec(schemaV1)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema')
current = 1
}
if (current < 2) {
applyV2(db)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2 schema')
}
}
+92
View File
@@ -0,0 +1,92 @@
import { randomUUID } from 'crypto'
import type { Bookmark, LandmarkType } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): Bookmark {
return {
id: row.id as string,
chapterId: row.chapter_id as string,
position: row.position as number,
label: row.label as string,
landmarkType: row.landmark_type as LandmarkType,
resolved: Boolean(row.resolved),
createdAt: row.created_at as string
}
}
export class BookmarkRepository {
constructor(private db: SqliteDb) {}
create(
chapterId: string,
position: number,
label: string,
landmarkType: LandmarkType = 'todo'
): Bookmark {
const id = randomUUID()
this.db
.prepare(
`INSERT INTO bookmarks (id, chapter_id, position, label, landmark_type)
VALUES (?, ?, ?, ?, ?)`
)
.run(id, chapterId, position, label, landmarkType)
return this.get(id)!
}
get(id: string): Bookmark | null {
const row = this.db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
list(chapterId?: string, resolved?: boolean): Bookmark[] {
let sql = 'SELECT * FROM bookmarks WHERE 1=1'
const params: unknown[] = []
if (chapterId !== undefined) {
sql += ' AND chapter_id = ?'
params.push(chapterId)
}
if (resolved !== undefined) {
sql += ' AND resolved = ?'
params.push(resolved ? 1 : 0)
}
sql += ' ORDER BY created_at DESC'
return (this.db.prepare(sql).all(...params) as Record<string, unknown>[]).map(mapRow)
}
listByChapter(chapterId: string): Bookmark[] {
return this.list(chapterId)
}
update(
id: string,
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
): Bookmark {
const existing = this.get(id)
if (!existing) throw new Error('Bookmark not found')
this.db
.prepare(
`UPDATE bookmarks SET label = ?, position = ?, landmark_type = ? WHERE id = ?`
)
.run(
patch.label ?? existing.label,
patch.position ?? existing.position,
patch.landmarkType ?? existing.landmarkType,
id
)
return this.get(id)!
}
resolve(id: string, resolved: boolean): Bookmark {
const existing = this.get(id)
if (!existing) throw new Error('Bookmark not found')
this.db.prepare('UPDATE bookmarks SET resolved = ? WHERE id = ?').run(resolved ? 1 : 0, id)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id)
}
}
@@ -0,0 +1,91 @@
import { randomUUID } from 'crypto'
import type { InspirationEntry, SettingType } from '../../../shared/types'
import type { SqliteDb } from '../types'
import { stripHtml } from '../../services/word-count'
import type { OutlineRepository } from './outline.repo'
import type { SettingRepository } from './setting.repo'
function mapRow(row: Record<string, unknown>): InspirationEntry {
let tags: string[] = []
try {
tags = JSON.parse((row.tags as string) || '[]') as string[]
} catch {
tags = []
}
return {
id: row.id as string,
title: row.title as string,
content: row.content as string,
tags,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string
}
}
export class InspirationRepository {
constructor(private db: SqliteDb) {}
create(title = '', content = '', tags: string[] = []): InspirationEntry {
const id = randomUUID()
this.db
.prepare(`INSERT INTO inspiration (id, title, content, tags) VALUES (?, ?, ?, ?)`)
.run(id, title, content, JSON.stringify(tags))
return this.get(id)!
}
get(id: string): InspirationEntry | null {
const row = this.db.prepare('SELECT * FROM inspiration WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
list(): InspirationEntry[] {
return (
this.db.prepare('SELECT * FROM inspiration ORDER BY updated_at DESC').all() as Record<string, unknown>[]
).map(mapRow)
}
update(
id: string,
patch: Partial<{ title: string; content: string; tags: string[] }>
): InspirationEntry {
const existing = this.get(id)
if (!existing) throw new Error('Inspiration not found')
this.db
.prepare(
`UPDATE inspiration SET title = ?, content = ?, tags = ?, updated_at = datetime('now') WHERE id = ?`
)
.run(
patch.title ?? existing.title,
patch.content ?? existing.content,
JSON.stringify(patch.tags ?? existing.tags),
id
)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM inspiration WHERE id = ?').run(id)
}
convertToOutline(id: string, outlineRepo: OutlineRepository) {
const item = this.get(id)
if (!item) throw new Error('Inspiration not found')
const outline = outlineRepo.create(null, item.title || '未命名灵感', outlineRepo.nextSortOrder(null))
outlineRepo.update(outline.id, { description: stripHtml(item.content) })
this.delete(id)
return outlineRepo.get(outline.id)!
}
convertToSetting(id: string, type: SettingType, settingRepo: SettingRepository) {
const item = this.get(id)
if (!item) throw new Error('Inspiration not found')
const setting = settingRepo.create(type, item.title || '未命名设定', stripHtml(item.content))
this.delete(id)
return setting
}
}
+137
View File
@@ -0,0 +1,137 @@
import { randomUUID } from 'crypto'
import type { OutlineItem, OutlineStatus } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): OutlineItem {
return {
id: row.id as string,
parentId: (row.parent_id as string | null) ?? null,
title: row.title as string,
description: (row.description as string) ?? '',
status: row.status as OutlineStatus,
expectedWordCount: (row.expected_word_count as number | null) ?? null,
chapterId: (row.chapter_id as string | null) ?? null,
sortOrder: row.sort_order as number,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string
}
}
function buildTree(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 class OutlineRepository {
constructor(private db: SqliteDb) {}
create(parentId: string | null, title: string, sortOrder: number): OutlineItem {
const id = randomUUID()
this.db
.prepare(
`INSERT INTO outline_items (id, parent_id, title, sort_order)
VALUES (?, ?, ?, ?)`
)
.run(id, parentId, title, sortOrder)
return this.get(id)!
}
get(id: string): OutlineItem | null {
const row = this.db.prepare('SELECT * FROM outline_items WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
listFlat(): OutlineItem[] {
return (
this.db.prepare('SELECT * FROM outline_items ORDER BY sort_order').all() as Record<string, unknown>[]
).map(mapRow)
}
listTree(): OutlineItem[] {
return buildTree(this.listFlat())
}
update(
id: string,
patch: Partial<{
title: string
description: string
status: OutlineStatus
expectedWordCount: number | null
chapterId: string | null
sortOrder: number
}>
): OutlineItem {
const existing = this.get(id)
if (!existing) throw new Error('Outline item not found')
this.db
.prepare(
`UPDATE outline_items SET
title = ?, description = ?, status = ?,
expected_word_count = ?, chapter_id = ?, sort_order = ?,
updated_at = datetime('now')
WHERE id = ?`
)
.run(
patch.title ?? existing.title,
patch.description ?? existing.description,
patch.status ?? existing.status,
patch.expectedWordCount !== undefined ? patch.expectedWordCount : existing.expectedWordCount,
patch.chapterId !== undefined ? patch.chapterId : existing.chapterId,
patch.sortOrder ?? existing.sortOrder,
id
)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM outline_items WHERE id = ?').run(id)
}
move(id: string, parentId: string | null, sortOrder: number): OutlineItem {
const existing = this.get(id)
if (!existing) throw new Error('Outline item not found')
this.db
.prepare(
`UPDATE outline_items SET parent_id = ?, sort_order = ?, updated_at = datetime('now') WHERE id = ?`
)
.run(parentId, sortOrder, id)
return this.get(id)!
}
listUncovered(): OutlineItem[] {
return (
this.db
.prepare('SELECT * FROM outline_items WHERE chapter_id IS NULL ORDER BY sort_order')
.all() as Record<string, unknown>[]
).map(mapRow)
}
nextSortOrder(parentId: string | null): number {
const row = parentId
? (this.db
.prepare('SELECT MAX(sort_order) AS maxOrder FROM outline_items WHERE parent_id = ?')
.get(parentId) as { maxOrder: number | null })
: (this.db
.prepare('SELECT MAX(sort_order) AS maxOrder FROM outline_items WHERE parent_id IS NULL')
.get() as { maxOrder: number | null })
return (row?.maxOrder ?? -1) + 1
}
}
+103
View File
@@ -0,0 +1,103 @@
import { randomUUID } from 'crypto'
import type { SettingEntry, SettingType } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>, chapterIds: string[]): SettingEntry {
let properties: Record<string, unknown> = {}
try {
properties = JSON.parse((row.properties as string) || '{}') as Record<string, unknown>
} catch {
properties = {}
}
return {
id: row.id as string,
type: row.type as SettingType,
name: row.name as string,
description: (row.description as string) ?? '',
properties,
chapterIds,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string
}
}
export class SettingRepository {
constructor(private db: SqliteDb) {}
private loadChapterIds(settingId: string): string[] {
return (
this.db
.prepare('SELECT chapter_id FROM setting_chapter_refs WHERE setting_id = ?')
.all(settingId) as { chapter_id: string }[]
).map((r) => r.chapter_id)
}
create(type: SettingType, name: string, description = '', properties: Record<string, unknown> = {}): SettingEntry {
const id = randomUUID()
this.db
.prepare(
`INSERT INTO settings (id, type, name, description, properties)
VALUES (?, ?, ?, ?, ?)`
)
.run(id, type, name, description, JSON.stringify(properties))
return this.get(id)!
}
get(id: string): SettingEntry | null {
const row = this.db.prepare('SELECT * FROM settings WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row, this.loadChapterIds(id)) : null
}
list(type?: SettingType): SettingEntry[] {
const rows = type
? (this.db.prepare('SELECT * FROM settings WHERE type = ? ORDER BY name').all(type) as Record<
string,
unknown
>[])
: (this.db.prepare('SELECT * FROM settings ORDER BY type, name').all() as Record<string, unknown>[])
return rows.map((row) => mapRow(row, this.loadChapterIds(row.id as string)))
}
update(
id: string,
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
): SettingEntry {
const existing = this.get(id)
if (!existing) throw new Error('Setting not found')
this.db
.prepare(
`UPDATE settings SET
type = ?, name = ?, description = ?, properties = ?,
updated_at = datetime('now')
WHERE id = ?`
)
.run(
patch.type ?? existing.type,
patch.name ?? existing.name,
patch.description ?? existing.description,
JSON.stringify(patch.properties ?? existing.properties),
id
)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM settings WHERE id = ?').run(id)
}
syncChapterRefs(settingId: string, chapterIds: string[]): SettingEntry {
if (!this.get(settingId)) throw new Error('Setting not found')
this.db.prepare('DELETE FROM setting_chapter_refs WHERE setting_id = ?').run(settingId)
const insert = this.db.prepare(
'INSERT INTO setting_chapter_refs (setting_id, chapter_id) VALUES (?, ?)'
)
for (const chapterId of chapterIds) {
insert.run(settingId, chapterId)
}
return this.get(settingId)!
}
}
+81
View File
@@ -0,0 +1,81 @@
import { randomUUID } from 'crypto'
import type { Snapshot, SnapshotType } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): Snapshot {
return {
id: row.id as string,
chapterId: row.chapter_id as string,
type: row.type as SnapshotType,
name: (row.name as string) ?? '',
content: row.content as string,
createdAt: row.created_at as string
}
}
export class SnapshotRepository {
constructor(private db: SqliteDb) {}
create(chapterId: string, type: SnapshotType, content: string, name = ''): Snapshot {
const id = randomUUID()
this.db
.prepare(`INSERT INTO snapshots (id, chapter_id, type, name, content) VALUES (?, ?, ?, ?, ?)`)
.run(id, chapterId, type, name, content)
return this.get(id)!
}
get(id: string): Snapshot | null {
const row = this.db.prepare('SELECT * FROM snapshots WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
list(chapterId: string): Snapshot[] {
return (
this.db
.prepare('SELECT * FROM snapshots WHERE chapter_id = ? ORDER BY created_at DESC')
.all(chapterId) as Record<string, unknown>[]
).map(mapRow)
}
delete(id: string): void {
this.db.prepare('DELETE FROM snapshots WHERE id = ?').run(id)
}
pruneAuto(chapterId: string, max: number): void {
const autos = (
this.db
.prepare(
`SELECT id FROM snapshots WHERE chapter_id = ? AND type = 'auto' ORDER BY created_at ASC`
)
.all(chapterId) as { id: string }[]
)
const excess = autos.length - max
if (excess <= 0) return
for (let i = 0; i < excess; i++) {
this.delete(autos[i].id)
}
}
prunePersist(chapterId: string, max: number): void {
const items = (
this.db
.prepare(
`SELECT id FROM snapshots WHERE chapter_id = ? AND type = 'persist' ORDER BY created_at ASC`
)
.all(chapterId) as { id: string }[]
)
const excess = items.length - max
if (excess <= 0) return
for (let i = 0; i < excess; i++) {
this.delete(items[i].id)
}
}
restore(snapshotId: string): string {
const snap = this.get(snapshotId)
if (!snap) throw new Error('Snapshot not found')
return snap.content
}
}
+70
View File
@@ -0,0 +1,70 @@
CREATE TABLE IF NOT EXISTS outline_items (
id TEXT PRIMARY KEY,
parent_id TEXT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
expected_word_count INTEGER DEFAULT NULL,
chapter_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (parent_id) REFERENCES outline_items(id) ON DELETE CASCADE,
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
properties TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS setting_chapter_refs (
setting_id TEXT NOT NULL,
chapter_id TEXT NOT NULL,
PRIMARY KEY (setting_id, chapter_id),
FOREIGN KEY (setting_id) REFERENCES settings(id) ON DELETE CASCADE,
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS inspiration (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
tags TEXT DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
chapter_id TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT DEFAULT '',
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
chapter_id TEXT NOT NULL,
position INTEGER NOT NULL,
label TEXT NOT NULL,
landmark_type TEXT NOT NULL DEFAULT 'todo',
resolved INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
source_kind,
source_id,
title,
body,
tokenize='unicode61'
);
+6 -1
View File
@@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain } from 'electron'
import { existsSync } from 'fs'
import { join } from 'path'
import { IPC } from '../shared/ipc-channels'
import { getShortcutManager, registerIpc } from './ipc/register'
import { getShortcutManager, getSnapshotService, registerIpc } from './ipc/register'
import { closeAllBookDbs } from './db/connection'
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_USER_DATA) {
@@ -66,6 +66,7 @@ app.whenReady().then(() => {
app.on('window-all-closed', () => {
getShortcutManager()?.unregisterAll()
getSnapshotService()?.stopAll()
closeAllBookDbs()
if (process.platform !== 'darwin') app.quit()
})
@@ -74,6 +75,10 @@ app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
app.on('before-quit', () => {
getSnapshotService()?.stopAll()
})
app.on('will-quit', () => {
getShortcutManager()?.unregisterAll()
})
+81
View File
@@ -0,0 +1,81 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { LandmarkType } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerBookmarkHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.BOOKMARK_LIST,
(
_e,
{
bookId,
chapterId,
resolved
}: { bookId: string; chapterId?: string; resolved?: boolean }
) => wrap(() => registry.getBookmarkRepo(bookId).list(chapterId, resolved))
)
ipcMain.handle(
IPC.BOOKMARK_CREATE,
(
_e,
{
bookId,
chapterId,
position,
label,
landmarkType
}: {
bookId: string
chapterId: string
position: number
label: string
landmarkType?: LandmarkType
}
) =>
wrap(() => {
const item = registry
.getBookmarkRepo(bookId)
.create(chapterId, position, label, landmarkType)
ftsSync.upsert(registry.getDb(bookId), 'bookmark', item.id, item.label, item.label)
return item
})
)
ipcMain.handle(
IPC.BOOKMARK_UPDATE,
(
_e,
{
bookId,
id,
patch
}: {
bookId: string
id: string
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
}
) =>
wrap(() => {
const item = registry.getBookmarkRepo(bookId).update(id, patch)
ftsSync.upsert(registry.getDb(bookId), 'bookmark', item.id, item.label, item.label)
return item
})
)
ipcMain.handle(
IPC.BOOKMARK_RESOLVE,
(_e, { bookId, id, resolved }: { bookId: string; id: string; resolved: boolean }) =>
wrap(() => registry.getBookmarkRepo(bookId).resolve(id, resolved))
)
ipcMain.handle(IPC.BOOKMARK_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
registry.getBookmarkRepo(bookId).delete(id)
ftsSync.remove(registry.getDb(bookId), 'bookmark', id)
})
)
}
+12 -1
View File
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ChapterStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerChapterHandlers(registry: BookRegistryService): void {
@@ -40,7 +41,9 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
wrap(() => {
const repo = registry.getChapterRepo(bookId)
const sortOrder = repo.nextSortOrder(volumeId)
return repo.create(volumeId, title, sortOrder)
const chapter = repo.create(volumeId, title, sortOrder)
ftsSync.upsert(registry.getDb(bookId), 'chapter', chapter.id, chapter.title, chapter.content)
return chapter
})
)
@@ -81,6 +84,13 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
status,
cursorOffset
})
ftsSync.upsert(
registry.getDb(bookId),
'chapter',
chapter.id,
chapter.title,
chapter.content
)
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
@@ -91,6 +101,7 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
registry.getChapterRepo(bookId).delete(chapterId)
ftsSync.remove(registry.getDb(bookId), 'chapter', chapterId)
})
)
}
@@ -0,0 +1,86 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SettingType } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerInspirationHandlers(registry: BookRegistryService): void {
ipcMain.handle(IPC.INSPIRATION_LIST, (_e, { bookId }: { bookId: string }) =>
wrap(() => registry.getInspirationRepo(bookId).list())
)
ipcMain.handle(
IPC.INSPIRATION_CREATE,
(
_e,
{
bookId,
title,
content,
tags
}: { bookId: string; title?: string; content?: string; tags?: string[] }
) =>
wrap(() => {
const item = registry.getInspirationRepo(bookId).create(title, content, tags)
ftsSync.upsert(registry.getDb(bookId), 'inspiration', item.id, item.title, item.content)
return item
})
)
ipcMain.handle(
IPC.INSPIRATION_UPDATE,
(
_e,
{
bookId,
id,
patch
}: {
bookId: string
id: string
patch: Partial<{ title: string; content: string; tags: string[] }>
}
) =>
wrap(() => {
const item = registry.getInspirationRepo(bookId).update(id, patch)
ftsSync.upsert(registry.getDb(bookId), 'inspiration', item.id, item.title, item.content)
return item
})
)
ipcMain.handle(IPC.INSPIRATION_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
registry.getInspirationRepo(bookId).delete(id)
ftsSync.remove(registry.getDb(bookId), 'inspiration', id)
})
)
ipcMain.handle(
IPC.INSPIRATION_CONVERT,
(
_e,
{
bookId,
id,
targetKind,
settingType
}: { bookId: string; id: string; targetKind: 'outline' | 'setting'; settingType?: SettingType }
) =>
wrap(() => {
const repo = registry.getInspirationRepo(bookId)
if (targetKind === 'outline') {
const item = repo.convertToOutline(id, registry.getOutlineRepo(bookId))
ftsSync.upsert(registry.getDb(bookId), 'outline', item.id, item.title, item.description)
return item
}
const item = repo.convertToSetting(
id,
settingType ?? 'custom',
registry.getSettingRepo(bookId)
)
ftsSync.upsert(registry.getDb(bookId), 'setting', item.id, item.name, item.description)
return item
})
)
}
+82
View File
@@ -0,0 +1,82 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { OutlineStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerOutlineHandlers(registry: BookRegistryService): void {
ipcMain.handle(IPC.OUTLINE_LIST, (_e, { bookId }: { bookId: string }) =>
wrap(() => registry.getOutlineRepo(bookId).listFlat())
)
ipcMain.handle(
IPC.OUTLINE_CREATE,
(
_e,
{
bookId,
parentId,
title,
sortOrder
}: { bookId: string; parentId?: string | null; title: string; sortOrder?: number }
) =>
wrap(() => {
const repo = registry.getOutlineRepo(bookId)
const order = sortOrder ?? repo.nextSortOrder(parentId ?? null)
const item = repo.create(parentId ?? null, title, order)
const db = registry.getDb(bookId)
ftsSync.upsert(db, 'outline', item.id, item.title, item.description)
return item
})
)
ipcMain.handle(
IPC.OUTLINE_UPDATE,
(
_e,
{
bookId,
id,
patch
}: {
bookId: string
id: string
patch: Partial<{
title: string
description: string
status: OutlineStatus
expectedWordCount: number | null
chapterId: string | null
sortOrder: number
}>
}
) =>
wrap(() => {
const item = registry.getOutlineRepo(bookId).update(id, patch)
const db = registry.getDb(bookId)
ftsSync.upsert(db, 'outline', item.id, item.title, item.description)
return item
})
)
ipcMain.handle(IPC.OUTLINE_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
registry.getOutlineRepo(bookId).delete(id)
ftsSync.remove(registry.getDb(bookId), 'outline', id)
})
)
ipcMain.handle(
IPC.OUTLINE_MOVE,
(
_e,
{
bookId,
id,
parentId,
sortOrder
}: { bookId: string; id: string; parentId?: string | null; sortOrder: number }
) => wrap(() => registry.getOutlineRepo(bookId).move(id, parentId ?? null, sortOrder))
)
}
+99
View File
@@ -0,0 +1,99 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SearchScope } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings'
import { searchService } from '../../services/search.service'
import { wordFreqService } from '../../services/wordfreq.service'
import { wrap } from '../result'
const MAX_SEARCH_HISTORY = 10
export function registerSearchHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
ipcMain.handle(
IPC.SEARCH_QUERY,
(
_e,
{
bookId,
query,
scope,
options
}: {
bookId: string
query: string
scope?: SearchScope
options?: { regex?: boolean; caseSensitive?: boolean; matchWholeWord?: boolean }
}
) =>
wrap(() =>
searchService.query(registry.getDb(bookId), {
text: query,
scope,
regex: options?.regex,
caseSensitive: options?.caseSensitive
})
)
)
ipcMain.handle(
IPC.SEARCH_REPLACE,
(
_e,
{
bookId,
query,
replacement,
scope,
dryRun,
options
}: {
bookId: string
query: string
replacement: string
scope?: SearchScope
dryRun: boolean
options?: { regex?: boolean; caseSensitive?: boolean }
}
) =>
wrap(() =>
searchService.replace(registry.getDb(bookId), {
text: query,
replacement,
dryRun,
regex: options?.regex,
caseSensitive: options?.caseSensitive
})
)
)
ipcMain.handle(IPC.SEARCH_GET_HISTORY, () => wrap(() => settings.get().searchHistory))
ipcMain.handle(IPC.SEARCH_SAVE_HISTORY, (_e, { query }: { query: string }) =>
wrap(() => {
const trimmed = query.trim()
if (!trimmed) return settings.get().searchHistory
const current = settings.get().searchHistory.filter((q) => q !== trimmed)
const next = [trimmed, ...current].slice(0, MAX_SEARCH_HISTORY)
settings.update({ searchHistory: next })
return next
})
)
ipcMain.handle(
IPC.WORDFREQ_ANALYZE,
(
_e,
{
bookId,
scope
}: {
bookId: string
scope: { kind: 'book' | 'volume' | 'chapter'; chapterId?: string; volumeId?: string }
}
) => wrap(() => wordFreqService.analyze(registry.getDb(bookId), scope))
)
}
+58
View File
@@ -0,0 +1,58 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SettingType } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerSettingHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.SETTING_LIST,
(_e, { bookId, type }: { bookId: string; type?: SettingType }) =>
wrap(() => registry.getSettingRepo(bookId).list(type))
)
ipcMain.handle(
IPC.SETTING_CREATE,
(_e, { bookId, type, name }: { bookId: string; type: SettingType; name: string }) =>
wrap(() => {
const item = registry.getSettingRepo(bookId).create(type, name)
ftsSync.upsert(registry.getDb(bookId), 'setting', item.id, item.name, item.description)
return item
})
)
ipcMain.handle(
IPC.SETTING_UPDATE,
(
_e,
{
bookId,
id,
patch
}: {
bookId: string
id: string
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
}
) =>
wrap(() => {
const item = registry.getSettingRepo(bookId).update(id, patch)
ftsSync.upsert(registry.getDb(bookId), 'setting', item.id, item.name, item.description)
return item
})
)
ipcMain.handle(IPC.SETTING_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
registry.getSettingRepo(bookId).delete(id)
ftsSync.remove(registry.getDb(bookId), 'setting', id)
})
)
ipcMain.handle(
IPC.SETTING_SYNC_REFS,
(_e, { bookId, id, chapterIds }: { bookId: string; id: string; chapterIds: string[] }) =>
wrap(() => registry.getSettingRepo(bookId).syncChapterRefs(id, chapterIds))
)
}
+76
View File
@@ -0,0 +1,76 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SnapshotType } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import type { SnapshotService } from '../../services/snapshot.service'
import { wrap } from '../result'
export function registerSnapshotHandlers(
registry: BookRegistryService,
snapshotService: SnapshotService
): void { ipcMain.handle(
IPC.SNAPSHOT_LIST,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => registry.getSnapshotRepo(bookId).list(chapterId))
)
ipcMain.handle(
IPC.SNAPSHOT_CREATE,
(
_e,
{
bookId,
chapterId,
type,
name,
content
}: { bookId: string; chapterId: string; type: SnapshotType; name?: string; content?: string }
) =>
wrap(() => {
const repo = registry.getSnapshotRepo(bookId)
const chapter = registry.getChapterRepo(bookId).get(chapterId)
if (!chapter) throw new Error('Chapter not found')
return repo.create(chapterId, type, content ?? chapter.content, name)
})
)
ipcMain.handle(
IPC.SNAPSHOT_RESTORE,
(_e, { bookId, chapterId, snapshotId }: { bookId: string; chapterId: string; snapshotId: string }) =>
wrap(() => {
const content = registry.getSnapshotRepo(bookId).restore(snapshotId)
return registry.getChapterRepo(bookId).update(chapterId, { content })
})
)
ipcMain.handle(
IPC.SNAPSHOT_DELETE,
(_e, { bookId, snapshotId }: { bookId: string; snapshotId: string }) =>
wrap(() => {
registry.getSnapshotRepo(bookId).delete(snapshotId)
})
)
ipcMain.handle(
IPC.SNAPSHOT_START_AUTO,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
const repo = registry.getSnapshotRepo(bookId)
const chapterRepo = registry.getChapterRepo(bookId)
snapshotService.startAutoSave(
bookId,
chapterId,
() => chapterRepo.get(chapterId)?.content ?? '',
repo
)
})
)
ipcMain.handle(
IPC.SNAPSHOT_STOP_AUTO,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
snapshotService.stopAutoSave(bookId, chapterId)
})
)
}
+19
View File
@@ -2,13 +2,21 @@ import { join } from 'path'
import { app } from 'electron'
import { GlobalSettingsService } from '../services/global-settings'
import { BookRegistryService } from '../services/book-registry'
import { SnapshotService } from '../services/snapshot.service'
import { ShortcutManager } from '../shortcuts/manager'
import { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler'
import { registerChapterHandlers } from './handlers/chapter.handler'
import { registerShortcutHandlers } from './handlers/shortcut.handler'
import { registerOutlineHandlers } from './handlers/outline.handler'
import { registerSettingHandlers } from './handlers/setting.handler'
import { registerInspirationHandlers } from './handlers/inspiration.handler'
import { registerSnapshotHandlers } from './handlers/snapshot.handler'
import { registerBookmarkHandlers } from './handlers/bookmark.handler'
import { registerSearchHandlers } from './handlers/search.handler'
let shortcutManager: ShortcutManager | null = null
let snapshotService: SnapshotService | null = null
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
const userData = app.getPath('userData')
@@ -16,11 +24,18 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
const settings = new GlobalSettingsService(userData)
const books = new BookRegistryService(userData)
shortcutManager = new ShortcutManager(settings)
snapshotService = new SnapshotService(() => settings.get())
registerSettingsHandlers(settings)
registerBookHandlers(books)
registerChapterHandlers(books)
registerShortcutHandlers(shortcutManager)
registerOutlineHandlers(books)
registerSettingHandlers(books)
registerInspirationHandlers(books)
registerSnapshotHandlers(books, snapshotService!)
registerBookmarkHandlers(books)
registerSearchHandlers(books, settings)
return { settings, books, shortcuts: shortcutManager }
}
@@ -28,3 +43,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
export function getShortcutManager(): ShortcutManager | null {
return shortcutManager
}
export function getSnapshotService(): SnapshotService | null {
return snapshotService
}
+36 -1
View File
@@ -4,6 +4,11 @@ import { randomUUID } from 'crypto'
import { openBookDb } from '../db/connection'
import { VolumeRepository } from '../db/repositories/volume.repo'
import { ChapterRepository } from '../db/repositories/chapter.repo'
import { OutlineRepository } from '../db/repositories/outline.repo'
import { SettingRepository } from '../db/repositories/setting.repo'
import { InspirationRepository } from '../db/repositories/inspiration.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'
interface RegistryFile {
@@ -99,9 +104,19 @@ export class BookRegistryService {
const db = openBookDb(join(this.userDataDir, meta.dbPath))
const volumes = new VolumeRepository(db).list()
const chapters = new ChapterRepository(db).list()
const outlines = new OutlineRepository(db).listFlat()
const settings = new SettingRepository(db).list()
const inspirations = new InspirationRepository(db).list()
const updated = this.updateMeta(bookId, { lastOpenedAt: new Date().toISOString() })
return { meta: updated, volumes, chapters }
return {
meta: updated,
volumes,
chapters,
outlines,
settings,
inspirations
}
}
getDb(bookId: string) {
@@ -117,6 +132,26 @@ export class BookRegistryService {
getChapterRepo(bookId: string): ChapterRepository {
return new ChapterRepository(this.getDb(bookId))
}
getOutlineRepo(bookId: string): OutlineRepository {
return new OutlineRepository(this.getDb(bookId))
}
getSettingRepo(bookId: string): SettingRepository {
return new SettingRepository(this.getDb(bookId))
}
getInspirationRepo(bookId: string): InspirationRepository {
return new InspirationRepository(this.getDb(bookId))
}
getSnapshotRepo(bookId: string): SnapshotRepository {
return new SnapshotRepository(this.getDb(bookId))
}
getBookmarkRepo(bookId: string): BookmarkRepository {
return new BookmarkRepository(this.getDb(bookId))
}
}
export function aggregateWordCounts(chapters: Chapter[], volumes: Volume[]): {
+68
View File
@@ -0,0 +1,68 @@
import type { SearchSourceKind } from '../../shared/types'
import { stripHtml } from './word-count'
import type { SqliteDb } from '../db/types'
export class FtsSyncService {
upsert(db: SqliteDb, kind: SearchSourceKind, id: string, title: string, body: string): void {
const plainBody = stripHtml(body)
db.prepare('DELETE FROM search_fts WHERE source_kind = ? AND source_id = ?').run(kind, id)
db.prepare(
'INSERT INTO search_fts (source_kind, source_id, title, body) VALUES (?, ?, ?, ?)'
).run(kind, id, title, plainBody)
}
remove(db: SqliteDb, kind: SearchSourceKind, id: string): void {
db.prepare('DELETE FROM search_fts WHERE source_kind = ? AND source_id = ?').run(kind, id)
}
rebuildAll(db: SqliteDb): void {
db.exec('DELETE FROM search_fts')
const chapters = db.prepare('SELECT id, title, content FROM chapters').all() as {
id: string
title: string
content: string
}[]
for (const ch of chapters) {
this.upsert(db, 'chapter', ch.id, ch.title, ch.content)
}
const outlines = db.prepare('SELECT id, title, description FROM outline_items').all() as {
id: string
title: string
description: string
}[]
for (const o of outlines) {
this.upsert(db, 'outline', o.id, o.title, o.description)
}
const settings = db.prepare('SELECT id, name, description FROM settings').all() as {
id: string
name: string
description: string
}[]
for (const s of settings) {
this.upsert(db, 'setting', s.id, s.name, s.description)
}
const inspirations = db.prepare('SELECT id, title, content FROM inspiration').all() as {
id: string
title: string
content: string
}[]
for (const i of inspirations) {
this.upsert(db, 'inspiration', i.id, i.title, i.content)
}
const bookmarks = db.prepare('SELECT id, label, chapter_id FROM bookmarks').all() as {
id: string
label: string
chapter_id: string
}[]
for (const b of bookmarks) {
this.upsert(db, 'bookmark', b.id, b.label, b.label)
}
}
}
export const ftsSync = new FtsSyncService()
+5 -1
View File
@@ -12,7 +12,11 @@ function defaults(): GlobalSettings {
theme: 'default',
language: 'zh-CN',
dailyWordGoal: 0,
shortcuts: { ...DEFAULT_SHORTCUTS }
shortcuts: { ...DEFAULT_SHORTCUTS },
snapshotIntervalMs: 300_000,
snapshotMaxAuto: 20,
snapshotMaxPersist: 3,
searchHistory: []
}
}
+159
View File
@@ -0,0 +1,159 @@
import type { SearchResult, SearchSourceKind } from '../../shared/types'
import type { SqliteDb } from '../db/types'
export interface SearchQueryParams {
text: string
scope?: 'all' | 'volume' | 'chapter'
regex?: boolean
caseSensitive?: boolean
chapterId?: string
}
export interface SearchReplaceParams {
text: string
replacement: string
dryRun: boolean
regex?: boolean
caseSensitive?: boolean
}
function makeSnippet(body: string, query: string, caseSensitive: boolean): string {
const hay = caseSensitive ? body : body.toLowerCase()
const needle = caseSensitive ? query : query.toLowerCase()
const idx = hay.indexOf(needle)
if (idx === -1) return body.slice(0, 40)
const start = Math.max(0, idx - 20)
const end = Math.min(body.length, idx + query.length + 20)
return body.slice(start, end)
}
function escapeFtsTerm(term: string): string {
return `"${term.replace(/"/g, '""')}"`
}
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export class SearchService {
query(db: SqliteDb, params: SearchQueryParams): SearchResult[] {
const text = params.text.trim()
if (!text) return []
if (params.regex) {
return this.queryRegex(db, text, params.caseSensitive ?? false)
}
try {
const ftsQuery = text.split(/\s+/).filter(Boolean).map(escapeFtsTerm).join(' ')
const rows = db
.prepare(
`SELECT source_kind, source_id, title, body FROM search_fts WHERE search_fts MATCH ?`
)
.all(ftsQuery) as { source_kind: string; source_id: string; title: string; body: string }[]
if (rows.length > 0) {
return rows.map((row) => ({
kind: row.source_kind as SearchSourceKind,
id: row.source_id,
title: row.title,
snippet: makeSnippet(row.body, text, params.caseSensitive ?? false)
}))
}
} catch {
/* fallback below */
}
return this.queryLike(db, text, params.caseSensitive ?? false)
}
private queryLike(db: SqliteDb, text: string, caseSensitive: boolean): SearchResult[] {
const rows = db.prepare('SELECT source_kind, source_id, title, body FROM search_fts').all() as {
source_kind: string
source_id: string
title: string
body: string
}[]
return rows
.filter((row) => {
const hay = caseSensitive ? row.body : row.body.toLowerCase()
const needle = caseSensitive ? text : text.toLowerCase()
const titleHay = caseSensitive ? row.title : row.title.toLowerCase()
return hay.includes(needle) || titleHay.includes(needle)
})
.map((row) => ({
kind: row.source_kind as SearchSourceKind,
id: row.source_id,
title: row.title,
snippet: makeSnippet(row.body, text, caseSensitive)
}))
}
private queryRegex(db: SqliteDb, pattern: string, caseSensitive: boolean): SearchResult[] {
let re: RegExp
try {
re = new RegExp(pattern, caseSensitive ? '' : 'i')
} catch {
return []
}
const rows = db.prepare('SELECT source_kind, source_id, title, body FROM search_fts').all() as {
source_kind: string
source_id: string
title: string
body: string
}[]
return rows
.filter((row) => re.test(row.body) || re.test(row.title))
.map((row) => ({
kind: row.source_kind as SearchSourceKind,
id: row.source_id,
title: row.title,
snippet: makeSnippet(row.body, pattern, caseSensitive)
}))
}
replace(
db: SqliteDb,
params: SearchReplaceParams
): { count: number; previews: SearchResult[] } {
const results = this.query(db, {
text: params.text,
regex: params.regex,
caseSensitive: params.caseSensitive
})
if (params.dryRun) {
return { count: results.length, previews: results.slice(0, 10) }
}
let count = 0
const flags = params.caseSensitive ? 'g' : 'gi'
const re = params.regex
? new RegExp(params.text, flags)
: new RegExp(escapeRegex(params.text), flags)
for (const hit of results) {
if (hit.kind === 'chapter') {
const row = db.prepare('SELECT content FROM chapters WHERE id = ?').get(hit.id) as
| { content: string }
| undefined
if (!row) continue
const next = row.content.replace(re, params.replacement)
if (next !== row.content) {
db.prepare(`UPDATE chapters SET content = ?, updated_at = datetime('now') WHERE id = ?`).run(
next,
hit.id
)
count++
}
}
}
return { count, previews: results.slice(0, 10) }
}
}
export const searchService = new SearchService()
+72
View File
@@ -0,0 +1,72 @@
import type { GlobalSettings } from '../../shared/types'
import type { SnapshotRepository } from '../db/repositories/snapshot.repo'
interface AutoSaveSession {
bookId: string
chapterId: string
getContent: () => string
lastContent: string
timer: ReturnType<typeof setInterval>
}
export class SnapshotService {
private sessions = new Map<string, AutoSaveSession>()
constructor(private getSettings: () => GlobalSettings) {}
private sessionKey(bookId: string, chapterId: string): string {
return `${bookId}:${chapterId}`
}
startAutoSave(bookId: string, chapterId: string, getContent: () => string, repo: SnapshotRepository): void {
const key = this.sessionKey(bookId, chapterId)
this.stopAutoSave(bookId, chapterId)
const settings = this.getSettings()
const lastContent = getContent()
const timer = setInterval(() => {
const content = getContent()
const session = this.sessions.get(key)
if (!session || content === session.lastContent) return
repo.create(chapterId, 'auto', content)
repo.pruneAuto(chapterId, settings.snapshotMaxAuto)
session.lastContent = content
}, settings.snapshotIntervalMs)
this.sessions.set(key, { bookId, chapterId, getContent, lastContent, timer })
}
stopAutoSave(bookId: string, chapterId: string): void {
const key = this.sessionKey(bookId, chapterId)
const session = this.sessions.get(key)
if (!session) return
clearInterval(session.timer)
this.sessions.delete(key)
}
onPersistSave(chapterId: string, content: string, repo: SnapshotRepository): void {
const settings = this.getSettings()
repo.create(chapterId, 'persist', content)
repo.prunePersist(chapterId, settings.snapshotMaxPersist)
}
flushAllPersist(repos: Map<string, SnapshotRepository>): void {
for (const session of this.sessions.values()) {
const repo = repos.get(session.bookId)
if (!repo) continue
const content = session.getContent()
if (content !== session.lastContent) {
this.onPersistSave(session.chapterId, content, repo)
session.lastContent = content
}
}
}
stopAll(): void {
for (const session of this.sessions.values()) {
clearInterval(session.timer)
}
this.sessions.clear()
}
}
+5 -1
View File
@@ -1,5 +1,9 @@
export function stripHtml(text: string): string {
return text.replace(/<[^>]+>/g, '')
}
export function countWords(text: string): number {
const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '')
const stripped = stripHtml(text).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)
+64
View File
@@ -0,0 +1,64 @@
import { stripHtml } from './word-count'
import type { WordFreqResult } from '../../shared/types'
import type { SqliteDb } from '../db/types'
export interface WordFreqScope {
kind: 'book' | 'volume' | 'chapter'
chapterId?: string
volumeId?: string
}
const TOP_N = 100
export class WordFreqService {
analyze(db: SqliteDb, scope: WordFreqScope): WordFreqResult {
const texts = this.collectTexts(db, scope)
const combined = texts.join('\n')
const plain = stripHtml(combined)
const counts = new Map<string, number>()
const cjk = plain.match(/[\u4e00-\u9fff]/g)
if (cjk) {
for (const ch of cjk) {
counts.set(ch, (counts.get(ch) ?? 0) + 1)
}
}
const latin = plain.match(/\b[a-zA-Z]{2,}\b/g)
if (latin) {
for (const word of latin) {
const key = word.toLowerCase()
counts.set(key, (counts.get(key) ?? 0) + 1)
}
}
const words = [...counts.entries()]
.map(([token, count]) => ({ token, count }))
.sort((a, b) => b.count - a.count)
.slice(0, TOP_N)
return { words }
}
private collectTexts(db: SqliteDb, scope: WordFreqScope): string[] {
if (scope.kind === 'chapter' && scope.chapterId) {
const row = db.prepare('SELECT content FROM chapters WHERE id = ?').get(scope.chapterId) as
| { content: string }
| undefined
return row ? [row.content] : []
}
if (scope.kind === 'volume' && scope.volumeId) {
const rows = db
.prepare('SELECT content FROM chapters WHERE volume_id = ?')
.all(scope.volumeId) as { content: string }[]
return rows.map((r) => r.content)
}
const rows = db.prepare('SELECT content FROM chapters').all() as { content: string }[]
return rows.map((r) => r.content)
}
}
export const wordFreqService = new WordFreqService()
+163 -1
View File
@@ -2,14 +2,25 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
import { IPC } from '../shared/ipc-channels'
import type {
ActionId,
Bookmark,
BookMeta,
BookOpenResult,
Chapter,
CreateBookParams,
GlobalSettings,
InspirationEntry,
IpcResult,
LandmarkType,
OutlineItem,
OutlineStatus,
SearchResult,
SettingEntry,
SettingType,
Snapshot,
SnapshotType,
UpdateChapterParams,
Volume
Volume,
WordFreqResult
} from '../shared/types'
const electronAPI = {
@@ -58,6 +69,157 @@ const electronAPI = {
delete: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId })
},
outline: {
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
create: (
bookId: string,
title: string,
parentId?: string | null,
sortOrder?: number
): Promise<IpcResult<OutlineItem>> =>
ipcRenderer.invoke(IPC.OUTLINE_CREATE, { bookId, parentId, title, sortOrder }),
update: (
bookId: string,
id: string,
patch: Partial<{
title: string
description: string
status: OutlineStatus
expectedWordCount: number | null
chapterId: string | null
sortOrder: number
}>
): Promise<IpcResult<OutlineItem>> =>
ipcRenderer.invoke(IPC.OUTLINE_UPDATE, { bookId, id, patch }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.OUTLINE_DELETE, { bookId, id }),
move: (
bookId: string,
id: string,
parentId: string | null,
sortOrder: number
): Promise<IpcResult<OutlineItem>> =>
ipcRenderer.invoke(IPC.OUTLINE_MOVE, { bookId, id, parentId, sortOrder })
},
setting: {
list: (bookId: string, type?: SettingType): Promise<IpcResult<SettingEntry[]>> =>
ipcRenderer.invoke(IPC.SETTING_LIST, { bookId, type }),
create: (bookId: string, type: SettingType, name: string): Promise<IpcResult<SettingEntry>> =>
ipcRenderer.invoke(IPC.SETTING_CREATE, { bookId, type, name }),
update: (
bookId: string,
id: string,
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
): Promise<IpcResult<SettingEntry>> =>
ipcRenderer.invoke(IPC.SETTING_UPDATE, { bookId, id, patch }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.SETTING_DELETE, { bookId, id }),
syncRefs: (bookId: string, id: string, chapterIds: string[]): Promise<IpcResult<SettingEntry>> =>
ipcRenderer.invoke(IPC.SETTING_SYNC_REFS, { bookId, id, chapterIds })
},
inspiration: {
list: (bookId: string): Promise<IpcResult<InspirationEntry[]>> =>
ipcRenderer.invoke(IPC.INSPIRATION_LIST, { bookId }),
create: (
bookId: string,
title?: string,
content?: string,
tags?: string[]
): Promise<IpcResult<InspirationEntry>> =>
ipcRenderer.invoke(IPC.INSPIRATION_CREATE, { bookId, title, content, tags }),
update: (
bookId: string,
id: string,
patch: Partial<{ title: string; content: string; tags: string[] }>
): Promise<IpcResult<InspirationEntry>> =>
ipcRenderer.invoke(IPC.INSPIRATION_UPDATE, { bookId, id, patch }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.INSPIRATION_DELETE, { bookId, id }),
convert: (
bookId: string,
id: string,
targetKind: 'outline' | 'setting',
settingType?: SettingType
): Promise<IpcResult<OutlineItem | SettingEntry>> =>
ipcRenderer.invoke(IPC.INSPIRATION_CONVERT, { bookId, id, targetKind, settingType })
},
snapshot: {
list: (bookId: string, chapterId: string): Promise<IpcResult<Snapshot[]>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_LIST, { bookId, chapterId }),
create: (
bookId: string,
chapterId: string,
type: SnapshotType,
name?: string,
content?: string
): Promise<IpcResult<Snapshot>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_CREATE, { bookId, chapterId, type, name, content }),
restore: (
bookId: string,
chapterId: string,
snapshotId: string
): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_RESTORE, { bookId, chapterId, snapshotId }),
delete: (bookId: string, snapshotId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_DELETE, { bookId, snapshotId }),
startAutoSave: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_START_AUTO, { bookId, chapterId }),
stopAutoSave: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_STOP_AUTO, { bookId, chapterId })
},
bookmark: {
list: (
bookId: string,
chapterId?: string,
resolved?: boolean
): Promise<IpcResult<Bookmark[]>> =>
ipcRenderer.invoke(IPC.BOOKMARK_LIST, { bookId, chapterId, resolved }),
create: (
bookId: string,
chapterId: string,
position: number,
label: string,
landmarkType?: LandmarkType
): Promise<IpcResult<Bookmark>> =>
ipcRenderer.invoke(IPC.BOOKMARK_CREATE, { bookId, chapterId, position, label, landmarkType }),
update: (
bookId: string,
id: string,
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
): Promise<IpcResult<Bookmark>> =>
ipcRenderer.invoke(IPC.BOOKMARK_UPDATE, { bookId, id, patch }),
resolve: (bookId: string, id: string, resolved: boolean): Promise<IpcResult<Bookmark>> =>
ipcRenderer.invoke(IPC.BOOKMARK_RESOLVE, { bookId, id, resolved }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.BOOKMARK_DELETE, { bookId, id })
},
search: {
query: (
bookId: string,
query: string,
options?: { regex?: boolean; caseSensitive?: boolean; matchWholeWord?: boolean }
): Promise<IpcResult<SearchResult[]>> =>
ipcRenderer.invoke(IPC.SEARCH_QUERY, { bookId, query, options }),
replace: (
bookId: string,
query: string,
replacement: string,
dryRun: boolean,
options?: { regex?: boolean; caseSensitive?: boolean }
): Promise<IpcResult<{ count: number; previews: SearchResult[] }>> =>
ipcRenderer.invoke(IPC.SEARCH_REPLACE, { bookId, query, replacement, dryRun, options }),
getHistory: (): Promise<IpcResult<string[]>> => ipcRenderer.invoke(IPC.SEARCH_GET_HISTORY),
saveHistory: (query: string): Promise<IpcResult<string[]>> =>
ipcRenderer.invoke(IPC.SEARCH_SAVE_HISTORY, { query })
},
wordfreq: {
analyze: (
bookId: string,
scope: { kind: 'book' | 'volume' | 'chapter'; chapterId?: string; volumeId?: string }
): Promise<IpcResult<WordFreqResult>> =>
ipcRenderer.invoke(IPC.WORDFREQ_ANALYZE, { bookId, scope })
},
shortcut: {
getAll: (): Promise<IpcResult<Record<ActionId, string>>> =>
ipcRenderer.invoke(IPC.SHORTCUT_GET_ALL),
+62 -1
View File
@@ -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>
)
+292 -84
View File
@@ -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>
)
}
+162 -36
View File
@@ -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>
)
}
+11 -5
View File
@@ -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>
)
}
+27
View File
@@ -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}]`
]
}
})
+27
View File
@@ -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}`
]
}
})
+23
View File
@@ -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)
}
+36
View File
@@ -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)
}
+8
View File
@@ -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)
+63 -2
View File
@@ -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) }))
}
}))
+27
View File
@@ -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 })
}))
+28
View File
@@ -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 })
}))
+30
View File
@@ -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]
})
}
}))
+23
View File
@@ -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 })
}))
+20 -1
View File
@@ -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 {
+474 -2
View File
@@ -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);
}
+66
View File
@@ -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;
}
+125 -1
View File
@@ -1,13 +1,24 @@
import type {
ActionId,
Bookmark,
BookMeta,
BookOpenResult,
Chapter,
CreateBookParams,
GlobalSettings,
InspirationEntry,
IpcResult,
LandmarkType,
OutlineItem,
OutlineStatus,
SearchResult,
SettingEntry,
SettingType,
Snapshot,
SnapshotType,
UpdateChapterParams,
Volume
Volume,
WordFreqResult
} from './types'
export interface ElectronAPI {
@@ -45,6 +56,119 @@ export interface ElectronAPI {
update: (params: UpdateChapterParams) => Promise<IpcResult<Chapter>>
delete: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
}
outline: {
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
create: (
bookId: string,
title: string,
parentId?: string | null,
sortOrder?: number
) => Promise<IpcResult<OutlineItem>>
update: (
bookId: string,
id: string,
patch: Partial<{
title: string
description: string
status: OutlineStatus
expectedWordCount: number | null
chapterId: string | null
sortOrder: number
}>
) => Promise<IpcResult<OutlineItem>>
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
move: (
bookId: string,
id: string,
parentId: string | null,
sortOrder: number
) => Promise<IpcResult<OutlineItem>>
}
setting: {
list: (bookId: string, type?: SettingType) => Promise<IpcResult<SettingEntry[]>>
create: (bookId: string, type: SettingType, name: string) => Promise<IpcResult<SettingEntry>>
update: (
bookId: string,
id: string,
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
) => Promise<IpcResult<SettingEntry>>
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
syncRefs: (bookId: string, id: string, chapterIds: string[]) => Promise<IpcResult<SettingEntry>>
}
inspiration: {
list: (bookId: string) => Promise<IpcResult<InspirationEntry[]>>
create: (
bookId: string,
title?: string,
content?: string,
tags?: string[]
) => Promise<IpcResult<InspirationEntry>>
update: (
bookId: string,
id: string,
patch: Partial<{ title: string; content: string; tags: string[] }>
) => Promise<IpcResult<InspirationEntry>>
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
convert: (
bookId: string,
id: string,
targetKind: 'outline' | 'setting',
settingType?: SettingType
) => Promise<IpcResult<OutlineItem | SettingEntry>>
}
snapshot: {
list: (bookId: string, chapterId: string) => Promise<IpcResult<Snapshot[]>>
create: (
bookId: string,
chapterId: string,
type: SnapshotType,
name?: string,
content?: string
) => Promise<IpcResult<Snapshot>>
restore: (bookId: string, chapterId: string, snapshotId: string) => Promise<IpcResult<Chapter>>
delete: (bookId: string, snapshotId: string) => Promise<IpcResult<void>>
startAutoSave: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
stopAutoSave: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
}
bookmark: {
list: (bookId: string, chapterId?: string, resolved?: boolean) => Promise<IpcResult<Bookmark[]>>
create: (
bookId: string,
chapterId: string,
position: number,
label: string,
landmarkType?: LandmarkType
) => Promise<IpcResult<Bookmark>>
update: (
bookId: string,
id: string,
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
) => Promise<IpcResult<Bookmark>>
resolve: (bookId: string, id: string, resolved: boolean) => Promise<IpcResult<Bookmark>>
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
}
search: {
query: (
bookId: string,
query: string,
options?: { regex?: boolean; caseSensitive?: boolean; matchWholeWord?: boolean }
) => Promise<IpcResult<SearchResult[]>>
replace: (
bookId: string,
query: string,
replacement: string,
dryRun: boolean,
options?: { regex?: boolean; caseSensitive?: boolean }
) => Promise<IpcResult<{ count: number; previews: SearchResult[] }>>
getHistory: () => Promise<IpcResult<string[]>>
saveHistory: (query: string) => Promise<IpcResult<string[]>>
}
wordfreq: {
analyze: (
bookId: string,
scope: { kind: 'book' | 'volume' | 'chapter'; chapterId?: string; volumeId?: string }
) => Promise<IpcResult<WordFreqResult>>
}
shortcut: {
getAll: () => Promise<IpcResult<Record<ActionId, string>>>
register: (action: ActionId, accelerator: string) => Promise<IpcResult<void>>
+32 -1
View File
@@ -18,5 +18,36 @@ export const IPC = {
SHORTCUT_TRIGGERED: 'shortcut:triggered',
WINDOW_MINIMIZE: 'window:minimize',
WINDOW_MAXIMIZE: 'window:maximize',
WINDOW_CLOSE: 'window:close'
WINDOW_CLOSE: 'window:close',
OUTLINE_LIST: 'outline:list',
OUTLINE_CREATE: 'outline:create',
OUTLINE_UPDATE: 'outline:update',
OUTLINE_DELETE: 'outline:delete',
OUTLINE_MOVE: 'outline:move',
SETTING_LIST: 'setting:list',
SETTING_CREATE: 'setting:create',
SETTING_UPDATE: 'setting:update',
SETTING_DELETE: 'setting:delete',
SETTING_SYNC_REFS: 'setting:syncRefs',
INSPIRATION_LIST: 'inspiration:list',
INSPIRATION_CREATE: 'inspiration:create',
INSPIRATION_UPDATE: 'inspiration:update',
INSPIRATION_DELETE: 'inspiration:delete',
INSPIRATION_CONVERT: 'inspiration:convert',
SNAPSHOT_LIST: 'snapshot:list',
SNAPSHOT_CREATE: 'snapshot:create',
SNAPSHOT_RESTORE: 'snapshot:restore',
SNAPSHOT_DELETE: 'snapshot:delete',
SNAPSHOT_START_AUTO: 'snapshot:startAuto',
SNAPSHOT_STOP_AUTO: 'snapshot:stopAuto',
BOOKMARK_LIST: 'bookmark:list',
BOOKMARK_CREATE: 'bookmark:create',
BOOKMARK_UPDATE: 'bookmark:update',
BOOKMARK_RESOLVE: 'bookmark:resolve',
BOOKMARK_DELETE: 'bookmark:delete',
SEARCH_QUERY: 'search:query',
SEARCH_REPLACE: 'search:replace',
SEARCH_GET_HISTORY: 'search:getHistory',
SEARCH_SAVE_HISTORY: 'search:saveHistory',
WORDFREQ_ANALYZE: 'wordfreq:analyze'
} as const
+92
View File
@@ -10,6 +10,15 @@ export type ThemeId =
export type Language = 'zh-CN' | 'en'
export type ChapterStatus = 'draft' | 'review' | 'done'
export type BookStatus = 'draft' | 'ongoing' | 'done'
export type PublishStatus = 'draft' | 'ready' | 'published'
export type OutlineStatus = 'pending' | 'writing' | 'done' | 'abandoned'
export type SettingType = 'character' | 'location' | 'item' | 'skill' | 'faction' | 'custom'
export type SnapshotType = 'auto' | 'manual' | 'persist' | 'ai'
export type LandmarkType = 'todo' | 'foreshadow' | 'research' | 'custom'
export type EditTargetKind = 'chapter' | 'outline' | 'setting' | 'inspiration'
export type EditTarget = { kind: EditTargetKind; id: string }
export type SearchScope = 'all' | 'volume' | 'chapter'
export type SearchSourceKind = 'chapter' | 'outline' | 'setting' | 'inspiration' | 'bookmark'
export type ActionId =
| 'saveChapter'
@@ -47,6 +56,10 @@ export interface GlobalSettings {
language: Language
dailyWordGoal: number
shortcuts: Record<ActionId, string>
snapshotIntervalMs: number
snapshotMaxAuto: number
snapshotMaxPersist: number
searchHistory: string[]
}
export interface BookMeta {
@@ -84,12 +97,91 @@ export interface Chapter {
wordCount: number
sortOrder: number
cursorOffset: number
publishStatus?: PublishStatus
povCharacterId?: string | null
summary?: string
storyTime?: string | null
}
export interface OutlineItem {
id: string
parentId: string | null
title: string
description: string
status: OutlineStatus
expectedWordCount: number | null
chapterId: string | null
sortOrder: number
createdAt: string
updatedAt: string
children?: OutlineItem[]
}
export interface SettingEntry {
id: string
type: SettingType
name: string
description: string
properties: Record<string, unknown>
chapterIds?: string[]
createdAt: string
updatedAt: string
}
export interface InspirationEntry {
id: string
title: string
content: string
tags: string[]
createdAt: string
updatedAt: string
}
export interface Snapshot {
id: string
chapterId: string
type: SnapshotType
name: string
content: string
createdAt: string
}
export interface Bookmark {
id: string
chapterId: string
position: number
label: string
landmarkType: LandmarkType
resolved: boolean
createdAt: string
}
export interface SearchOptions {
regex?: boolean
caseSensitive?: boolean
matchWholeWord?: boolean
}
export interface SearchResult {
kind: SearchSourceKind
id: string
title: string
snippet: string
chapterId?: string
position?: number
}
export interface WordFreqResult {
words: { token: string; count: number }[]
}
export interface BookOpenResult {
meta: BookMeta
volumes: Volume[]
chapters: Chapter[]
outlines: OutlineItem[]
settings: SettingEntry[]
inspirations: InspirationEntry[]
}
export interface UpdateChapterParams {