c10be87d18
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>
229 lines
8.9 KiB
TypeScript
229 lines
8.9 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Provider as JotaiProvider } from 'jotai'
|
|
import { useTranslation } from 'react-i18next'
|
|
import './i18n'
|
|
import './styles/themes.css'
|
|
import './styles/globals.css'
|
|
import './styles/layout.css'
|
|
import { TopBar } from '@renderer/components/layout/TopBar'
|
|
import { HomePage } from '@renderer/components/home/HomePage'
|
|
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
|
|
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
|
|
import { OnboardingWizard } from '@renderer/components/onboarding/OnboardingWizard'
|
|
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 { 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'
|
|
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
|
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
|
import { getEditorTextForTts } from '@renderer/lib/editor-commands'
|
|
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
|
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
|
|
import { useAiStore } from '@renderer/stores/useAiStore'
|
|
import { useExportStore } from '@renderer/stores/useExportStore'
|
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
|
|
|
function AppInner(): React.JSX.Element {
|
|
const { t } = useTranslation()
|
|
const view = useAppStore((s) => s.view)
|
|
const toast = useAppStore((s) => s.toast)
|
|
const focusMode = useAppStore((s) => s.focusMode)
|
|
const namingCheckOpen = useAppStore((s) => s.namingCheckOpen)
|
|
const setFocusMode = useAppStore((s) => s.setFocusMode)
|
|
const setNamingCheckOpen = useAppStore((s) => s.setNamingCheckOpen)
|
|
const showToast = useAppStore((s) => s.showToast)
|
|
const setView = useAppStore((s) => s.setView)
|
|
const { loaded, onboardingCompleted, load: loadSettings } = useSettingsStore()
|
|
const loadBooks = useBookStore((s) => s.loadBooks)
|
|
const { activeTabId, openTabs } = useTabStore()
|
|
const openBook = useBookStore((s) => s.openBook)
|
|
const [showOnboarding, setShowOnboarding] = useState(false)
|
|
|
|
useEffect(() => {
|
|
void (async () => {
|
|
await loadSettings()
|
|
await loadBooks()
|
|
})()
|
|
}, [loadSettings, loadBooks])
|
|
|
|
useEffect(() => {
|
|
if (loaded && !onboardingCompleted) setShowOnboarding(true)
|
|
}, [loaded, onboardingCompleted])
|
|
|
|
useEffect(() => {
|
|
return window.electronAPI.onGoalNotification((payload) => {
|
|
showToast(t(payload.messageKey, payload.messageParams))
|
|
})
|
|
}, [showToast, t])
|
|
|
|
useEffect(() => {
|
|
const openSearch = (): void => useSearchStore.getState().setOpen(true)
|
|
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
|
|
const openInspiration = (): void => {
|
|
useAppStore.getState().setInspirationModalOpen(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')
|
|
)
|
|
}
|
|
const onSetAiOnline = (e: Event): void => {
|
|
const online = (e as CustomEvent<{ online: boolean }>).detail?.online
|
|
if (typeof online === 'boolean') useAiStore.setState({ online })
|
|
}
|
|
const onFlushEditor = (): void => {
|
|
void flushEditorSave()
|
|
}
|
|
window.addEventListener('bilin:e2e-open-search', openSearch)
|
|
window.addEventListener('bilin:e2e-open-landmarks', openLandmarks)
|
|
window.addEventListener('bilin:e2e-capture-inspiration', openInspiration)
|
|
window.addEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
|
|
window.addEventListener('bilin:e2e-set-ai-online', onSetAiOnline)
|
|
window.addEventListener('bilin:e2e-flush-editor', onFlushEditor)
|
|
return () => {
|
|
window.removeEventListener('bilin:e2e-open-search', openSearch)
|
|
window.removeEventListener('bilin:e2e-open-landmarks', openLandmarks)
|
|
window.removeEventListener('bilin:e2e-capture-inspiration', openInspiration)
|
|
window.removeEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
|
|
window.removeEventListener('bilin:e2e-set-ai-online', onSetAiOnline)
|
|
window.removeEventListener('bilin:e2e-flush-editor', onFlushEditor)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const unsub = window.electronAPI.onShortcutTriggered(async (action) => {
|
|
if (action === 'saveChapter') {
|
|
try {
|
|
await flushEditorSave(true)
|
|
} catch {
|
|
showToast(t('toast.saveFailed'))
|
|
}
|
|
return
|
|
}
|
|
if (action === 'newChapter') {
|
|
if (view !== 'editor') return
|
|
const { currentBookId, activeVolumeId, refreshChapters, setSelectedChapter } = useBookStore.getState()
|
|
if (!currentBookId || !activeVolumeId) return
|
|
await flushEditorSave()
|
|
const ch = await ipcCall(() =>
|
|
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
|
|
)
|
|
await refreshChapters()
|
|
setSelectedChapter(ch.id)
|
|
return
|
|
}
|
|
if (action === 'closeTab') {
|
|
const tab = openTabs.find((x) => x.id === activeTabId)
|
|
if (!tab) return
|
|
const next = useTabStore.getState().closeTab(tab.id)
|
|
if (next) {
|
|
const nt = useTabStore.getState().openTabs.find((x) => x.id === next)
|
|
if (nt?.type === 'book' && nt.bookId) {
|
|
await openBook(nt.bookId)
|
|
setView('editor')
|
|
} else setView('settings')
|
|
} 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') {
|
|
useAppStore.getState().setInspirationModalOpen(true)
|
|
return
|
|
}
|
|
if (action === 'exportBook') {
|
|
if (view !== 'editor') return
|
|
const target = useEditStore.getState().target
|
|
if (target?.kind !== 'chapter') return
|
|
useExportStore.getState().openModal()
|
|
return
|
|
}
|
|
if (action === 'focusMode') {
|
|
if (view !== 'editor') return
|
|
setFocusMode(!useAppStore.getState().focusMode)
|
|
return
|
|
}
|
|
if (action === 'toggleTTS') {
|
|
if (view !== 'editor') return
|
|
if (isSpeaking()) {
|
|
stopSpeaking()
|
|
showToast(t('tts.stopped'))
|
|
return
|
|
}
|
|
const text = getEditorTextForTts()
|
|
if (!text.trim()) {
|
|
showToast(t('tts.noText'))
|
|
return
|
|
}
|
|
speakText(text)
|
|
showToast(t('tts.started'))
|
|
return
|
|
}
|
|
showToast(t('feature.comingSoon'))
|
|
})
|
|
return unsub
|
|
}, [view, activeTabId, openTabs, openBook, setView, setFocusMode, showToast, t])
|
|
|
|
const handleOnboardingComplete = (): void => {
|
|
setShowOnboarding(false)
|
|
}
|
|
|
|
return (
|
|
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
|
|
<TopBar />
|
|
<div id="main-area">
|
|
{view === 'home' && <HomePage />}
|
|
{view === 'editor' && <EditorLayout />}
|
|
{view === 'settings' && <SettingsPage />}
|
|
</div>
|
|
<FocusModeOverlay />
|
|
<ReadModeOverlay />
|
|
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
|
<SearchModal />
|
|
<InspirationModal />
|
|
<ImportBookModal />
|
|
<ExportAllModal />
|
|
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
|
{toast && <div className="toast">{toast}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function App(): React.JSX.Element {
|
|
return (
|
|
<JotaiProvider>
|
|
<AppInner />
|
|
</JotaiProvider>
|
|
)
|
|
}
|