feat: ship v0.3.0 with P2.1 editor polish and P3 AI collaboration

Add chapter reorder, search replace, inspiration capture, and AI chat with context editing, settings, offline handling, and naming checks. Fix dev black screen by keeping process.env reads in the main process only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 16:33:56 +08:00
parent 0b5aa146bd
commit 8ce35a1e8e
59 changed files with 3808 additions and 100 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAtomValue } from 'jotai'
import { useAppStore } from '@renderer/stores/useAppStore'
@@ -122,6 +122,55 @@ export function EditorLayout(): React.JSX.Element {
setActiveVolume(vol.id)
}
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
const handleChapterDropOnItem = async (
draggedId: string,
targetVolumeId: string,
targetChapterId: string
): Promise<void> => {
if (!currentBookId || draggedId === targetChapterId) return
await flushEditorSave()
const dragged = chapters.find((c) => c.id === draggedId)
if (!dragged) return
const volChapters = chapters
.filter((c) => c.volumeId === targetVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
const targetIdx = volChapters.findIndex((c) => c.id === targetChapterId)
if (dragged.volumeId === targetVolumeId) {
const ids = volChapters.map((c) => c.id)
const from = ids.indexOf(draggedId)
if (from < 0) return
ids.splice(from, 1)
const insertAt = ids.indexOf(targetChapterId)
ids.splice(insertAt, 0, draggedId)
await ipcCall(() => window.electronAPI.chapter.reorder(currentBookId, targetVolumeId, ids))
} else {
await ipcCall(() =>
window.electronAPI.chapter.moveToVolume(
currentBookId,
draggedId,
targetVolumeId,
Math.max(0, targetIdx)
)
)
}
await refreshChapters()
}
const handleChapterDropOnVolume = async (draggedId: string, targetVolumeId: string): Promise<void> => {
if (!currentBookId) return
await flushEditorSave()
const dragged = chapters.find((c) => c.id === draggedId)
if (!dragged) return
const targetLen = chapters.filter((c) => c.volumeId === targetVolumeId).length
if (dragged.volumeId === targetVolumeId) return
await ipcCall(() =>
window.electronAPI.chapter.moveToVolume(currentBookId, draggedId, targetVolumeId, targetLen)
)
await refreshChapters()
}
const handleInsertLandmark = async (): Promise<void> => {
if (!currentBookId || target?.kind !== 'chapter') {
showToast(t('landmark.chapterOnly'))
@@ -163,7 +212,14 @@ export function EditorLayout(): React.JSX.Element {
<div key={vol.id}>
<div
className="vol-header"
data-volume-id={vol.id}
onClick={() => setActiveVolume(vol.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
if (draggedId) void handleChapterDropOnVolume(draggedId, vol.id)
}}
style={{ color: activeVolumeId === vol.id ? 'var(--accent-light)' : undefined }}
>
{vol.name}
@@ -173,7 +229,24 @@ export function EditorLayout(): React.JSX.Element {
.map((ch, idx) => (
<div
key={ch.id}
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''}`}
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''} ${dragOverChapterId === ch.id ? 'drag-over' : ''}`}
draggable
data-testid={`chapter-item-${ch.id}`}
onDragStart={(e) => {
e.dataTransfer.setData('application/x-bilin-chapter', ch.id)
e.dataTransfer.effectAllowed = 'move'
}}
onDragOver={(e) => {
e.preventDefault()
setDragOverChapterId(ch.id)
}}
onDragLeave={() => setDragOverChapterId(null)}
onDrop={(e) => {
e.preventDefault()
setDragOverChapterId(null)
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
}}
onClick={() => void handleSelectChapter(ch.id)}
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
role="button"