feat(w3): add continuous read mode and Wave 3 plan

Introduce ReadModeOverlay with device width and font theme persistence, sidebar and volume menu entry, plus E2E-READ-01 through 03.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 16:38:54 +08:00
parent fe6e411d2c
commit c10be87d18
13 changed files with 653 additions and 2 deletions
+2
View File
@@ -19,6 +19,7 @@ import { SearchModal } from '@renderer/components/search/SearchModal'
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
import { ReadModeOverlay } from '@renderer/components/read/ReadModeOverlay'
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import { useSearchStore } from '@renderer/stores/useSearchStore'
@@ -206,6 +207,7 @@ function AppInner(): React.JSX.Element {
{view === 'settings' && <SettingsPage />}
</div>
<FocusModeOverlay />
<ReadModeOverlay />
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
<SearchModal />
<InspirationModal />
@@ -28,6 +28,7 @@ import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeMod
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
import { ChapterMetaPanel } from '@renderer/components/chapter/ChapterMetaPanel'
import { VolumeContextMenu } from '@renderer/components/volume/VolumeContextMenu'
import { useReadModeStore } from '@renderer/stores/useReadModeStore'
import { ExportModal } from '@renderer/components/export/ExportModal'
import { useExportStore } from '@renderer/stores/useExportStore'
import {
@@ -124,6 +125,7 @@ export function EditorLayout(): React.JSX.Element {
const [bridgeOpen, setBridgeOpen] = useState(false)
const [templateModalOpen, setTemplateModalOpen] = useState(false)
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
const openReadMode = useReadModeStore((s) => s.openForVolume)
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
null
)
@@ -497,6 +499,22 @@ export function EditorLayout(): React.JSX.Element {
>
+ {t('template.quickNew')}
</button>
<button
type="button"
data-testid="chapter-read-mode"
style={{ marginTop: 6 }}
disabled={!activeVolumeId}
onClick={() => {
if (!activeVolumeId) return
const idx = chapters
.filter((c) => c.volumeId === activeVolumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
.findIndex((c) => c.id === selectedChapterId)
openReadMode(activeVolumeId, Math.max(0, idx))
}}
>
👁 {t('readMode.open')}
</button>
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
+ {t('editor.newVolume')}
</button>
@@ -710,6 +728,7 @@ export function EditorLayout(): React.JSX.Element {
hasChapters={chapters.some((c) => c.volumeId === volumeMenuVolume.id)}
x={volumeMenu.x}
y={volumeMenu.y}
onOpenReadMode={() => openReadMode(volumeMenuVolume.id, 0)}
onClose={() => setVolumeMenu(null)}
onChanged={refreshChapters}
/>
@@ -0,0 +1,216 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { Chapter } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useReadModeStore } from '@renderer/stores/useReadModeStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
function htmlToPlain(html: string): string {
const div = document.createElement('div')
div.innerHTML = html
return (div.textContent ?? '').replace(/\s+/g, ' ').trim()
}
function paragraphsFromHtml(html: string): string[] {
const div = document.createElement('div')
div.innerHTML = html
const blocks = div.querySelectorAll('p, h1, h2, h3, li')
if (blocks.length === 0) {
const text = htmlToPlain(html)
return text ? text.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean) : []
}
return Array.from(blocks)
.map((el) => (el.textContent ?? '').trim())
.filter(Boolean)
}
function ChapterSection({
chapter,
chapterNumber
}: {
chapter: Chapter
chapterNumber: number
}): React.JSX.Element {
const paragraphs = useMemo(() => paragraphsFromHtml(chapter.content), [chapter.content])
return (
<section className="read-mode-chapter" data-testid={`read-chapter-${chapter.id}`}>
<h2 className="read-mode-chapter-title">
{chapterNumber}. {chapter.title}
</h2>
{paragraphs.map((p, i) => (
<p key={i} className="read-mode-paragraph">
{p}
</p>
))}
</section>
)
}
export function ReadModeOverlay(): React.JSX.Element | null {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const bookName = useBookStore((s) => s.books.find((b) => b.id === s.currentBookId)?.name)
const chapters = useBookStore((s) => s.chapters)
const volumes = useBookStore((s) => s.volumes)
const open = useReadModeStore((s) => s.open)
const volumeId = useReadModeStore((s) => s.volumeId)
const chapterIndex = useReadModeStore((s) => s.chapterIndex)
const setChapterIndex = useReadModeStore((s) => s.setChapterIndex)
const close = useReadModeStore((s) => s.close)
const fontTheme = useSettingsStore((s) => s.readModeFontTheme ?? 'serif')
const deviceWidth = useSettingsStore((s) => s.readModeDeviceWidth ?? 'desktop')
const language = useSettingsStore((s) => s.language)
const updateSettings = useSettingsStore((s) => s.update)
const scrollRef = useRef<HTMLDivElement>(null)
const volumeChapters = useMemo(() => {
if (!volumeId) return []
return chapters
.filter((c) => c.volumeId === volumeId)
.sort((a, b) => a.sortOrder - b.sortOrder)
}, [chapters, volumeId])
const volumeName = volumes.find((v) => v.id === volumeId)?.name ?? ''
const visibleIndices = useMemo(() => {
const indices = [chapterIndex - 1, chapterIndex, chapterIndex + 1].filter(
(i) => i >= 0 && i < volumeChapters.length
)
return indices
}, [chapterIndex, volumeChapters.length])
const goPrev = useCallback(() => {
if (chapterIndex > 0) setChapterIndex(chapterIndex - 1)
}, [chapterIndex, setChapterIndex])
const goNext = useCallback(() => {
if (chapterIndex < volumeChapters.length - 1) setChapterIndex(chapterIndex + 1)
}, [chapterIndex, volumeChapters.length, setChapterIndex])
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
close()
return
}
if (e.key === 'ArrowUp' || e.key === 'PageUp') {
e.preventDefault()
goPrev()
}
if (e.key === 'ArrowDown' || e.key === 'PageDown') {
e.preventDefault()
goNext()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, close, goPrev, goNext])
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 })
}, [chapterIndex])
const handleTts = (): void => {
const ch = volumeChapters[chapterIndex]
if (!ch) return
if (isSpeaking()) {
stopSpeaking()
showToast(t('tts.stopped'))
return
}
const text = htmlToPlain(ch.content)
if (!text) {
showToast(t('tts.noText'))
return
}
speakText(text, language === 'en' ? 'en-US' : 'zh-CN')
showToast(t('tts.started'))
}
if (!open || !volumeId || volumeChapters.length === 0) return null
return (
<div
className={`read-mode-overlay read-mode-font-${fontTheme}`}
data-testid="read-mode-overlay"
role="dialog"
aria-modal="true"
aria-label={t('readMode.title')}
>
<div className="read-mode-toolbar">
<span className="read-mode-toolbar-title">
{bookName} · {volumeName}
</span>
<div className="read-mode-toolbar-controls">
<select
className="form-control form-control--narrow"
data-testid="read-device-width"
value={deviceWidth}
onChange={(e) =>
void updateSettings({
readModeDeviceWidth: e.target.value as typeof deviceWidth
})
}
aria-label={t('readMode.deviceWidth')}
>
<option value="phone">{t('readMode.devicePhone')}</option>
<option value="tablet">{t('readMode.deviceTablet')}</option>
<option value="desktop">{t('readMode.deviceDesktop')}</option>
</select>
<select
className="form-control form-control--narrow"
data-testid="read-font-theme"
value={fontTheme}
onChange={(e) =>
void updateSettings({
readModeFontTheme: e.target.value as typeof fontTheme
})
}
aria-label={t('readMode.fontTheme')}
>
<option value="serif">{t('readMode.fontSerif')}</option>
<option value="sans">{t('readMode.fontSans')}</option>
<option value="eye-care">{t('readMode.fontEyeCare')}</option>
</select>
<button type="button" className="btn" data-testid="read-tts-btn" onClick={handleTts}>
🔊 {t('readMode.tts')}
</button>
<button type="button" className="btn" data-testid="read-prev-chapter" onClick={goPrev} disabled={chapterIndex <= 0}>
</button>
<span data-testid="read-chapter-indicator">
{chapterIndex + 1}/{volumeChapters.length}
</span>
<button
type="button"
className="btn"
data-testid="read-next-chapter"
onClick={goNext}
disabled={chapterIndex >= volumeChapters.length - 1}
>
</button>
<button type="button" className="btn btn-primary" data-testid="read-mode-close" onClick={close}>
{t('readMode.exit')}
</button>
</div>
</div>
<div className="read-mode-scroll" ref={scrollRef}>
<div
className={`read-mode-content read-mode-device-${deviceWidth}`}
data-testid="read-mode-content"
>
{visibleIndices.map((idx) => {
const ch = volumeChapters[idx]
return (
<ChapterSection key={ch.id} chapter={ch} chapterNumber={idx + 1} />
)
})}
</div>
</div>
</div>
)
}
@@ -10,6 +10,7 @@ interface VolumeContextMenuProps {
hasChapters: boolean
x: number
y: number
onOpenReadMode?: () => void
onClose: () => void
onChanged: () => Promise<void>
}
@@ -20,6 +21,7 @@ export function VolumeContextMenu({
hasChapters,
x,
y,
onOpenReadMode,
onClose,
onChanged
}: VolumeContextMenuProps): React.JSX.Element {
@@ -74,6 +76,18 @@ export function VolumeContextMenu({
<button type="button" onClick={() => void handleDescription()}>
{t('volume.editDescription')}
</button>
{onOpenReadMode && (
<button
type="button"
data-testid="volume-read-mode"
onClick={() => {
onClose()
onOpenReadMode()
}}
>
{t('readMode.open')}
</button>
)}
<button type="button" className="danger" onClick={() => void handleDelete()}>
{t('volume.delete')}
</button>
+24
View File
@@ -0,0 +1,24 @@
import { create } from 'zustand'
import type { GlobalSettings } from '@shared/types'
export type ReadDeviceWidth = NonNullable<GlobalSettings['readModeDeviceWidth']>
export type ReadFontTheme = NonNullable<GlobalSettings['readModeFontTheme']>
interface ReadModeState {
open: boolean
volumeId: string | null
chapterIndex: number
openForVolume: (volumeId: string, chapterIndex?: number) => void
close: () => void
setChapterIndex: (index: number) => void
}
export const useReadModeStore = create<ReadModeState>((set) => ({
open: false,
volumeId: null,
chapterIndex: 0,
openForVolume: (volumeId, chapterIndex = 0) =>
set({ open: true, volumeId, chapterIndex: Math.max(0, chapterIndex) }),
close: () => set({ open: false, volumeId: null, chapterIndex: 0 }),
setChapterIndex: (chapterIndex) => set({ chapterIndex: Math.max(0, chapterIndex) })
}))
+3
View File
@@ -32,6 +32,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
chapterTemplates: [],
submissionPresets: [],
defaultSubmissionPresetId: 'builtin-webnovel',
readModeFontTheme: 'serif',
readModeDeviceWidth: 'desktop',
enableFocusAmbientSound: false,
loaded: false,
load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get())
+101
View File
@@ -2346,3 +2346,104 @@
font-size: 12px;
margin-bottom: 8px;
}
.read-mode-overlay {
position: fixed;
inset: 0;
z-index: 10001;
background: var(--bg-primary);
display: flex;
flex-direction: column;
}
.read-mode-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.read-mode-toolbar-title {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.read-mode-toolbar-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.read-mode-scroll {
flex: 1;
overflow-y: auto;
scroll-snap-type: y proximity;
}
.read-mode-content {
width: 100%;
padding: 32px 24px 64px;
box-sizing: border-box;
}
.read-mode-device-phone {
max-width: 360px;
margin: 0 auto;
}
.read-mode-device-tablet {
max-width: 768px;
margin: 0 auto;
}
.read-mode-device-desktop {
max-width: 100%;
}
.read-mode-chapter {
scroll-snap-align: start;
margin-bottom: 48px;
}
.read-mode-chapter-title {
font-size: 1.25rem;
margin-bottom: 20px;
color: var(--text-primary);
}
.read-mode-paragraph {
line-height: 1.9;
margin-bottom: 1em;
font-size: 16px;
color: var(--text-primary);
}
.read-mode-font-serif .read-mode-paragraph {
font-family: Georgia, 'Noto Serif SC', 'Songti SC', serif;
}
.read-mode-font-sans .read-mode-paragraph {
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.read-mode-font-eye-care {
background: #ebe4d4;
}
.read-mode-font-eye-care .read-mode-content {
background: #f5f0e6;
border-radius: 8px;
}
.read-mode-font-eye-care .read-mode-paragraph,
.read-mode-font-eye-care .read-mode-chapter-title {
color: #3d3428;
}