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:
@@ -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>
|
||||
|
||||
@@ -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) })
|
||||
}))
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export type ExportFormat = 'txt' | 'md' | 'docx' | 'pdf'
|
||||
export type ExportScope = 'chapter' | 'volume' | 'book'
|
||||
|
||||
export interface ExportMatrixOptions {
|
||||
includeAnnotations?: boolean
|
||||
markAiContent?: boolean
|
||||
exportHistoryVersions?: boolean
|
||||
}
|
||||
|
||||
export interface ExportMatrixParams {
|
||||
bookId: string
|
||||
scope: ExportScope
|
||||
scopeId?: string
|
||||
format: ExportFormat
|
||||
options?: ExportMatrixOptions
|
||||
}
|
||||
|
||||
export interface ExportMatrixChapter {
|
||||
id: string
|
||||
title: string
|
||||
contentHtml: string
|
||||
chapterNumber: number
|
||||
}
|
||||
@@ -289,6 +289,9 @@ export interface GlobalSettings {
|
||||
chapterTemplates?: ChapterTemplate[]
|
||||
submissionPresets?: SubmissionPreset[]
|
||||
defaultSubmissionPresetId?: string
|
||||
readModeFontTheme?: 'serif' | 'sans' | 'eye-care'
|
||||
readModeDeviceWidth?: 'phone' | 'tablet' | 'desktop'
|
||||
enableFocusAmbientSound?: boolean
|
||||
}
|
||||
|
||||
export interface BookMeta {
|
||||
|
||||
Reference in New Issue
Block a user