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
+902 -167

No files matched your search

+162 -15
View File
@@ -15,6 +15,7 @@ use tauri::{Emitter, Manager};
const FOLDER_NAME: &str = "margin";
const DICTIONARY_NAME: &str = "custom-dictionary.txt";
const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.file";
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
const AUTH_TIMEOUT_SECS: u64 = 120;
@@ -58,6 +59,7 @@ pub struct Session {
access_expiry: u64,
email: Option<String>,
folder_id: Option<String>,
scopes: Option<String>,
}
#[derive(Serialize, Deserialize, Default)]
@@ -68,6 +70,8 @@ struct BackupState {
folder_id: Option<String>,
last_backup: Option<u64>,
#[serde(default)]
scopes: Option<String>,
#[serde(default)]
files: HashMap<String, FileRecord>,
}
@@ -84,6 +88,7 @@ pub struct Status {
email: Option<String>,
last_backup: Option<u64>,
pending: bool,
needs_reauth: bool,
}
#[derive(Serialize)]
@@ -92,6 +97,15 @@ pub struct RestoreResult {
restored: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncOutcome {
uploaded: usize,
downloaded: usize,
#[serde(flatten)]
status: Status,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupOutcome {
@@ -164,6 +178,13 @@ fn urlencode(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
fn has_drive_scope(scope: &str) -> bool {
scope.split_whitespace().any(|s| s == DRIVE_SCOPE)
}
const REAUTH_MESSAGE: &str =
"margin needs the \"See, edit, create and delete only the specific Google Drive files you use with this app\" permission. Connect again and tick that box.";
fn drive_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'")
}
@@ -260,6 +281,8 @@ struct TokenResponse {
refresh_token: Option<String>,
#[serde(default)]
expires_in: u64,
#[serde(default)]
scope: Option<String>,
}
#[derive(Deserialize)]
@@ -484,7 +507,29 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
}
let refresh = refresh.ok_or("Not connected to Google Drive.")?;
let creds = load_credentials()?;
let tokens = refresh_access_token(&creds, &refresh).await?;
let tokens = match refresh_access_token(&creds, &refresh).await {
Ok(tokens) => tokens,
Err(e) => {
if e.contains("invalid_grant") {
forget_credentials(app, state);
return Err(
"Google Drive access has expired. Connect again to keep backing up.".to_string()
);
}
return Err(e);
}
};
if let Some(granted) = &tokens.scope {
if !has_drive_scope(granted) {
let mut session = state.0.lock().unwrap();
session.scopes = Some(granted.clone());
drop(session);
let mut stored = load_state(app);
stored.scopes = Some(granted.clone());
let _ = save_state(app, &stored);
return Err(REAUTH_MESSAGE.to_string());
}
}
let access_token = tokens.access_token.clone();
{
let mut session = state.0.lock().unwrap();
@@ -493,10 +538,18 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
if let Some(rotated) = &tokens.refresh_token {
session.refresh_token = Some(rotated.clone());
}
if let Some(granted) = &tokens.scope {
session.scopes = Some(granted.clone());
}
if let Some(rotated) = &tokens.refresh_token {
}
if tokens.refresh_token.is_some() || tokens.scope.is_some() {
let mut stored = load_state(app);
if let Some(rotated) = &tokens.refresh_token {
stored.refresh_token = Some(rotated.clone());
}
if let Some(granted) = &tokens.scope {
stored.scopes = Some(granted.clone());
}
let _ = save_state(app, &stored);
}
Ok(access_token)
@@ -592,17 +645,19 @@ fn compute_pending(app: &tauri::AppHandle, stored: &BackupState) -> bool {
}
fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status {
let (connected, email) = {
let (connected, email, scopes) = {
let session = state.0.lock().unwrap();
(session.refresh_token.is_some(), session.email.clone())
(session.refresh_token.is_some(), session.email.clone(), session.scopes.clone())
};
let stored = load_state(app);
let pending = connected && compute_pending(app, &stored);
let granted = scopes.or_else(|| stored.scopes.clone());
Status {
connected,
email: email.or(stored.email),
last_backup: stored.last_backup,
pending,
needs_reauth: connected && granted.map(|s| !has_drive_scope(&s)).unwrap_or(false),
}
}
@@ -613,6 +668,21 @@ pub fn init_session(app: &tauri::AppHandle) {
session.refresh_token = stored.refresh_token;
session.email = stored.email;
session.folder_id = stored.folder_id;
session.scopes = stored.scopes;
}
fn forget_credentials(app: &tauri::AppHandle, state: &GDriveState) {
{
let mut session = state.0.lock().unwrap();
session.refresh_token = None;
session.access_token = None;
session.access_expiry = 0;
session.scopes = None;
}
let mut stored = load_state(app);
stored.refresh_token = None;
stored.scopes = None;
let _ = save_state(app, &stored);
}
async fn complete_auth(
@@ -631,6 +701,15 @@ async fn complete_auth(
.map_err(|e| e.to_string())??;
let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?;
let granted = tokens.scope.clone().unwrap_or_default();
if !has_drive_scope(&granted) {
let _ = HTTP
.post("https://oauth2.googleapis.com/revoke")
.form(&[("token", tokens.access_token.as_str())])
.send()
.await;
return Err(REAUTH_MESSAGE.to_string());
}
let email = fetch_email(&tokens.access_token).await?;
let folder_id = ensure_folder(&tokens.access_token).await?;
@@ -644,6 +723,7 @@ async fn complete_auth(
session.access_expiry = now() + tokens.expires_in.saturating_sub(60);
session.email = Some(email.clone());
session.folder_id = Some(folder_id.clone());
session.scopes = Some(granted.clone());
}
let mut stored = load_state(app);
@@ -652,6 +732,7 @@ async fn complete_auth(
}
stored.email = Some(email);
stored.folder_id = Some(folder_id);
stored.scopes = Some(granted);
save_state(app, &stored)?;
Ok(())
}
@@ -711,10 +792,12 @@ pub async fn gdrive_disconnect(app: tauri::AppHandle, state: tauri::State<'_, GD
session.access_token = None;
session.access_expiry = 0;
session.email = None;
session.scopes = None;
}
let mut stored = load_state(&app);
stored.refresh_token = None;
stored.email = None;
stored.scopes = None;
save_state(&app, &stored)?;
Ok(status_inner(&app, &state))
}
@@ -724,12 +807,13 @@ pub async fn gdrive_status(app: tauri::AppHandle, state: tauri::State<'_, GDrive
Ok(status_inner(&app, &state))
}
#[tauri::command]
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
let access_token = valid_access_token(&app, &state).await?;
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
let files = collect_local_files(&app)?;
let mut stored = load_state(&app);
async fn push_local_files(
app: &tauri::AppHandle,
access_token: &str,
folder_id: &str,
stored: &mut BackupState,
) -> Result<usize, String> {
let files = collect_local_files(app)?;
let mut uploaded = 0;
for (name, bytes) in &files {
let hash = hash_bytes(bytes);
@@ -739,18 +823,27 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
continue;
}
}
let drive_id = match &existing {
Some(record) => Some(record.drive_id.clone()),
None => find_file(&access_token, &folder_id, name).await?.map(|file| file.id),
let drive_id = match existing.filter(|record| !record.drive_id.is_empty()) {
Some(record) => Some(record.drive_id),
None => find_file(access_token, folder_id, name).await?.map(|file| file.id),
};
let result = upload_file(&access_token, &folder_id, name, bytes, drive_id).await?;
let result = upload_file(access_token, folder_id, name, bytes, drive_id).await?;
stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
uploaded += 1;
}
Ok(uploaded)
}
#[tauri::command]
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
let access_token = valid_access_token(&app, &state).await?;
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
let mut stored = load_state(&app);
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
if uploaded > 0 {
stored.last_backup = Some(now());
save_state(&app, &stored)?;
}
save_state(&app, &stored)?;
Ok(BackupOutcome {
uploaded,
status: Status {
@@ -758,6 +851,60 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
email: stored.email.clone(),
last_backup: stored.last_backup,
pending: false,
needs_reauth: false,
},
})
}
#[tauri::command]
pub async fn gdrive_sync(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<SyncOutcome, String> {
let access_token = valid_access_token(&app, &state).await?;
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
let remote = list_in_folder(&access_token, &folder_id).await?;
let mut stored = load_state(&app);
let mut downloaded = 0;
for file in &remote {
let is_dictionary = file.name == DICTIONARY_NAME;
if !is_dictionary && !safe_book_name(&file.name) {
continue;
}
let destination = if is_dictionary {
dictionary_path(&app)?
} else {
crate::library::library_dir(&app)?.join(&file.name)
};
if destination.exists() {
let record = stored
.files
.entry(file.name.clone())
.or_insert_with(|| FileRecord { hash: String::new(), drive_id: file.id.clone() });
record.drive_id = file.id.clone();
continue;
}
let bytes = download_file(&access_token, &file.id).await?;
crate::project::atomic_write(&destination, &bytes, false)?;
stored.files.insert(
file.name.clone(),
FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() },
);
downloaded += 1;
}
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
if uploaded > 0 || downloaded > 0 {
stored.last_backup = Some(now());
}
save_state(&app, &stored)?;
Ok(SyncOutcome {
uploaded,
downloaded,
status: Status {
connected: true,
email: stored.email.clone(),
last_backup: stored.last_backup,
pending: false,
needs_reauth: false,
},
})
}
+40 -3
View File
@@ -97,6 +97,12 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
app_submenu.insert(&settings, 3)?;
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
}
if let Some(window) = find_submenu("Window") {
let show_window = MenuItemBuilder::with_id("show-window", "Open Window")
.accelerator("CmdOrCtrl+Shift+M")
.build(handle)?;
window.append_items(&[&PredefinedMenuItem::separator(handle)?, &show_window])?;
}
if let Some(view) = find_submenu("View") {
let toggle_chapters = MenuItemBuilder::with_id("toggle-chapters", "Toggle Chapters")
.accelerator("CmdOrCtrl+\\")
@@ -177,10 +183,14 @@ pub fn run() {
) {
app.emit("menu-action", event.id().0.as_str()).ok();
}
#[cfg(target_os = "macos")]
if event.id().0.as_str() == "show-window" {
open_main_window(app);
}
});
}
builder
let app = builder
.invoke_handler(tauri::generate_handler![
epub::package_epub,
epub::unzip_epub,
@@ -200,9 +210,36 @@ pub fn run() {
gdrive::gdrive_disconnect,
gdrive::gdrive_status,
gdrive::gdrive_backup,
gdrive::gdrive_sync,
gdrive::gdrive_restore,
gdrive::gdrive_list_backups
])
.run(context)
.expect("error while running margin");
.build(context)
.expect("error while building margin");
app.run(|_app, _event| {
#[cfg(target_os = "macos")]
match &_event {
tauri::RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(),
tauri::RunEvent::Reopen { has_visible_windows: false, .. } => open_main_window(_app),
_ => {}
}
});
}
#[cfg(target_os = "macos")]
fn open_main_window(app: &tauri::AppHandle) {
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
return;
}
let Some(config) = app.config().app.windows.first().cloned() else {
return;
};
if let Ok(builder) = tauri::WebviewWindowBuilder::from_config(app, &config) {
let _ = builder.build();
}
}
+1
View File
@@ -120,6 +120,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
updated_at,
});
}
books.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
Ok(books)
}
+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);
+37 -7
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,14 +106,35 @@ export function BackupSettings() {
</button>
</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 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"
@@ -119,10 +146,13 @@ export function BackupSettings() {
</div>
<p className="backup-note">
Backs up automatically when you close margin, and every 15 minutes while there are changes.
Syncs on launch and right after connecting, backs up when you close margin, and every 15 minutes
while there are changes.
</p>
</>
)}
</>
)}
</div>
</div>
</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">
+12 -24
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,10 +308,7 @@ 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">
<Menu open={widthOpen} onClose={() => setWidthOpen(false)}>
{WIDTH_OPTIONS.map((w) => (
<button
key={w.id}
@@ -321,9 +321,7 @@ export function EditorView() {
{w.label}
</button>
))}
</div>
</>
)}
</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">
<Menu open={exportOpen} onClose={() => setExportOpen(false)}>
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
</div>
</>
)}
</Menu>
</div>
)}
{!isCompact && (
@@ -362,10 +355,7 @@ 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">
<Menu open={moreOpen} onClose={() => setMoreOpen(false)}>
<div className="menu-label">Editor width</div>
{WIDTH_OPTIONS.map((w) => (
<button
@@ -409,9 +399,7 @@ export function EditorView() {
)}
<div className="menu-sep" />
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
</div>
</>
)}
</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 {