diff --git a/src-tauri/src/gdrive.rs b/src-tauri/src/gdrive.rs index 1bce948..fe09aec 100644 --- a/src-tauri/src/gdrive.rs +++ b/src-tauri/src/gdrive.rs @@ -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, folder_id: Option, + scopes: Option, } #[derive(Serialize, Deserialize, Default)] @@ -68,6 +70,8 @@ struct BackupState { folder_id: Option, last_backup: Option, #[serde(default)] + scopes: Option, + #[serde(default)] files: HashMap, } @@ -84,6 +88,7 @@ pub struct Status { email: Option, last_backup: Option, 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, #[serde(default)] expires_in: u64, + #[serde(default)] + scope: Option, } #[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); - stored.refresh_token = Some(rotated.clone()); + 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 { - 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 { + 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 { + 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 { + 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, }, }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fd3d0ea..9f867d7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -97,6 +97,12 @@ fn build_menu(handle: &tauri::AppHandle) -> tauri::Result 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(); + } } diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 44db948..1cd497a 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -120,6 +120,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result, String> { updated_at, }); } + books.sort_by_key(|b| std::cmp::Reverse(b.updated_at)); Ok(books) } diff --git a/src/App.tsx b/src/App.tsx index b767b9d..2583af9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ? : } diff --git a/src/backup.ts b/src/backup.ts index aec496c..fc256f3 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -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 { if (!isDesktop) return OFFLINE; @@ -39,6 +45,10 @@ export async function gdriveBackup(): Promise { return invoke("gdrive_backup"); } +export async function gdriveSync(): Promise { + return invoke("gdrive_sync"); +} + export async function gdriveRestore(): Promise { return invoke("gdrive_restore"); } diff --git a/src/components/AddPageMenu.tsx b/src/components/AddPageMenu.tsx index 0b6f8da..d7925c0 100644 --- a/src/components/AddPageMenu.tsx +++ b/src/components/AddPageMenu.tsx @@ -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(".add-page-item")?.focus(); - }, [open]); + useFocusTrap(popRef, open); useEscapeLayer(open, () => { setOpen(false); diff --git a/src/components/BackupSettings.tsx b/src/components/BackupSettings.tsx index 321cec3..2178a30 100644 --- a/src/components/BackupSettings.tsx +++ b/src/components/BackupSettings.tsx @@ -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(null); const [confirmRestore, setConfirmRestore] = useState(false); + const panelRef = useRef(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 ( <>
-
e.stopPropagation()}> +
e.stopPropagation()}>

Backup & Sync

-
- - {pending ? "Changes not backed up" : formatBackupTime(lastBackup)} -
+ {needsReauth ? ( + <> +
+ 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. +
+
+ +
+

+ On the Google screen, tick See, edit, create and delete only the specific Google Drive + files you use with this app before continuing. +

+ + ) : ( + <> +
+ + {pending ? "Changes not backed up" : formatBackupTime(lastBackup)} +
-
- - -
+
+ + + +
-

- 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. +

+ + )} )}
diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 5c5fff1..963374e 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -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(null); + const panelRef = useRef(null); useEffect(() => { confirmRef.current?.focus(); }, []); useEscapeLayer(true, onClose); + useFocusTrap(panelRef); return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

{title}

- {widthOpen && ( - <> -
setWidthOpen(false)} /> -
- {WIDTH_OPTIONS.map((w) => ( - - ))} -
- - )} + setWidthOpen(false)}> + {WIDTH_OPTIONS.map((w) => ( + + ))} +
)} {!isCompact && ( @@ -331,15 +329,10 @@ export function EditorView() { - {exportOpen && ( - <> -
setExportOpen(false)} /> -
- - -
- - )} + setExportOpen(false)}> + + +
)} {!isCompact && ( @@ -362,56 +355,51 @@ export function EditorView() { - {moreOpen && ( - <> -
setMoreOpen(false)} /> -
-
Editor width
- {WIDTH_OPTIONS.map((w) => ( - - ))} + setMoreOpen(false)}> +
Editor width
+ {WIDTH_OPTIONS.map((w) => ( + + ))} +
+ + + {proofingAvailable && !coverActive && ( + <>
- - - {proofingAvailable && !coverActive && ( - <> -
- - - - )} -
- -
- - )} + + )} +
+ +
)}
diff --git a/src/components/ExportPreview.tsx b/src/components/ExportPreview.tsx index 112f427..c94d418 100644 --- a/src/components/ExportPreview.tsx +++ b/src/components/ExportPreview.tsx @@ -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() { - {!compact && {Math.round(zoom * 100)}%} + {!compact && {zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`}} @@ -207,7 +209,7 @@ function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; on const stageRef = useRef(null); const [doc, setDoc] = useState(null); const [baseRatio, setBaseRatio] = useState(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 (
- {doc && baseRatio !== null && stageWidth > 0 && ( + {doc && baseRatio !== null && stage.width > 0 && (
{Array.from({ length: doc.numPages }, (_, i) => ( { + editor?.commands.focus(); + onClose(); + }; + + useEscapeLayer(open, close); if (!open || !editor) return null; @@ -239,7 +244,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps -
diff --git a/src/components/Library.tsx b/src/components/Library.tsx index dac5c4d..3a7cdee 100644 --- a/src/components/Library.tsx +++ b/src/components/Library.tsx @@ -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(); }; diff --git a/src/components/Menu.tsx b/src/components/Menu.tsx new file mode 100644 index 0000000..75d6256 --- /dev/null +++ b/src/components/Menu.tsx @@ -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(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("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 ( + <> +
+
+ {children} +
+ + ); +} diff --git a/src/components/MoveChapterDialog.tsx b/src/components/MoveChapterDialog.tsx new file mode 100644 index 0000000..31fc1e4 --- /dev/null +++ b/src/components/MoveChapterDialog.tsx @@ -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(null); + const [moving, setMoving] = useState(false); + const panelRef = useRef(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 ( +
+
e.stopPropagation()}> +
+

Move to book

+ +
+
+

+ Move {label} out of this book and into: +

+ {books === null ? ( +

Loading books…

+ ) : books.length === 0 ? ( +

There is no other book to move this into yet.

+ ) : ( +
    + {books.map((b) => ( +
  • + +
  • + ))} +
+ )} +
+
+ +
+
+
+ ); +} diff --git a/src/components/ProofPopover.tsx b/src/components/ProofPopover.tsx index b9e2727..915d0e1 100644 --- a/src/components/ProofPopover.tsx +++ b/src/components/ProofPopover.tsx @@ -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(null); + useFocusTrap(popRef); + useEffect(() => { const onDown = (e: MouseEvent) => { if (popRef.current?.contains(e.target as Node)) return; diff --git a/src/components/ResizeHandle.tsx b/src/components/ResizeHandle.tsx index 14e4551..5921487 100644 --- a/src/components/ResizeHandle.tsx +++ b/src/components/ResizeHandle.tsx @@ -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) => { e.preventDefault(); @@ -23,12 +25,26 @@ export function ResizeHandle({ pane }: { pane: Pane }) { handle.addEventListener("pointerup", onUp); }; + const onKeyDown = (e: KeyboardEvent) => { + 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 (
resetPaneWidth(pane)} title="Drag to resize · double-click to reset" /> diff --git a/src/components/RowMenu.tsx b/src/components/RowMenu.tsx index 4287f95..1f9fd6c 100644 --- a/src/components/RowMenu.tsx +++ b/src/components/RowMenu.tsx @@ -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(".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 )} + {onMove && ( + + )}
- set({ title: e.target.value })} /> + set({ title: e.target.value })} /> set({ subtitle: e.target.value })} /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index c12cec1..95140b6 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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(null); const [dropTarget, setDropTarget] = useState(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(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 }) { 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 })} /> @@ -276,6 +282,14 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) { )}
+ {pendingMove && ( + setPendingMove(null)} + /> + )} + {pendingDelete && ( = { @@ -21,7 +23,9 @@ export function UpdateDialog() { const error = useUpdater((s) => s.error); const busy = phase === "downloading" || phase === "installing"; + const panelRef = useRef(null); useEscapeLayer(phase !== "idle" && !busy, dismissUpdate); + useFocusTrap(panelRef, phase !== "idle"); if (phase === "idle") return null; @@ -30,7 +34,7 @@ export function UpdateDialog() { return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

{TITLES[phase]}

{!busy && ( diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 6ab7b94..cfd5b19 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -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()); - 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(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; 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 ; } diff --git a/src/editor/FigureView.tsx b/src/editor/FigureView.tsx index 283a31b..d09e076 100644 --- a/src/editor/FigureView.tsx +++ b/src/editor/FigureView.tsx @@ -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)
{src ? {alt} :
No image
} {selected && src && resizable && ( -
+
= { @@ -28,7 +29,8 @@ function normalizeUrl(url: string): string { export function FloatingToolbar({ editor }: { editor: Editor | null }) { const fileRef = useRef(null); - const linkInputRef = useRef(null); + const linkPopRef = useRef(null); + const alignPopRef = useRef(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 && ( <>
setAlignOpen(false)} /> -
e.stopPropagation()}> +
e.stopPropagation()}> {(["left", "center", "right"] as Alignment[]).map((value) => (