Files
margin/src/components/EditorView.tsx
T
2026-06-27 09:32:22 -04:00

469 lines
17 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useRef, 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 { BackupButton } from "./BackupButton";
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 { useBackup } from "../store/useBackup";
import { useExportPreview } from "../store/useExportPreview";
import { useProofing } from "../store/useProofing";
import { useTheme } from "../store/useTheme";
import { useWidth } from "../store/useWidth";
import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library";
import { isDesktop } from "../ipc";
import { useCompact } from "../useMedia";
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 isCompact = useCompact();
const [editor, setEditor] = useState<TiptapEditor | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
const [dock, setDock] = useState(!isCompact);
const [settingsOpen, setSettingsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [moreOpen, setMoreOpen] = 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 toggleSidebar = () =>
setSidebarOpen((open) => {
if (!open && isCompact) setDock(false);
return !open;
});
const toggleDock = () =>
setDock((open) => {
if (!open && isCompact) setSidebarOpen(false);
return !open;
});
const closeDrawers = () => {
setSidebarOpen(false);
setDock(false);
};
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();
useBackup.getState().refresh();
})
.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(() => {
setSidebarOpen(!isCompact);
setDock(!isCompact);
}, [isCompact]);
useEffect(() => {
if (!editor) return;
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).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<typeof setTimeout>;
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);
if (format === "pdf" && isDesktop) useExportPreview.getState().openPreview();
else 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 === "part"
? `Part ${partRoman(partNumber(book.chapters, realIdx) ?? 0)}`
: kind === "front"
? "Front matter"
: "Back matter";
return (
<div className="app" data-compact={isCompact} data-sidebar={sidebarOpen} data-dock={dock}>
<header className="titlebar" data-tauri-drag-region>
<div className="lead">
<button className="icon-btn" data-on={sidebarOpen} onClick={toggleSidebar} title="Toggle chapters" aria-label="Toggle chapters">
<Icon d="M3 4.5h18v15H3zM9 4.5v15" />
</button>
</div>
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
{book.metadata.title || "Untitled"}
{dirty && <span className="dirty-dot" />}
</button>
<div className="actions">
{isDesktop && !isCompact && <BackupButton />}
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
</button>
{!isCompact && proofingAvailable && !coverActive && (
<>
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
</button>
<button className="icon-btn" data-on={grammar} onClick={toggleGrammar} title="Check grammar">
<Icon d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" />
</button>
</>
)}
{!isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
</button>
{widthOpen && (
<>
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} />
<div className="menu">
{WIDTH_OPTIONS.map((w) => (
<button
key={w.id}
data-on={width === w.id}
onClick={() => {
setWidth(w.id);
setWidthOpen(false);
}}
>
{w.label}
</button>
))}
</div>
</>
)}
</div>
)}
{!isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
</button>
{exportOpen && (
<>
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
<div className="menu">
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
</div>
</>
)}
</div>
)}
{!isCompact && (
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
{theme === "dark" ? (
<Icon>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" />
</Icon>
) : (
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
)}
</button>
)}
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
</button>
{isCompact && (
<div className="menu-wrap">
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options">
<Icon d="M5 12h.01M12 12h.01M19 12h.01" />
</button>
{moreOpen && (
<>
<div className="menu-backdrop" onClick={() => setMoreOpen(false)} />
<div className="menu">
<div className="menu-label">Editor width</div>
{WIDTH_OPTIONS.map((w) => (
<button
key={w.id}
data-on={width === w.id}
onClick={() => {
setWidth(w.id);
setMoreOpen(false);
}}
>
{w.label}
</button>
))}
<div className="menu-sep" />
<button
onClick={() => {
setMoreOpen(false);
handleExport("pdf");
}}
>
Export PDF…
</button>
<button
onClick={() => {
setMoreOpen(false);
handleExport("epub");
}}
>
Export EPUB…
</button>
{proofingAvailable && !coverActive && (
<>
<div className="menu-sep" />
<button data-on={spelling} onClick={toggleSpelling}>
Check spelling
</button>
<button data-on={grammar} onClick={toggleGrammar}>
Check grammar
</button>
</>
)}
<div className="menu-sep" />
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
</div>
</>
)}
</div>
)}
</div>
</header>
<FindBar
editor={coverActive ? null : editor}
open={findOpen && !coverActive}
initialExpanded={findExpanded}
onClose={() => setFindOpen(false)}
/>
<div className="body">
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} />
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
<main className="editor-pane">
{coverActive ? (
<CoverView />
) : (
<>
<article className="sheet">
<header className="chapter-opener">
<div className="chapter-num">{eyebrow}</div>
<ChapterTitleInput
value={chapter.title}
placeholder={
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
}
disabled={!!chapter.noTitle}
onChange={(value) => setChapterTitle(chapter.id, value)}
/>
{(chapter.noTitle || !chapter.title.trim()) && (
<label className="no-title-check">
<input
type="checkbox"
checked={!!chapter.noTitle}
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
/>
No title
</label>
)}
</header>
<Editor
key={chapter.id}
bookId={book.id}
chapterId={chapter.id}
content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor}
onContentError={() =>
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
}
/>
</article>
<FloatingToolbar editor={editor} />
</>
)}
</main>
{dock && !isCompact && <ResizeHandle pane="dock" />}
{dock && <Dock />}
{isCompact && (sidebarOpen || dock) && (
<button className="drawer-scrim" aria-label="Close panel" onClick={closeDrawers} />
)}
</div>
{proofPopover && editor && (
<ProofPopover
issue={proofPopover.issue}
coords={proofPopover.coords}
onReplace={(suggestion) => {
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 && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && (
<div className="export-overlay">
<div className="spinner" />
<p>Exporting {exporting}…</p>
</div>
)}
{notice && (
<div className="toast" onClick={() => setNotice(null)}>
{notice}
</div>
)}
</div>
);
}
function ChapterTitleInput({
value,
placeholder,
disabled,
onChange,
}: {
value: string;
placeholder: string;
disabled: boolean;
onChange: (value: string) => void;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [value, disabled]);
return (
<textarea
ref={ref}
className="chapter-title-input"
rows={1}
value={value}
placeholder={placeholder}
spellCheck={false}
disabled={disabled}
onKeyDown={(e) => e.key === "Enter" && e.preventDefault()}
onChange={(e) => onChange(e.target.value)}
/>
);
}