group of bug fixes

This commit is contained in:
pj committed 2026-08-24 09:17:26 +05:30
1 parent cfc93fc97d
commit bcd5eb9ebc
30 files changed
+973 -238

No files matched your search

+21 -8
View File
@@ -11,7 +11,7 @@ import { useBook } from "./store/useBook";
import { useBackup } from "./store/useBackup";
import { useExportPreview } from "./store/useExportPreview";
import { isDesktop } from "./ipc";
import { createAndOpenBook, saveBook } from "./library";
import { createAndOpenBook, lastBookId, loadBook, saveBook } from "./library";
import { runExport } from "./export/run";
import { checkForUpdates } from "./updater";
@@ -51,12 +51,7 @@ function App() {
if ((!book || !dirty) && !connected) return;
event.preventDefault();
if (book && dirty) await saveBook(book).catch(() => {});
if (connected) {
await Promise.race([
useBackup.getState().backup(true),
new Promise((resolve) => setTimeout(resolve, 8000)),
]);
}
if (connected) useBackup.getState().backup(true);
win.destroy();
});
return () => {
@@ -76,16 +71,34 @@ function App() {
useEffect(() => {
if (!isDesktop) return;
let first = true;
const tick = async () => {
await useBackup.getState().refresh();
const state = useBackup.getState();
if (state.connected && state.pending) state.backup(true);
if (!state.connected || state.needsReauth) return;
if (first) {
first = false;
state.sync(true);
} else if (state.pending) {
state.backup(true);
}
};
tick();
const id = setInterval(tick, 15 * 60 * 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (!isDesktop) return;
const id = lastBookId();
if (!id || useBook.getState().book) return;
loadBook(id)
.then((last) => {
if (!useBook.getState().book) useBook.getState().openBook(last);
})
.catch(() => {});
}, []);
return (
<>
{book ? <EditorView /> : <Library onOpen={openBook} />}
+11 -1
View File
@@ -6,6 +6,7 @@ export interface BackupStatus {
email: string | null;
lastBackup: number | null;
pending: boolean;
needsReauth: boolean;
}
export interface RestoreResult {
@@ -16,11 +17,16 @@ export interface BackupOutcome extends BackupStatus {
uploaded: number;
}
export interface SyncOutcome extends BackupStatus {
uploaded: number;
downloaded: number;
}
export interface RemoteBackup {
name: string;
}
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false };
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false, needsReauth: false };
export async function gdriveStatus(): Promise<BackupStatus> {
if (!isDesktop) return OFFLINE;
@@ -39,6 +45,10 @@ export async function gdriveBackup(): Promise<BackupOutcome> {
return invoke<BackupOutcome>("gdrive_backup");
}
export async function gdriveSync(): Promise<SyncOutcome> {
return invoke<SyncOutcome>("gdrive_sync");
}
export async function gdriveRestore(): Promise<RestoreResult> {
return invoke<RestoreResult>("gdrive_restore");
}
+2 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { PAGE_TYPES } from "../model/book";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
interface AddPageMenuProps {
@@ -40,10 +41,7 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
};
}, [open]);
useEffect(() => {
if (!open) return;
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
}, [open]);
useFocusTrap(popRef, open);
useEscapeLayer(open, () => {
setOpen(false);
+53 -23
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Icon } from "./Icon";
import { ConfirmDialog } from "./ConfirmDialog";
import { useBackup } from "../store/useBackup";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { formatBackupTime, gdriveListBackups } from "../backup";
export function BackupSettings() {
@@ -12,10 +13,12 @@ export function BackupSettings() {
const email = useBackup((s) => s.email);
const lastBackup = useBackup((s) => s.lastBackup);
const pending = useBackup((s) => s.pending);
const needsReauth = useBackup((s) => s.needsReauth);
const phase = useBackup((s) => s.phase);
const connect = useBackup((s) => s.connect);
const disconnect = useBackup((s) => s.disconnect);
const backup = useBackup((s) => s.backup);
const sync = useBackup((s) => s.sync);
const restore = useBackup((s) => s.restore);
const cancelConnect = useBackup((s) => s.cancelConnect);
const openAuthUrl = useBackup((s) => s.openAuthUrl);
@@ -23,10 +26,13 @@ export function BackupSettings() {
const [remoteCount, setRemoteCount] = useState<number | null>(null);
const [confirmRestore, setConfirmRestore] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const working = phase === "working";
useFocusTrap(panelRef, open && !confirmRestore);
useEffect(() => {
if (!open || !connected) {
if (!open || !connected || needsReauth) {
setRemoteCount(null);
return;
}
@@ -37,7 +43,7 @@ export function BackupSettings() {
return () => {
active = false;
};
}, [open, connected, lastBackup]);
}, [open, connected, needsReauth, lastBackup]);
useEscapeLayer(open, close);
@@ -46,7 +52,7 @@ export function BackupSettings() {
return (
<>
<div className="overlay" onClick={close}>
<div className="panel" onClick={(e) => e.stopPropagation()}>
<div ref={panelRef} className="panel" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>Backup &amp; Sync</h2>
<button className="icon-btn" onClick={close} title="Close">
@@ -100,27 +106,51 @@ export function BackupSettings() {
</button>
</div>
<div className="backup-status-row">
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
</div>
{needsReauth ? (
<>
<div className="backup-warn">
margin can't reach your Drive folder. The permission to create and edit its own files was
not granted, so nothing is being backed up.
</div>
<div className="backup-actions">
<button className="btn-primary" disabled={working} onClick={connect}>
{working ? "Working…" : "Reconnect Google Drive"}
</button>
</div>
<p className="backup-note">
On the Google screen, tick <strong>See, edit, create and delete only the specific Google Drive
files you use with this app</strong> before continuing.
</p>
</>
) : (
<>
<div className="backup-status-row">
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
</div>
<div className="backup-actions">
<button className="btn-primary" disabled={working} onClick={() => backup()}>
{working ? "Working…" : "Back up now"}
</button>
<button
className="btn-ghost"
disabled={working || remoteCount === 0}
onClick={() => setConfirmRestore(true)}
>
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
</button>
</div>
<div className="backup-actions">
<button className="btn-primary" disabled={working} onClick={() => sync()}>
{working ? "Working…" : "Sync now"}
</button>
<button className="btn-ghost" disabled={working} onClick={() => backup()}>
Back up only
</button>
<button
className="btn-ghost"
disabled={working || remoteCount === 0}
onClick={() => setConfirmRestore(true)}
>
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
</button>
</div>
<p className="backup-note">
Backs up automatically when you close margin, and every 15 minutes while there are changes.
</p>
<p className="backup-note">
Syncs on launch and right after connecting, backs up when you close margin, and every 15 minutes
while there are changes.
</p>
</>
)}
</>
)}
</div>
+4 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
interface ConfirmDialogProps {
@@ -12,16 +13,18 @@ interface ConfirmDialogProps {
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
confirmRef.current?.focus();
}, []);
useEscapeLayer(true, onClose);
useFocusTrap(panelRef);
return (
<div className="overlay" onClick={onClose}>
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
<div ref={panelRef} className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>{title}</h2>
<button className="icon-btn" onClick={onClose} title="Close">
+66 -78
View File
@@ -5,6 +5,7 @@ import { Sidebar } from "./Sidebar";
import { Dock } from "./Dock";
import { ResizeHandle } from "./ResizeHandle";
import { Icon } from "./Icon";
import { Menu } from "./Menu";
import { Settings } from "./Settings";
import { BackupButton } from "./BackupButton";
import { CoverView } from "./CoverView";
@@ -23,6 +24,7 @@ import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library";
import { useEscapeLayer } from "../escape";
import { focusTrapped } from "../focus";
import { isDesktop, runWritingTool } from "../ipc";
import { useCompact } from "../useMedia";
import { issueSignature, rememberWord, runProof } from "../proofing";
@@ -57,7 +59,7 @@ export function EditorView() {
editorRef.current = editor;
const lastChapterRef = useRef<string>("");
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
const [dock, setDock] = useState(!isCompact);
const [dock, setDock] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
@@ -119,7 +121,7 @@ export function EditorView() {
}, []);
const focusEditorSoon = useCallback(() => {
if (useBook.getState().pendingTitleFocus) return;
if (useBook.getState().pendingTitleFocus || focusTrapped()) return;
requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false }));
}, []);
@@ -162,6 +164,7 @@ export function EditorView() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (focusTrapped()) return;
if (e.metaKey && e.altKey && e.key.toLowerCase() === "f") {
e.preventDefault();
openFind(true);
@@ -214,7 +217,7 @@ export function EditorView() {
useEffect(() => {
setSidebarOpen(!isCompact);
setDock(!isCompact);
if (isCompact) setDock(false);
}, [isCompact]);
useEffect(() => {
@@ -305,25 +308,20 @@ export function EditorView() {
<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>
</>
)}
<Menu open={widthOpen} onClose={() => setWidthOpen(false)}>
{WIDTH_OPTIONS.map((w) => (
<button
key={w.id}
data-on={width === w.id}
onClick={() => {
setWidth(w.id);
setWidthOpen(false);
}}
>
{w.label}
</button>
))}
</Menu>
</div>
)}
{!isCompact && (
@@ -331,15 +329,10 @@ export function EditorView() {
<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>
</>
)}
<Menu open={exportOpen} onClose={() => setExportOpen(false)}>
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
</Menu>
</div>
)}
{!isCompact && (
@@ -362,56 +355,51 @@ export function EditorView() {
<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>
))}
<Menu open={moreOpen} onClose={() => setMoreOpen(false)}>
<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
onClick={() => {
setMoreOpen(false);
handleExport("pdf");
}}
>
Export PDF…
<button data-on={spelling} onClick={toggleSpelling}>
Check spelling
</button>
<button
onClick={() => {
setMoreOpen(false);
handleExport("epub");
}}
>
Export EPUB…
<button data-on={grammar} onClick={toggleGrammar}>
Check grammar
</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 className="menu-sep" />
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
</Menu>
</div>
)}
</div>
+11 -9
View File
@@ -8,6 +8,7 @@ import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
import { compilePdf } from "../ipc";
import { saveBytes } from "../project";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
@@ -83,6 +84,7 @@ function ExportPreviewModal() {
}, [book]);
useEscapeLayer(true, close);
useFocusTrap(panelRef);
useEffect(() => {
const el = panelRef.current;
@@ -168,7 +170,7 @@ function ExportPreviewModal() {
<button className="icon-btn" disabled={!pdf || zoom <= ZOOM_MIN} onClick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out">
<Icon d="M5 12h14" />
</button>
{!compact && <span>{Math.round(zoom * 100)}%</span>}
{!compact && <span>{zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`}</span>}
<button className="icon-btn" disabled={!pdf || zoom >= ZOOM_MAX} onClick={() => adjustZoom(ZOOM_STEP)} title="Zoom in">
<Icon d="M12 5v14M5 12h14" />
</button>
@@ -207,7 +209,7 @@ function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; on
const stageRef = useRef<HTMLDivElement>(null);
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [baseRatio, setBaseRatio] = useState<number | null>(null);
const [stageWidth, setStageWidth] = useState(0);
const [stage, setStage] = useState({ width: 0, height: 0 });
useEffect(() => {
let cancelled = false;
@@ -229,21 +231,21 @@ function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; on
}, [data, onPages]);
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const update = () => setStageWidth(stage.clientWidth);
const el = stageRef.current;
if (!el) return;
const update = () => setStage({ width: el.clientWidth, height: el.clientHeight });
update();
const observer = new ResizeObserver(update);
observer.observe(stage);
observer.observe(el);
return () => observer.disconnect();
}, [doc]);
const fit = Math.max(280, stageWidth - 56);
const displayWidth = Math.round(fit * zoom);
const pageFit = Math.max(240, Math.min(stage.width - 56, (stage.height - 56) / (baseRatio ?? 1)));
const displayWidth = Math.round(pageFit * zoom);
return (
<div className="preview-stage" ref={stageRef}>
{doc && baseRatio !== null && stageWidth > 0 && (
{doc && baseRatio !== null && stage.width > 0 && (
<div className="preview-col">
{Array.from({ length: doc.numPages }, (_, i) => (
<PdfPage
+7 -2
View File
@@ -96,7 +96,12 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
};
}, [editor, pendingNav, activeChapterId]);
useEscapeLayer(open, onClose);
const close = () => {
editor?.commands.focus();
onClose();
};
useEscapeLayer(open, close);
if (!open || !editor) return null;
@@ -239,7 +244,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
<button className="find-toggle" data-on={wholeWord} title="Whole word" onClick={() => setWholeWord((v) => !v)}>
<span className="find-ww">ab</span>
</button>
<button className="find-btn" title="Close (Esc)" onClick={onClose}>
<button className="find-btn" title="Close (Esc)" onClick={close}>
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
</button>
</div>
+14 -1
View File
@@ -1,8 +1,19 @@
import { useEffect, useState } from "react";
import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library";
import {
type BookSummary,
createAndOpenBook,
deleteBook,
exampleBook,
lastBookId,
listBooks,
loadBook,
rememberLastBook,
saveBook,
} from "../library";
import type { Book } from "../model/book";
import { importEpub } from "../import/epub";
import { clearPositions } from "../editor/positions";
import { clearChapterStates } from "../editor/session";
import { isDesktop } from "../ipc";
import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon";
@@ -66,6 +77,8 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
if (!pendingDelete) return;
await deleteBook(pendingDelete.id);
clearPositions(pendingDelete.id);
clearChapterStates(pendingDelete.id);
if (lastBookId() === pendingDelete.id) rememberLastBook(null);
setPendingDelete(null);
refresh();
};
+42
View File
@@ -0,0 +1,42 @@
import { useRef, type ReactNode } from "react";
import { useFocusTrap } from "../focus";
interface MenuProps {
open: boolean;
onClose: () => void;
children: ReactNode;
}
export function Menu({ open, onClose, children }: MenuProps) {
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref, open);
if (!open) return null;
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
e.preventDefault();
const items = Array.from(ref.current?.querySelectorAll<HTMLElement>("button") ?? []);
if (!items.length) return;
const at = items.indexOf(document.activeElement as HTMLElement);
const next =
e.key === "Home"
? 0
: e.key === "End"
? items.length - 1
: e.key === "ArrowDown"
? (at + 1) % items.length
: (at - 1 + items.length) % items.length;
items[next]?.focus();
};
return (
<>
<div className="menu-backdrop" onClick={onClose} />
<div ref={ref} className="menu" onKeyDown={onKeyDown}>
{children}
</div>
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import type { Chapter } from "../model/book";
import { type BookSummary, listBooks, moveChapterToBook, saveBook } from "../library";
import { useBook } from "../store/useBook";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
interface MoveChapterDialogProps {
chapter: Chapter;
label: string;
onClose: () => void;
}
export function MoveChapterDialog({ chapter, label, onClose }: MoveChapterDialogProps) {
const bookId = useBook((s) => s.book?.id ?? "");
const deleteChapter = useBook((s) => s.deleteChapter);
const setNotice = useBook((s) => s.setNotice);
const [books, setBooks] = useState<BookSummary[] | null>(null);
const [moving, setMoving] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useEscapeLayer(!moving, onClose);
useFocusTrap(panelRef, books !== null);
useEffect(() => {
listBooks()
.then((list) => setBooks(list.filter((b) => b.id !== bookId && !b.corrupt)))
.catch(() => setBooks([]));
}, [bookId]);
const move = async (target: BookSummary) => {
setMoving(true);
try {
const source = useBook.getState().book;
if (source) await saveBook(source);
await moveChapterToBook(chapter, target.id);
deleteChapter(chapter.id);
const trimmed = useBook.getState().book;
if (trimmed) await saveBook(trimmed);
setNotice(`Moved to ${target.title || "Untitled"}`);
onClose();
} catch (e) {
setNotice(`Could not move: ${e}`);
setMoving(false);
}
};
return (
<div className="overlay" onClick={moving ? undefined : onClose}>
<div ref={panelRef} className="panel panel-move" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>Move to book</h2>
<button className="icon-btn" onClick={onClose} title="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
<p className="confirm-text">
Move <strong>{label}</strong> out of this book and into:
</p>
{books === null ? (
<p className="move-empty">Loading books…</p>
) : books.length === 0 ? (
<p className="move-empty">There is no other book to move this into yet.</p>
) : (
<ul className="move-list">
{books.map((b) => (
<li key={b.id}>
<button className="move-target" disabled={moving} onClick={() => move(b)}>
<span className="move-title">{b.title || "Untitled"}</span>
{b.author && <span className="move-author">{b.author}</span>}
</button>
</li>
))}
</ul>
)}
</div>
<div className="panel-foot">
<button className="btn-ghost" onClick={onClose} disabled={moving}>
{moving ? "Moving…" : "Cancel"}
</button>
</div>
</div>
</div>
);
}
+3
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useFocusTrap } from "../focus";
import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing";
function humanize(category: string): string {
@@ -20,6 +21,8 @@ const POP_WIDTH = 264;
export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) {
const popRef = useRef<HTMLDivElement>(null);
useFocusTrap(popRef);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return;
+17 -1
View File
@@ -1,6 +1,8 @@
import type { PointerEvent } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { applyPaneWidth, currentPaneWidth, resetPaneWidth, type Pane } from "../panes";
const STEP = 16;
export function ResizeHandle({ pane }: { pane: Pane }) {
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault();
@@ -23,12 +25,26 @@ export function ResizeHandle({ pane }: { pane: Pane }) {
handle.addEventListener("pointerup", onUp);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const towards = pane === "sidebar" ? 1 : -1;
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
applyPaneWidth(pane, currentPaneWidth(pane) + (e.key === "ArrowRight" ? STEP : -STEP) * towards);
} else if (e.key === "Home" || e.key === "Enter") {
e.preventDefault();
resetPaneWidth(pane);
}
};
return (
<div
className="pane-resizer"
role="separator"
aria-orientation="vertical"
aria-label={pane === "sidebar" ? "Resize chapters panel" : "Resize preview panel"}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
onDoubleClick={() => resetPaneWidth(pane)}
title="Drag to resize · double-click to reset"
/>
+18 -5
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
interface RowMenuProps {
label: string;
onDuplicate?: () => void;
onMove?: () => void;
onDelete: () => void;
onToggleTitle?: () => void;
titleHidden?: boolean;
@@ -13,9 +15,10 @@ interface RowMenuProps {
marginHidden?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
tabIndex?: number;
}
export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "" }: RowMenuProps) {
export function RowMenu({ label, onDuplicate, onMove, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "", tabIndex }: RowMenuProps) {
const [open, setOpen] = useState(false);
useEffect(() => {
@@ -34,10 +37,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
setOpen((v) => !v);
};
useEffect(() => {
if (!open) return;
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
}, [open]);
useFocusTrap(popRef, open);
useEscapeLayer(open, () => {
setOpen(false);
@@ -89,6 +89,12 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
onDuplicate?.();
};
const moveOut = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onMove?.();
};
const remove = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
@@ -101,6 +107,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
ref={btnRef}
className={`row-menu-btn ${className}`}
data-open={open}
tabIndex={tabIndex}
title={label}
onMouseDown={(e) => e.stopPropagation()}
onClick={toggle}
@@ -136,6 +143,12 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
Duplicate
</button>
)}
{onMove && (
<button className="row-menu-item" onClick={moveOut}>
<Icon d="M4 12h13M13 8l4 4-4 4M20 4v16" size={14} />
Move to book…
</button>
)}
<button className="row-menu-item danger" onClick={remove}>
<Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} />
Delete
+12 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useBook } from "../store/useBook";
import { listSystemFonts } from "../ipc";
@@ -14,6 +14,7 @@ import {
pairingFor,
} from "../model/fonts";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
const LANGUAGES = [
@@ -86,6 +87,13 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
}));
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null);
const panelRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleRef.current?.focus();
titleRef.current?.select();
}, []);
useEffect(() => {
if (systemFontCache) return;
@@ -96,6 +104,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
}, []);
useEscapeLayer(true, onClose);
useFocusTrap(panelRef);
if (!book) return null;
@@ -115,7 +124,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
return (
<div className="overlay" onClick={onClose}>
<div className="panel" onClick={(e) => e.stopPropagation()}>
<div ref={panelRef} className="panel" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>Book setup</h2>
<button className="icon-btn" onClick={onClose} title="Close">
@@ -124,7 +133,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
</div>
<div className="panel-body">
<Field label="Title">
<input autoFocus value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} />
<input ref={titleRef} value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} />
</Field>
<Field label="Subtitle">
<input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} />
+14
View File
@@ -5,7 +5,9 @@ import { AddPageMenu } from "./AddPageMenu";
import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon";
import { RowMenu } from "./RowMenu";
import { MoveChapterDialog } from "./MoveChapterDialog";
import { relativeTime } from "../time";
import { isDesktop } from "../ipc";
interface Row {
chapter: Chapter;
@@ -37,6 +39,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
const [pendingMove, setPendingMove] = useState<{ chapter: Chapter; label: string } | null>(null);
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
@@ -142,6 +145,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
});
const onRowKeyDown = (e: React.KeyboardEvent, index: number, id: string) => {
if ((e.target as HTMLElement).closest(".row-menu-btn")) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveChapter(id);
@@ -240,6 +244,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
</span>
<RowMenu
label="Page options"
tabIndex={row.chapter.id === tabStopId ? 0 : -1}
onOpenChange={(open) =>
setMenuOpenId((cur) => (open ? row.chapter.id : cur === row.chapter.id ? null : cur))
}
@@ -248,6 +253,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
marginHidden={!!row.chapter.noMargin}
onToggleMargin={() => setChapterNoMargin(row.chapter.id, !row.chapter.noMargin)}
onDuplicate={() => duplicateChapter(row.chapter.id)}
onMove={isDesktop ? () => setPendingMove({ chapter: row.chapter, label: rowLabel }) : undefined}
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
/>
</li>
@@ -276,6 +282,14 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
)}
</div>
{pendingMove && (
<MoveChapterDialog
chapter={pendingMove.chapter}
label={pendingMove.label}
onClose={() => setPendingMove(null)}
/>
)}
{pendingDelete && (
<ConfirmDialog
title="Delete"
+5 -1
View File
@@ -1,6 +1,8 @@
import { useRef } from "react";
import { useUpdater } from "../store/useUpdater";
import { installUpdate, dismissUpdate } from "../updater";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
const TITLES: Record<string, string> = {
@@ -21,7 +23,9 @@ export function UpdateDialog() {
const error = useUpdater((s) => s.error);
const busy = phase === "downloading" || phase === "installing";
const panelRef = useRef<HTMLDivElement>(null);
useEscapeLayer(phase !== "idle" && !busy, dismissUpdate);
useFocusTrap(panelRef, phase !== "idle");
if (phase === "idle") return null;
@@ -30,7 +34,7 @@ export function UpdateDialog() {
return (
<div className="overlay" onClick={close}>
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
<div ref={panelRef} className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>{TITLES[phase]}</h2>
{!busy && (
+62 -64
View File
@@ -1,9 +1,9 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { EditorContent, type Editor as TiptapEditor } from "@tiptap/react";
import { EditorState } from "@tiptap/pm/state";
import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions";
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
import { loadChapterState, saveChapterState, sharedEditor, type ChapterState } from "./session";
import { useBook } from "../store/useBook";
interface EditorProps {
@@ -15,14 +15,6 @@ interface EditorProps {
onContentError: (error: Error) => void;
}
interface Cached {
state: EditorState;
content: JSONContent;
scroll: number;
}
const SCROLL_KEEPOUT = { top: 32, right: 0, bottom: 96, left: 0 };
function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState {
const base = editor.view.state;
try {
@@ -35,31 +27,33 @@ function buildState(editor: TiptapEditor, content: JSONContent, onError: (error:
}
}
function stillMatches(editor: TiptapEditor, entry: ChapterState, content: JSONContent): boolean {
if (entry.state.schema !== editor.schema) return false;
if (entry.content === content) return true;
try {
return entry.state.doc.eq(editor.schema.nodeFromJSON(content));
} catch {
return false;
}
}
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError;
const cache = useRef(new Map<string, Cached>());
const activeId = useRef(chapterId);
const [editor] = useState(() =>
sharedEditor({
onUpdate: (next) => onChangeRef.current(next),
onContentError: (error) => onContentErrorRef.current(error),
})
);
const active = useRef({ bookId: "", chapterId: "" });
const latest = useRef<ChapterPosition | null>(null);
const restoreToken = useRef(0);
const editor = useEditor({
extensions: editorExtensions,
content,
immediatelyRender: false,
enableContentCheck: true,
editorProps: {
attributes: { class: "prose" },
scrollThreshold: SCROLL_KEEPOUT,
scrollMargin: SCROLL_KEEPOUT,
},
onContentError: ({ error }) => onContentErrorRef.current(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
});
const scrollerOf = (ed: TiptapEditor) => ed.view.dom.closest(".editor-pane") as HTMLElement | null;
const restorePosition = (ed: TiptapEditor, id: string, focus: boolean) => {
@@ -82,62 +76,66 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
}
};
const stash = (ed: TiptapEditor, id: string) => {
const stash = (ed: TiptapEditor) => {
const { bookId: prevBook, chapterId: prevChapter } = active.current;
if (!prevChapter) return;
const scroller = scrollerOf(ed);
const { from, to } = ed.state.selection;
const scroll = scroller?.scrollTop ?? 0;
const stored = useBook.getState().book?.chapters.find((c) => c.id === id)?.content ?? cache.current.get(id)?.content ?? ed.getJSON();
cache.current.set(id, { state: ed.view.state, content: stored, scroll });
savePosition(bookId, id, { from, to, scroll });
const scroll = scroller?.scrollTop ?? loadChapterState(prevBook, prevChapter)?.scroll ?? 0;
const stored = useBook.getState().book?.chapters.find((c) => c.id === prevChapter)?.content ?? ed.getJSON();
saveChapterState(prevBook, prevChapter, { state: ed.view.state, content: stored, scroll });
savePosition(prevBook, prevChapter, { from, to, scroll });
};
const applyChapter = (ed: TiptapEditor, id: string, next: JSONContent, focus: boolean) => {
const entry = loadChapterState(bookId, id);
if (entry && stillMatches(ed, entry, next)) {
entry.content = next;
ed.view.updateState(entry.state);
const scroller = scrollerOf(ed);
if (scroller) scroller.scrollTop = entry.scroll;
++restoreToken.current;
if (focus) ed.commands.focus(undefined, { scrollIntoView: false });
return;
}
ed.view.updateState(buildState(ed, next, onContentErrorRef.current));
saveChapterState(bookId, id, { state: ed.view.state, content: next, scroll: 0 });
restorePosition(ed, id, focus);
};
useLayoutEffect(() => {
const prev = active.current;
if (prev.bookId === bookId && prev.chapterId === chapterId) return;
stash(editor);
applyChapter(editor, chapterId, content, !prev.chapterId);
active.current = { bookId, chapterId };
latest.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookId, chapterId, content, editor]);
useEffect(() => {
if (!editor) return;
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
activeId.current = chapterId;
restorePosition(editor, chapterId, true);
onReady(editor);
return () => onReady(null);
return () => {
stash(editor);
onReady(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]);
useLayoutEffect(() => {
if (!editor) return;
const prev = activeId.current;
if (prev === chapterId) return;
stash(editor, prev);
const entry = cache.current.get(chapterId);
if (entry && entry.content === content) {
editor.view.updateState(entry.state);
const scroller = scrollerOf(editor);
if (scroller) scroller.scrollTop = entry.scroll;
++restoreToken.current;
} else {
editor.view.updateState(buildState(editor, content, onContentErrorRef.current));
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
restorePosition(editor, chapterId, false);
}
activeId.current = chapterId;
latest.current = null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapterId, content, editor]);
useEffect(() => {
if (!editor) return;
const scroller = scrollerOf(editor);
let timer: ReturnType<typeof setTimeout>;
const capture = () => {
const { from, to } = editor.state.selection;
const scroll = scroller?.scrollTop ?? 0;
latest.current = { from, to, scroll };
const entry = cache.current.get(activeId.current);
const entry = loadChapterState(active.current.bookId, active.current.chapterId);
if (entry) entry.scroll = scroll;
};
const persist = () => {
capture();
clearTimeout(timer);
timer = setTimeout(() => savePosition(bookId, activeId.current, latest.current!), 400);
timer = setTimeout(() => savePosition(active.current.bookId, active.current.chapterId, latest.current!), 400);
};
editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true });
@@ -145,9 +143,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
clearTimeout(timer);
editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist);
if (latest.current) savePosition(bookId, activeId.current, latest.current);
if (latest.current) savePosition(active.current.bookId, active.current.chapterId, latest.current);
};
}, [editor, bookId]);
}, [editor]);
return <EditorContent editor={editor} className="editor-host" />;
}
+15 -1
View File
@@ -20,6 +20,14 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
const baseWidth = node.attrs.width ?? FIGURE_WIDTH[placement as FigurePlacement] ?? 100;
const width = resizable ? (dragWidth ?? baseWidth) : null;
const nudge = (delta: number) => updateAttributes({ width: clampWidth((width ?? baseWidth) + delta) });
const onHandleKey = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
nudge(e.key === "ArrowRight" ? 5 : -5);
};
const startResize = (e: React.PointerEvent) => {
e.preventDefault();
const wrapper = frameRef.current?.closest(".figure") as HTMLElement | null;
@@ -52,7 +60,13 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
<div className="figure-frame" ref={frameRef}>
{src ? <img src={src} alt={alt} /> : <div className="figure-empty">No image</div>}
{selected && src && resizable && (
<div className="figure-handle" onPointerDown={startResize} title="Drag to resize" />
<button
className="figure-handle"
aria-label="Resize image"
title="Drag or press arrow keys to resize"
onPointerDown={startResize}
onKeyDown={onHandleKey}
/>
)}
</div>
<input
+7 -7
View File
@@ -2,6 +2,7 @@ import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import type { Alignment } from "./align";
const ALIGN_ICONS: Record<Alignment, string> = {
@@ -28,7 +29,8 @@ function normalizeUrl(url: string): string {
export function FloatingToolbar({ editor }: { editor: Editor | null }) {
const fileRef = useRef<HTMLInputElement>(null);
const linkInputRef = useRef<HTMLInputElement>(null);
const linkPopRef = useRef<HTMLDivElement>(null);
const alignPopRef = useRef<HTMLDivElement>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [linkValue, setLinkValue] = useState("");
const [alignOpen, setAlignOpen] = useState(false);
@@ -40,9 +42,8 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
setLinkOpen(true);
};
useEffect(() => {
if (linkOpen) linkInputRef.current?.focus();
}, [linkOpen]);
useFocusTrap(linkPopRef, linkOpen);
useFocusTrap(alignPopRef, alignOpen);
useEffect(() => {
if (!editor) return;
@@ -130,7 +131,7 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
{alignOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setAlignOpen(false)} />
<div className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
<div ref={alignPopRef} className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
{(["left", "center", "right"] as Alignment[]).map((value) => (
<button
key={value}
@@ -153,9 +154,8 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
{linkOpen && (
<>
<div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} />
<div className="link-pop" onMouseDown={(e) => e.stopPropagation()}>
<div ref={linkPopRef} className="link-pop" onMouseDown={(e) => e.stopPropagation()}>
<input
ref={linkInputRef}
className="link-input"
value={linkValue}
placeholder="https://…"
+65
View File
@@ -0,0 +1,65 @@
import { Editor as TiptapEditor } from "@tiptap/react";
import type { JSONContent } from "@tiptap/core";
import type { EditorState } from "@tiptap/pm/state";
import { editorExtensions } from "./extensions";
export interface ChapterState {
state: EditorState;
content: JSONContent;
scroll: number;
}
export interface EditorHooks {
onUpdate: (content: JSONContent) => void;
onContentError: (error: Error) => void;
}
const SCROLL_KEEPOUT = { top: 32, right: 0, bottom: 96, left: 0 };
const MAX_STATES = 24;
const states = new Map<string, ChapterState>();
const stateKey = (bookId: string, chapterId: string) => `${bookId}:${chapterId}`;
let instance: TiptapEditor | null = null;
let hooks: EditorHooks | null = null;
export function sharedEditor(next: EditorHooks): TiptapEditor {
hooks = next;
if (!instance) {
instance = new TiptapEditor({
extensions: editorExtensions,
enableContentCheck: true,
editorProps: {
attributes: { class: "prose" },
scrollThreshold: SCROLL_KEEPOUT,
scrollMargin: SCROLL_KEEPOUT,
},
onContentError: ({ error }) => hooks?.onContentError(error),
onUpdate: ({ editor }) => hooks?.onUpdate(editor.getJSON()),
});
}
return instance;
}
export function loadChapterState(bookId: string, chapterId: string): ChapterState | undefined {
return states.get(stateKey(bookId, chapterId));
}
export function saveChapterState(bookId: string, chapterId: string, entry: ChapterState): void {
for (const id of states.keys()) {
if (!id.startsWith(`${bookId}:`)) states.delete(id);
}
const id = stateKey(bookId, chapterId);
states.delete(id);
states.set(id, entry);
for (const oldest of states.keys()) {
if (states.size <= MAX_STATES) break;
states.delete(oldest);
}
}
export function clearChapterStates(bookId: string): void {
for (const id of states.keys()) {
if (id.startsWith(`${bookId}:`)) states.delete(id);
}
}
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef, type RefObject } from "react";
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function focusable(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => !el.hidden && el.offsetParent !== null,
);
}
let openTraps = 0;
export function focusTrapped(): boolean {
return openTraps > 0;
}
export function useFocusTrap(ref: RefObject<HTMLElement | null>, active = true): void {
const opener = useRef<HTMLElement | null>(null);
const wasActive = useRef(false);
if (active && !wasActive.current) opener.current = document.activeElement as HTMLElement | null;
wasActive.current = active;
useEffect(() => {
const root = ref.current;
if (!active || !root) return;
openTraps++;
if (!root.contains(document.activeElement)) focusable(root)[0]?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const items = focusable(root);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const current = document.activeElement as HTMLElement | null;
if (!current || !root.contains(current)) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
} else if (e.shiftKey && current === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && current === last) {
e.preventDefault();
first.focus();
}
};
root.addEventListener("keydown", onKeyDown);
return () => {
openTraps--;
root.removeEventListener("keydown", onKeyDown);
if (opener.current?.isConnected) opener.current.focus();
};
}, [ref, active]);
}
+27 -1
View File
@@ -1,6 +1,15 @@
import { invoke } from "@tauri-apps/api/core";
import { isDesktop } from "./ipc";
import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book";
import {
type Book,
type Chapter,
SCHEMA_VERSION,
createBook,
insertByKind,
normalizeBook,
schemaVersion,
starterBook,
} from "./model/book";
export interface BookSummary {
id: string;
@@ -39,6 +48,23 @@ export async function deleteBook(id: string): Promise<void> {
await invoke("delete_book", { id });
}
export async function moveChapterToBook(chapter: Chapter, targetId: string): Promise<void> {
const target = normalizeBook(await loadBook(targetId));
const moved = { ...chapter, content: structuredClone(chapter.content), updatedAt: Date.now() };
await saveBook({ ...target, chapters: insertByKind(target.chapters, moved) });
}
const LAST_BOOK_KEY = "margin-last-book";
export function lastBookId(): string | null {
return localStorage.getItem(LAST_BOOK_KEY);
}
export function rememberLastBook(id: string | null): void {
if (id) localStorage.setItem(LAST_BOOK_KEY, id);
else localStorage.removeItem(LAST_BOOK_KEY);
}
export function newBook(): Book {
return createBook();
}
+13
View File
@@ -171,6 +171,19 @@ export function createPart(title = ""): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: "part", updatedAt: Date.now() };
}
export function insertByKind(chapters: Chapter[], chapter: Chapter): Chapter[] {
const kind = chapterKind(chapter);
const next = [...chapters];
const at =
kind === "front"
? next.filter((c) => chapterKind(c) === "front").length
: kind === "back"
? next.length
: next.length - next.filter((c) => chapterKind(c) === "back").length;
next.splice(at, 0, chapter);
return next;
}
export function cloneChapter(chapter: Chapter): Chapter {
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
+49 -7
View File
@@ -7,6 +7,7 @@ import {
gdriveDisconnect,
gdriveRestore,
gdriveStatus,
gdriveSync,
} from "../backup";
import { isDesktop } from "../ipc";
import { useBook } from "./useBook";
@@ -18,6 +19,7 @@ interface BackupState {
email: string | null;
lastBackup: number | null;
pending: boolean;
needsReauth: boolean;
phase: Phase;
error: string | null;
authUrl: string | null;
@@ -33,6 +35,7 @@ interface BackupState {
copyAuthUrl: () => Promise<void>;
disconnect: () => Promise<void>;
backup: (silent?: boolean) => Promise<void>;
sync: (silent?: boolean) => Promise<void>;
restore: () => Promise<void>;
restoreFromDrive: () => Promise<void>;
openSettings: () => void;
@@ -43,11 +46,31 @@ function notify(message: string) {
useBook.getState().setNotice(message);
}
const RECONNECT = /Connect again/;
function describe(error: unknown, fallback: string): string {
const message = String(error).replace(/^Error:\s*/, "");
return RECONNECT.test(message) ? message : `${fallback}: ${message}`;
}
function plural(n: number, word: string): string {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
function syncMessage(uploaded: number, downloaded: number): string {
if (!uploaded && !downloaded) return "Everything is already in sync";
const parts = [];
if (downloaded) parts.push(`brought back ${plural(downloaded, "file")}`);
if (uploaded) parts.push(`backed up ${plural(uploaded, "file")}`);
return `Synced with Google Drive: ${parts.join(", ")}`;
}
export const useBackup = create<BackupState>((set, get) => ({
connected: false,
email: null,
lastBackup: null,
pending: false,
needsReauth: false,
phase: "idle",
error: null,
authUrl: null,
@@ -60,6 +83,7 @@ export const useBackup = create<BackupState>((set, get) => ({
email: status.email,
lastBackup: status.lastBackup,
pending: status.pending,
needsReauth: status.needsReauth,
}),
refresh: async () => {
if (!isDesktop) return;
@@ -75,7 +99,7 @@ export const useBackup = create<BackupState>((set, get) => ({
.then((url) => set({ authUrl: url }))
.catch((e) => {
set({ phase: "error", error: String(e), resolveConnect: null });
notify(`Could not connect: ${e}`);
notify(describe(e, "Could not connect"));
resolve(false);
});
}),
@@ -91,11 +115,13 @@ export const useBackup = create<BackupState>((set, get) => ({
await get().refresh();
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
notify("Connected to Google Drive");
} else {
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
notify(`Could not connect: ${error ?? "authorization failed"}`);
await get().sync();
resolve?.(true);
return;
}
resolve?.(ok);
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
notify(describe(error ?? "authorization failed", "Could not connect"));
resolve?.(false);
},
openAuthUrl: () => {
const url = get().authUrl;
@@ -132,7 +158,22 @@ export const useBackup = create<BackupState>((set, get) => ({
if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
} catch (e) {
set({ phase: "error", error: String(e) });
if (!silent) notify(`Backup failed: ${e}`);
await get().refresh();
if (!silent || RECONNECT.test(String(e))) notify(describe(e, "Backup failed"));
}
},
sync: async (silent = false) => {
if (!get().connected || get().phase === "working") return;
set({ phase: "working", error: null });
try {
const outcome = await gdriveSync();
get().apply(outcome);
set((s) => ({ phase: "idle", restoreNonce: outcome.downloaded > 0 ? s.restoreNonce + 1 : s.restoreNonce }));
if (!silent) notify(syncMessage(outcome.uploaded, outcome.downloaded));
} catch (e) {
set({ phase: "error", error: String(e) });
await get().refresh();
if (!silent || RECONNECT.test(String(e))) notify(describe(e, "Sync failed"));
}
},
restore: async () => {
@@ -144,7 +185,8 @@ export const useBackup = create<BackupState>((set, get) => ({
notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
} catch (e) {
set({ phase: "error", error: String(e) });
notify(`Restore failed: ${e}`);
await get().refresh();
notify(describe(e, "Restore failed"));
}
},
restoreFromDrive: async () => {
+4 -1
View File
@@ -14,7 +14,7 @@ import {
normalizeBook,
} from "../model/book";
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
import { saveBook } from "../library";
import { rememberLastBook, saveBook } from "../library";
import { applyBookFonts, resetBookFonts } from "../book-style";
export const COVER_ID = "__cover__";
@@ -70,10 +70,12 @@ export const useBook = create<BookState>((set, get) => ({
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
applyBookFonts(normalized.settings.fonts);
rememberLastBook(normalized.id);
set({ book: normalized, activeChapterId, dirty: false, pendingTitleFocus: null });
},
closeBook: () => {
flushOutgoing(get().book, get().dirty);
rememberLastBook(null);
resetBookFonts();
set({ book: null, activeChapterId: "", dirty: false, pendingTitleFocus: null });
},
@@ -163,6 +165,7 @@ export const useBook = create<BookState>((set, get) => ({
const index = s.book.chapters.findIndex((c) => c.id === id);
if (index === -1) return {};
const chapters = s.book.chapters.filter((c) => c.id !== id);
if (!chapters.length) chapters.push(createChapter(""));
const activeChapterId =
s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId;
return { dirty: true, activeChapterId, book: { ...s.book, chapters } };
+84 -1
View File
@@ -169,6 +169,14 @@ body.resizing .pane-resizer::after {
opacity: 0.55;
}
.pane-resizer:focus-visible {
outline: none;
}
.pane-resizer:focus-visible::after {
opacity: 1;
}
body.resizing {
cursor: col-resize;
user-select: none;
@@ -192,6 +200,7 @@ body.resizing {
width: 0;
border-right-width: 0;
overflow: hidden;
visibility: hidden;
}
body.resizing .sidebar {
@@ -401,6 +410,7 @@ body.resizing .sidebar {
}
.chapter:hover .row-menu-btn,
.chapter .row-menu-btn:focus-visible,
.chapter .row-menu-btn[data-open="true"] {
opacity: 0.7;
}
@@ -1600,6 +1610,7 @@ body.resizing .sidebar {
}
.card-book:hover .card-menu,
.card-menu:focus-visible,
.card-menu[data-open="true"] {
opacity: 1;
}
@@ -1628,6 +1639,63 @@ body.resizing .sidebar {
color: var(--ink);
}
.panel-move {
width: 440px;
}
.move-empty {
margin: 0;
font-size: var(--t-3);
color: var(--ink-soft);
}
.move-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 300px;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;
}
.move-target {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: none;
text-align: left;
cursor: pointer;
}
.move-target:hover:not(:disabled),
.move-target:focus-visible {
background: var(--raised);
border-color: var(--line);
}
.move-target:disabled {
opacity: 0.5;
cursor: default;
}
.move-title {
font-family: var(--font-book);
font-size: var(--t-4);
color: var(--ink);
}
.move-author {
font-family: var(--font-ui);
font-size: var(--t-1);
color: var(--ink-soft);
}
.update-notes {
max-height: 180px;
overflow-y: auto;
@@ -2779,9 +2847,22 @@ body.resizing .sidebar {
.backup-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.backup-warn {
margin: 18px 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--raised);
font-family: var(--font-ui);
font-size: var(--t-3);
line-height: 1.5;
color: var(--ink-soft);
}
.backup-note {
margin: 18px 0 0;
color: var(--ink-faint);
@@ -2815,11 +2896,13 @@ body.resizing .sidebar {
padding-left: env(safe-area-inset-left);
border-right: 1px solid var(--line);
transform: translateX(-100%);
transition: transform 260ms var(--ease);
visibility: hidden;
transition: transform 260ms var(--ease), visibility 260ms var(--ease);
}
.app[data-compact="true"][data-sidebar="true"] .sidebar {
transform: translateX(0);
visibility: visible;
}
.app[data-compact="true"] .dock {