import { useCallback, useEffect, useState } from "react"; import type { Editor as TiptapEditor } from "@tiptap/react"; import { Sidebar } from "./Sidebar"; import { Dock } from "./Dock"; import { ResizeHandle } from "./ResizeHandle"; import { Icon } from "./Icon"; import { Settings } from "./Settings"; import { CoverView } from "./CoverView"; import { FindBar } from "./FindBar"; import { ProofPopover } from "./ProofPopover"; import { Editor } from "../editor/Editor"; import { FloatingToolbar } from "../editor/FloatingToolbar"; import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing"; import { COVER_ID, useBook } from "../store/useBook"; import { useProofing } from "../store/useProofing"; import { useTheme } from "../store/useTheme"; import { useWidth } from "../store/useWidth"; import { WIDTH_OPTIONS } from "../width"; import { bodyNumber, chapterKind } from "../model/book"; import { saveBook } from "../library"; import { isDesktop } from "../ipc"; import { issueSignature, rememberWord, runProof } from "../proofing"; import { runExport } from "../export/run"; export function EditorView() { const book = useBook((s) => s.book); const activeChapterId = useBook((s) => s.activeChapterId); const dirty = useBook((s) => s.dirty); const exporting = useBook((s) => s.exporting); const notice = useBook((s) => s.notice); const setNotice = useBook((s) => s.setNotice); const setChapterContent = useBook((s) => s.setChapterContent); const setChapterTitle = useBook((s) => s.setChapterTitle); const setChapterNoTitle = useBook((s) => s.setChapterNoTitle); const markSaved = useBook((s) => s.markSaved); const theme = useTheme((s) => s.theme); const toggleTheme = useTheme((s) => s.toggle); const width = useWidth((s) => s.width); const setWidth = useWidth((s) => s.setWidth); const [editor, setEditor] = useState(null); const [dock, setDock] = useState(true); const [settingsOpen, setSettingsOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false); const [widthOpen, setWidthOpen] = useState(false); const [findOpen, setFindOpen] = useState(false); const [findExpanded, setFindExpanded] = useState(false); const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null); const spelling = useProofing((s) => s.spelling); const grammar = useProofing((s) => s.grammar); const ignored = useProofing((s) => s.ignored); const toggleSpelling = useProofing((s) => s.toggleSpelling); const toggleGrammar = useProofing((s) => s.toggleGrammar); const coverActive = activeChapterId === COVER_ID; const proofingAvailable = isDesktop && book?.metadata.language === "en"; const openFind = useCallback((expanded: boolean) => { setFindExpanded(expanded); setFindOpen(true); }, []); const reproof = useCallback(() => { if (!editor) return; const state = useProofing.getState(); if (!proofingAvailable || (!state.spelling && !state.grammar)) { editor.commands.clearProofIssues(); return; } runProof(editor.state.doc, { spelling: state.spelling, grammar: state.grammar }, state.ignored) .then((issues) => editor.commands.setProofIssues(issues)) .catch(() => {}); }, [editor, proofingAvailable]); const saveNow = useCallback(() => { const current = useBook.getState().book; if (!current) return; saveBook(current) .then(() => { if (useBook.getState().book === current) markSaved(); }) .catch((e) => setNotice(`Save failed: ${e}`)); }, [markSaved, setNotice]); useEffect(() => { if (!book || !dirty || !isDesktop) return; const timer = setTimeout(saveNow, 800); return () => clearTimeout(timer); }, [book, dirty, saveNow]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { e.preventDefault(); saveNow(); } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { e.preventDefault(); openFind(e.altKey); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [saveNow, openFind]); useEffect(() => { if (!notice) return; const timer = setTimeout(() => setNotice(null), 4000); return () => clearTimeout(timer); }, [notice]); useEffect(() => { if (!editor) return; const storage = (editor.storage as unknown as Record).proofing; storage.onClickIssue = (issue, coords) => setProofPopover({ issue, coords }); const closePopover = () => setProofPopover(null); editor.on("update", closePopover); return () => { storage.onClickIssue = null; editor.off("update", closePopover); }; }, [editor]); useEffect(() => { if (!editor) return; if (!proofingAvailable || coverActive || (!spelling && !grammar)) { editor.commands.clearProofIssues(); return; } let timer: ReturnType; const schedule = (delay: number) => { clearTimeout(timer); timer = setTimeout(reproof, delay); }; editor.commands.clearProofIssues(); schedule(150); const onUpdate = () => schedule(500); editor.on("update", onUpdate); return () => { clearTimeout(timer); editor.off("update", onUpdate); }; }, [editor, spelling, grammar, ignored, activeChapterId, coverActive, proofingAvailable, reproof]); const handleExport = (format: "pdf" | "epub") => { setExportOpen(false); runExport(format); }; if (!book) return null; const idx = book.chapters.findIndex((c) => c.id === activeChapterId); const chapter = book.chapters[idx] ?? book.chapters[0]; const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id); const kind = chapter ? chapterKind(chapter) : "body"; const eyebrow = kind === "body" ? `Chapter ${bodyNumber(book.chapters, realIdx) ?? ""}` : kind === "front" ? "Front matter" : "Back matter"; return (
{proofingAvailable && !coverActive && ( <> )}
{widthOpen && ( <>
setWidthOpen(false)} />
{WIDTH_OPTIONS.map((w) => ( ))}
)}
{exportOpen && ( <>
setExportOpen(false)} />
)}
setFindOpen(false)} />
{coverActive ? ( ) : ( <>
{eyebrow}
setChapterTitle(chapter.id, e.target.value)} /> {(chapter.noTitle || !chapter.title.trim()) && ( )}
setChapterContent(chapter.id, content)} onReady={setEditor} />
)}
{dock && } {dock && }
{proofPopover && editor && ( { const { from, to } = proofPopover.issue; if (suggestion === "") editor.chain().focus().deleteRange({ from, to }).run(); else editor.chain().focus().insertContentAt({ from, to }, suggestion).run(); setProofPopover(null); }} onIgnore={() => { useProofing.getState().ignore(issueSignature(proofPopover.issue)); setProofPopover(null); }} onRemember={() => { rememberWord(proofPopover.issue.word).then(reproof).catch(() => {}); setProofPopover(null); }} onClose={() => setProofPopover(null)} /> )} {settingsOpen && setSettingsOpen(false)} onSave={saveNow} />} {exporting && (

Exporting {exporting}…

)} {notice && (
setNotice(null)}> {notice}
)}
); }