2 Commits
Author SHA1 Message Date
github-actions[bot] df1a447a79 chore(release): v0.1.17 2026-08-24 04:11:47 +00:00
pj bcd5eb9ebc group of bug fixes 2026-08-24 09:17:26 +05:30
33 changed files with 976 additions and 241 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "margin-app", "name": "margin-app",
"private": true, "private": true,
"version": "0.1.16", "version": "0.1.17",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "margin-app" name = "margin-app"
version = "0.1.16" version = "0.1.17"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+163 -16
View File
@@ -15,6 +15,7 @@ use tauri::{Emitter, Manager};
const FOLDER_NAME: &str = "margin"; const FOLDER_NAME: &str = "margin";
const DICTIONARY_NAME: &str = "custom-dictionary.txt"; 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 SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
const AUTH_TIMEOUT_SECS: u64 = 120; const AUTH_TIMEOUT_SECS: u64 = 120;
@@ -58,6 +59,7 @@ pub struct Session {
access_expiry: u64, access_expiry: u64,
email: Option<String>, email: Option<String>,
folder_id: Option<String>, folder_id: Option<String>,
scopes: Option<String>,
} }
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
@@ -68,6 +70,8 @@ struct BackupState {
folder_id: Option<String>, folder_id: Option<String>,
last_backup: Option<u64>, last_backup: Option<u64>,
#[serde(default)] #[serde(default)]
scopes: Option<String>,
#[serde(default)]
files: HashMap<String, FileRecord>, files: HashMap<String, FileRecord>,
} }
@@ -84,6 +88,7 @@ pub struct Status {
email: Option<String>, email: Option<String>,
last_backup: Option<u64>, last_backup: Option<u64>,
pending: bool, pending: bool,
needs_reauth: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -92,6 +97,15 @@ pub struct RestoreResult {
restored: usize, restored: usize,
} }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncOutcome {
uploaded: usize,
downloaded: usize,
#[serde(flatten)]
status: Status,
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BackupOutcome { pub struct BackupOutcome {
@@ -164,6 +178,13 @@ fn urlencode(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect() 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 { fn drive_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'") s.replace('\\', "\\\\").replace('\'', "\\'")
} }
@@ -260,6 +281,8 @@ struct TokenResponse {
refresh_token: Option<String>, refresh_token: Option<String>,
#[serde(default)] #[serde(default)]
expires_in: u64, expires_in: u64,
#[serde(default)]
scope: Option<String>,
} }
#[derive(Deserialize)] #[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 refresh = refresh.ok_or("Not connected to Google Drive.")?;
let creds = load_credentials()?; 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 access_token = tokens.access_token.clone();
{ {
let mut session = state.0.lock().unwrap(); 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 { if let Some(rotated) = &tokens.refresh_token {
session.refresh_token = Some(rotated.clone()); 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); 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); let _ = save_state(app, &stored);
} }
Ok(access_token) 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 { fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status {
let (connected, email) = { let (connected, email, scopes) = {
let session = state.0.lock().unwrap(); 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 stored = load_state(app);
let pending = connected && compute_pending(app, &stored); let pending = connected && compute_pending(app, &stored);
let granted = scopes.or_else(|| stored.scopes.clone());
Status { Status {
connected, connected,
email: email.or(stored.email), email: email.or(stored.email),
last_backup: stored.last_backup, last_backup: stored.last_backup,
pending, 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.refresh_token = stored.refresh_token;
session.email = stored.email; session.email = stored.email;
session.folder_id = stored.folder_id; 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( async fn complete_auth(
@@ -631,6 +701,15 @@ async fn complete_auth(
.map_err(|e| e.to_string())??; .map_err(|e| e.to_string())??;
let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?; 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 email = fetch_email(&tokens.access_token).await?;
let folder_id = ensure_folder(&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.access_expiry = now() + tokens.expires_in.saturating_sub(60);
session.email = Some(email.clone()); session.email = Some(email.clone());
session.folder_id = Some(folder_id.clone()); session.folder_id = Some(folder_id.clone());
session.scopes = Some(granted.clone());
} }
let mut stored = load_state(app); let mut stored = load_state(app);
@@ -652,6 +732,7 @@ async fn complete_auth(
} }
stored.email = Some(email); stored.email = Some(email);
stored.folder_id = Some(folder_id); stored.folder_id = Some(folder_id);
stored.scopes = Some(granted);
save_state(app, &stored)?; save_state(app, &stored)?;
Ok(()) Ok(())
} }
@@ -711,10 +792,12 @@ pub async fn gdrive_disconnect(app: tauri::AppHandle, state: tauri::State<'_, GD
session.access_token = None; session.access_token = None;
session.access_expiry = 0; session.access_expiry = 0;
session.email = None; session.email = None;
session.scopes = None;
} }
let mut stored = load_state(&app); let mut stored = load_state(&app);
stored.refresh_token = None; stored.refresh_token = None;
stored.email = None; stored.email = None;
stored.scopes = None;
save_state(&app, &stored)?; save_state(&app, &stored)?;
Ok(status_inner(&app, &state)) 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)) Ok(status_inner(&app, &state))
} }
#[tauri::command] async fn push_local_files(
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> { app: &tauri::AppHandle,
let access_token = valid_access_token(&app, &state).await?; access_token: &str,
let folder_id = ensure_folder_id(&app, &state, &access_token).await?; folder_id: &str,
let files = collect_local_files(&app)?; stored: &mut BackupState,
let mut stored = load_state(&app); ) -> Result<usize, String> {
let files = collect_local_files(app)?;
let mut uploaded = 0; let mut uploaded = 0;
for (name, bytes) in &files { for (name, bytes) in &files {
let hash = hash_bytes(bytes); let hash = hash_bytes(bytes);
@@ -739,18 +823,27 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
continue; continue;
} }
} }
let drive_id = match &existing { let drive_id = match existing.filter(|record| !record.drive_id.is_empty()) {
Some(record) => Some(record.drive_id.clone()), Some(record) => Some(record.drive_id),
None => find_file(&access_token, &folder_id, name).await?.map(|file| file.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 }); stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
uploaded += 1; 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 { if uploaded > 0 {
stored.last_backup = Some(now()); stored.last_backup = Some(now());
save_state(&app, &stored)?;
} }
save_state(&app, &stored)?;
Ok(BackupOutcome { Ok(BackupOutcome {
uploaded, uploaded,
status: Status { status: Status {
@@ -758,6 +851,60 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
email: stored.email.clone(), email: stored.email.clone(),
last_backup: stored.last_backup, last_backup: stored.last_backup,
pending: false, 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(&settings, 3)?;
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?; 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") { if let Some(view) = find_submenu("View") {
let toggle_chapters = MenuItemBuilder::with_id("toggle-chapters", "Toggle Chapters") let toggle_chapters = MenuItemBuilder::with_id("toggle-chapters", "Toggle Chapters")
.accelerator("CmdOrCtrl+\\") .accelerator("CmdOrCtrl+\\")
@@ -177,10 +183,14 @@ pub fn run() {
) { ) {
app.emit("menu-action", event.id().0.as_str()).ok(); 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![ .invoke_handler(tauri::generate_handler![
epub::package_epub, epub::package_epub,
epub::unzip_epub, epub::unzip_epub,
@@ -200,9 +210,36 @@ pub fn run() {
gdrive::gdrive_disconnect, gdrive::gdrive_disconnect,
gdrive::gdrive_status, gdrive::gdrive_status,
gdrive::gdrive_backup, gdrive::gdrive_backup,
gdrive::gdrive_sync,
gdrive::gdrive_restore, gdrive::gdrive_restore,
gdrive::gdrive_list_backups gdrive::gdrive_list_backups
]) ])
.run(context) .build(context)
.expect("error while running margin"); .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, updated_at,
}); });
} }
books.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
Ok(books) Ok(books)
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Margin", "productName": "Margin",
"version": "0.1.16", "version": "0.1.17",
"identifier": "studio.margin.app", "identifier": "studio.margin.app",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+21 -8
View File
@@ -11,7 +11,7 @@ import { useBook } from "./store/useBook";
import { useBackup } from "./store/useBackup"; import { useBackup } from "./store/useBackup";
import { useExportPreview } from "./store/useExportPreview"; import { useExportPreview } from "./store/useExportPreview";
import { isDesktop } from "./ipc"; import { isDesktop } from "./ipc";
import { createAndOpenBook, saveBook } from "./library"; import { createAndOpenBook, lastBookId, loadBook, saveBook } from "./library";
import { runExport } from "./export/run"; import { runExport } from "./export/run";
import { checkForUpdates } from "./updater"; import { checkForUpdates } from "./updater";
@@ -51,12 +51,7 @@ function App() {
if ((!book || !dirty) && !connected) return; if ((!book || !dirty) && !connected) return;
event.preventDefault(); event.preventDefault();
if (book && dirty) await saveBook(book).catch(() => {}); if (book && dirty) await saveBook(book).catch(() => {});
if (connected) { if (connected) useBackup.getState().backup(true);
await Promise.race([
useBackup.getState().backup(true),
new Promise((resolve) => setTimeout(resolve, 8000)),
]);
}
win.destroy(); win.destroy();
}); });
return () => { return () => {
@@ -76,16 +71,34 @@ function App() {
useEffect(() => { useEffect(() => {
if (!isDesktop) return; if (!isDesktop) return;
let first = true;
const tick = async () => { const tick = async () => {
await useBackup.getState().refresh(); await useBackup.getState().refresh();
const state = useBackup.getState(); 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(); tick();
const id = setInterval(tick, 15 * 60 * 1000); const id = setInterval(tick, 15 * 60 * 1000);
return () => clearInterval(id); 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 ( return (
<> <>
{book ? <EditorView /> : <Library onOpen={openBook} />} {book ? <EditorView /> : <Library onOpen={openBook} />}
+11 -1
View File
@@ -6,6 +6,7 @@ export interface BackupStatus {
email: string | null; email: string | null;
lastBackup: number | null; lastBackup: number | null;
pending: boolean; pending: boolean;
needsReauth: boolean;
} }
export interface RestoreResult { export interface RestoreResult {
@@ -16,11 +17,16 @@ export interface BackupOutcome extends BackupStatus {
uploaded: number; uploaded: number;
} }
export interface SyncOutcome extends BackupStatus {
uploaded: number;
downloaded: number;
}
export interface RemoteBackup { export interface RemoteBackup {
name: string; 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> { export async function gdriveStatus(): Promise<BackupStatus> {
if (!isDesktop) return OFFLINE; if (!isDesktop) return OFFLINE;
@@ -39,6 +45,10 @@ export async function gdriveBackup(): Promise<BackupOutcome> {
return invoke<BackupOutcome>("gdrive_backup"); return invoke<BackupOutcome>("gdrive_backup");
} }
export async function gdriveSync(): Promise<SyncOutcome> {
return invoke<SyncOutcome>("gdrive_sync");
}
export async function gdriveRestore(): Promise<RestoreResult> { export async function gdriveRestore(): Promise<RestoreResult> {
return invoke<RestoreResult>("gdrive_restore"); return invoke<RestoreResult>("gdrive_restore");
} }
+2 -4
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PAGE_TYPES } from "../model/book"; import { PAGE_TYPES } from "../model/book";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface AddPageMenuProps { interface AddPageMenuProps {
@@ -40,10 +41,7 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
}; };
}, [open]); }, [open]);
useEffect(() => { useFocusTrap(popRef, open);
if (!open) return;
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
}, [open]);
useEscapeLayer(open, () => { useEscapeLayer(open, () => {
setOpen(false); setOpen(false);
+53 -23
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { useBackup } from "../store/useBackup"; import { useBackup } from "../store/useBackup";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { formatBackupTime, gdriveListBackups } from "../backup"; import { formatBackupTime, gdriveListBackups } from "../backup";
export function BackupSettings() { export function BackupSettings() {
@@ -12,10 +13,12 @@ export function BackupSettings() {
const email = useBackup((s) => s.email); const email = useBackup((s) => s.email);
const lastBackup = useBackup((s) => s.lastBackup); const lastBackup = useBackup((s) => s.lastBackup);
const pending = useBackup((s) => s.pending); const pending = useBackup((s) => s.pending);
const needsReauth = useBackup((s) => s.needsReauth);
const phase = useBackup((s) => s.phase); const phase = useBackup((s) => s.phase);
const connect = useBackup((s) => s.connect); const connect = useBackup((s) => s.connect);
const disconnect = useBackup((s) => s.disconnect); const disconnect = useBackup((s) => s.disconnect);
const backup = useBackup((s) => s.backup); const backup = useBackup((s) => s.backup);
const sync = useBackup((s) => s.sync);
const restore = useBackup((s) => s.restore); const restore = useBackup((s) => s.restore);
const cancelConnect = useBackup((s) => s.cancelConnect); const cancelConnect = useBackup((s) => s.cancelConnect);
const openAuthUrl = useBackup((s) => s.openAuthUrl); const openAuthUrl = useBackup((s) => s.openAuthUrl);
@@ -23,10 +26,13 @@ export function BackupSettings() {
const [remoteCount, setRemoteCount] = useState<number | null>(null); const [remoteCount, setRemoteCount] = useState<number | null>(null);
const [confirmRestore, setConfirmRestore] = useState(false); const [confirmRestore, setConfirmRestore] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const working = phase === "working"; const working = phase === "working";
useFocusTrap(panelRef, open && !confirmRestore);
useEffect(() => { useEffect(() => {
if (!open || !connected) { if (!open || !connected || needsReauth) {
setRemoteCount(null); setRemoteCount(null);
return; return;
} }
@@ -37,7 +43,7 @@ export function BackupSettings() {
return () => { return () => {
active = false; active = false;
}; };
}, [open, connected, lastBackup]); }, [open, connected, needsReauth, lastBackup]);
useEscapeLayer(open, close); useEscapeLayer(open, close);
@@ -46,7 +52,7 @@ export function BackupSettings() {
return ( return (
<> <>
<div className="overlay" onClick={close}> <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"> <div className="panel-head">
<h2>Backup &amp; Sync</h2> <h2>Backup &amp; Sync</h2>
<button className="icon-btn" onClick={close} title="Close"> <button className="icon-btn" onClick={close} title="Close">
@@ -100,27 +106,51 @@ export function BackupSettings() {
</button> </button>
</div> </div>
<div className="backup-status-row"> {needsReauth ? (
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} /> <>
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span> <div className="backup-warn">
</div> 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"> <div className="backup-actions">
<button className="btn-primary" disabled={working} onClick={() => backup()}> <button className="btn-primary" disabled={working} onClick={() => sync()}>
{working ? "Working…" : "Back up now"} {working ? "Working…" : "Sync now"}
</button> </button>
<button <button className="btn-ghost" disabled={working} onClick={() => backup()}>
className="btn-ghost" Back up only
disabled={working || remoteCount === 0} </button>
onClick={() => setConfirmRestore(true)} <button
> className="btn-ghost"
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`} disabled={working || remoteCount === 0}
</button> onClick={() => setConfirmRestore(true)}
</div> >
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
</button>
</div>
<p className="backup-note"> <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
</p> while there are changes.
</p>
</>
)}
</> </>
)} )}
</div> </div>
+4 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface ConfirmDialogProps { interface ConfirmDialogProps {
@@ -12,16 +13,18 @@ interface ConfirmDialogProps {
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) { export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null); const confirmRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
confirmRef.current?.focus(); confirmRef.current?.focus();
}, []); }, []);
useEscapeLayer(true, onClose); useEscapeLayer(true, onClose);
useFocusTrap(panelRef);
return ( return (
<div className="overlay" onClick={onClose}> <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"> <div className="panel-head">
<h2>{title}</h2> <h2>{title}</h2>
<button className="icon-btn" onClick={onClose} title="Close"> <button className="icon-btn" onClick={onClose} title="Close">
+66 -78
View File
@@ -5,6 +5,7 @@ import { Sidebar } from "./Sidebar";
import { Dock } from "./Dock"; import { Dock } from "./Dock";
import { ResizeHandle } from "./ResizeHandle"; import { ResizeHandle } from "./ResizeHandle";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { Menu } from "./Menu";
import { Settings } from "./Settings"; import { Settings } from "./Settings";
import { BackupButton } from "./BackupButton"; import { BackupButton } from "./BackupButton";
import { CoverView } from "./CoverView"; import { CoverView } from "./CoverView";
@@ -23,6 +24,7 @@ import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book"; import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library"; import { saveBook } from "../library";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { focusTrapped } from "../focus";
import { isDesktop, runWritingTool } from "../ipc"; import { isDesktop, runWritingTool } from "../ipc";
import { useCompact } from "../useMedia"; import { useCompact } from "../useMedia";
import { issueSignature, rememberWord, runProof } from "../proofing"; import { issueSignature, rememberWord, runProof } from "../proofing";
@@ -57,7 +59,7 @@ export function EditorView() {
editorRef.current = editor; editorRef.current = editor;
const lastChapterRef = useRef<string>(""); const lastChapterRef = useRef<string>("");
const [sidebarOpen, setSidebarOpen] = useState(!isCompact); const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
const [dock, setDock] = useState(!isCompact); const [dock, setDock] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
@@ -119,7 +121,7 @@ export function EditorView() {
}, []); }, []);
const focusEditorSoon = useCallback(() => { const focusEditorSoon = useCallback(() => {
if (useBook.getState().pendingTitleFocus) return; if (useBook.getState().pendingTitleFocus || focusTrapped()) return;
requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false })); requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false }));
}, []); }, []);
@@ -162,6 +164,7 @@ export function EditorView() {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (focusTrapped()) return;
if (e.metaKey && e.altKey && e.key.toLowerCase() === "f") { if (e.metaKey && e.altKey && e.key.toLowerCase() === "f") {
e.preventDefault(); e.preventDefault();
openFind(true); openFind(true);
@@ -214,7 +217,7 @@ export function EditorView() {
useEffect(() => { useEffect(() => {
setSidebarOpen(!isCompact); setSidebarOpen(!isCompact);
setDock(!isCompact); if (isCompact) setDock(false);
}, [isCompact]); }, [isCompact]);
useEffect(() => { useEffect(() => {
@@ -305,25 +308,20 @@ export function EditorView() {
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width"> <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" /> <Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
</button> </button>
{widthOpen && ( <Menu open={widthOpen} onClose={() => setWidthOpen(false)}>
<> {WIDTH_OPTIONS.map((w) => (
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} /> <button
<div className="menu"> key={w.id}
{WIDTH_OPTIONS.map((w) => ( data-on={width === w.id}
<button onClick={() => {
key={w.id} setWidth(w.id);
data-on={width === w.id} setWidthOpen(false);
onClick={() => { }}
setWidth(w.id); >
setWidthOpen(false); {w.label}
}} </button>
> ))}
{w.label} </Menu>
</button>
))}
</div>
</>
)}
</div> </div>
)} )}
{!isCompact && ( {!isCompact && (
@@ -331,15 +329,10 @@ export function EditorView() {
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export"> <button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" /> <Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
</button> </button>
{exportOpen && ( <Menu open={exportOpen} onClose={() => setExportOpen(false)}>
<> <button onClick={() => handleExport("pdf")}>Export PDF…</button>
<div className="menu-backdrop" onClick={() => setExportOpen(false)} /> <button onClick={() => handleExport("epub")}>Export EPUB…</button>
<div className="menu"> </Menu>
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
</div>
</>
)}
</div> </div>
)} )}
{!isCompact && ( {!isCompact && (
@@ -362,56 +355,51 @@ export function EditorView() {
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options"> <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" /> <Icon d="M5 12h.01M12 12h.01M19 12h.01" />
</button> </button>
{moreOpen && ( <Menu open={moreOpen} onClose={() => setMoreOpen(false)}>
<> <div className="menu-label">Editor width</div>
<div className="menu-backdrop" onClick={() => setMoreOpen(false)} /> {WIDTH_OPTIONS.map((w) => (
<div className="menu"> <button
<div className="menu-label">Editor width</div> key={w.id}
{WIDTH_OPTIONS.map((w) => ( data-on={width === w.id}
<button onClick={() => {
key={w.id} setWidth(w.id);
data-on={width === w.id} setMoreOpen(false);
onClick={() => { }}
setWidth(w.id); >
setMoreOpen(false); {w.label}
}} </button>
> ))}
{w.label} <div className="menu-sep" />
</button> <button
))} onClick={() => {
setMoreOpen(false);
handleExport("pdf");
}}
>
Export PDF…
</button>
<button
onClick={() => {
setMoreOpen(false);
handleExport("epub");
}}
>
Export EPUB…
</button>
{proofingAvailable && !coverActive && (
<>
<div className="menu-sep" /> <div className="menu-sep" />
<button <button data-on={spelling} onClick={toggleSpelling}>
onClick={() => { Check spelling
setMoreOpen(false);
handleExport("pdf");
}}
>
Export PDF…
</button> </button>
<button <button data-on={grammar} onClick={toggleGrammar}>
onClick={() => { Check grammar
setMoreOpen(false);
handleExport("epub");
}}
>
Export EPUB…
</button> </button>
{proofingAvailable && !coverActive && ( </>
<> )}
<div className="menu-sep" /> <div className="menu-sep" />
<button data-on={spelling} onClick={toggleSpelling}> <button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
Check spelling </Menu>
</button>
<button data-on={grammar} onClick={toggleGrammar}>
Check grammar
</button>
</>
)}
<div className="menu-sep" />
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
</div>
</>
)}
</div> </div>
)} )}
</div> </div>
+11 -9
View File
@@ -8,6 +8,7 @@ import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
import { compilePdf } from "../ipc"; import { compilePdf } from "../ipc";
import { saveBytes } from "../project"; import { saveBytes } from "../project";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
@@ -83,6 +84,7 @@ function ExportPreviewModal() {
}, [book]); }, [book]);
useEscapeLayer(true, close); useEscapeLayer(true, close);
useFocusTrap(panelRef);
useEffect(() => { useEffect(() => {
const el = panelRef.current; 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"> <button className="icon-btn" disabled={!pdf || zoom <= ZOOM_MIN} onClick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out">
<Icon d="M5 12h14" /> <Icon d="M5 12h14" />
</button> </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"> <button className="icon-btn" disabled={!pdf || zoom >= ZOOM_MAX} onClick={() => adjustZoom(ZOOM_STEP)} title="Zoom in">
<Icon d="M12 5v14M5 12h14" /> <Icon d="M12 5v14M5 12h14" />
</button> </button>
@@ -207,7 +209,7 @@ function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; on
const stageRef = useRef<HTMLDivElement>(null); const stageRef = useRef<HTMLDivElement>(null);
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null); const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
const [baseRatio, setBaseRatio] = useState<number | null>(null); const [baseRatio, setBaseRatio] = useState<number | null>(null);
const [stageWidth, setStageWidth] = useState(0); const [stage, setStage] = useState({ width: 0, height: 0 });
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -229,21 +231,21 @@ function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; on
}, [data, onPages]); }, [data, onPages]);
useEffect(() => { useEffect(() => {
const stage = stageRef.current; const el = stageRef.current;
if (!stage) return; if (!el) return;
const update = () => setStageWidth(stage.clientWidth); const update = () => setStage({ width: el.clientWidth, height: el.clientHeight });
update(); update();
const observer = new ResizeObserver(update); const observer = new ResizeObserver(update);
observer.observe(stage); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
}, [doc]); }, [doc]);
const fit = Math.max(280, stageWidth - 56); const pageFit = Math.max(240, Math.min(stage.width - 56, (stage.height - 56) / (baseRatio ?? 1)));
const displayWidth = Math.round(fit * zoom); const displayWidth = Math.round(pageFit * zoom);
return ( return (
<div className="preview-stage" ref={stageRef}> <div className="preview-stage" ref={stageRef}>
{doc && baseRatio !== null && stageWidth > 0 && ( {doc && baseRatio !== null && stage.width > 0 && (
<div className="preview-col"> <div className="preview-col">
{Array.from({ length: doc.numPages }, (_, i) => ( {Array.from({ length: doc.numPages }, (_, i) => (
<PdfPage <PdfPage
+7 -2
View File
@@ -96,7 +96,12 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
}; };
}, [editor, pendingNav, activeChapterId]); }, [editor, pendingNav, activeChapterId]);
useEscapeLayer(open, onClose); const close = () => {
editor?.commands.focus();
onClose();
};
useEscapeLayer(open, close);
if (!open || !editor) return null; 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)}> <button className="find-toggle" data-on={wholeWord} title="Whole word" onClick={() => setWholeWord((v) => !v)}>
<span className="find-ww">ab</span> <span className="find-ww">ab</span>
</button> </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} /> <Icon d="M18 6L6 18M6 6l12 12" size={14} />
</button> </button>
</div> </div>
+14 -1
View File
@@ -1,8 +1,19 @@
import { useEffect, useState } from "react"; 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 type { Book } from "../model/book";
import { importEpub } from "../import/epub"; import { importEpub } from "../import/epub";
import { clearPositions } from "../editor/positions"; import { clearPositions } from "../editor/positions";
import { clearChapterStates } from "../editor/session";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
@@ -66,6 +77,8 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
if (!pendingDelete) return; if (!pendingDelete) return;
await deleteBook(pendingDelete.id); await deleteBook(pendingDelete.id);
clearPositions(pendingDelete.id); clearPositions(pendingDelete.id);
clearChapterStates(pendingDelete.id);
if (lastBookId() === pendingDelete.id) rememberLastBook(null);
setPendingDelete(null); setPendingDelete(null);
refresh(); 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 { useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useFocusTrap } from "../focus";
import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing"; import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing";
function humanize(category: string): string { function humanize(category: string): string {
@@ -20,6 +21,8 @@ const POP_WIDTH = 264;
export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) { export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) {
const popRef = useRef<HTMLDivElement>(null); const popRef = useRef<HTMLDivElement>(null);
useFocusTrap(popRef);
useEffect(() => { useEffect(() => {
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return; 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"; import { applyPaneWidth, currentPaneWidth, resetPaneWidth, type Pane } from "../panes";
const STEP = 16;
export function ResizeHandle({ pane }: { pane: Pane }) { export function ResizeHandle({ pane }: { pane: Pane }) {
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => { const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
@@ -23,12 +25,26 @@ export function ResizeHandle({ pane }: { pane: Pane }) {
handle.addEventListener("pointerup", onUp); 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 ( return (
<div <div
className="pane-resizer" className="pane-resizer"
role="separator" role="separator"
aria-orientation="vertical" aria-orientation="vertical"
aria-label={pane === "sidebar" ? "Resize chapters panel" : "Resize preview panel"}
tabIndex={0}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
onDoubleClick={() => resetPaneWidth(pane)} onDoubleClick={() => resetPaneWidth(pane)}
title="Drag to resize · double-click to reset" title="Drag to resize · double-click to reset"
/> />
+18 -5
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface RowMenuProps { interface RowMenuProps {
label: string; label: string;
onDuplicate?: () => void; onDuplicate?: () => void;
onMove?: () => void;
onDelete: () => void; onDelete: () => void;
onToggleTitle?: () => void; onToggleTitle?: () => void;
titleHidden?: boolean; titleHidden?: boolean;
@@ -13,9 +15,10 @@ interface RowMenuProps {
marginHidden?: boolean; marginHidden?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
className?: string; 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); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -34,10 +37,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
setOpen((v) => !v); setOpen((v) => !v);
}; };
useEffect(() => { useFocusTrap(popRef, open);
if (!open) return;
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
}, [open]);
useEscapeLayer(open, () => { useEscapeLayer(open, () => {
setOpen(false); setOpen(false);
@@ -89,6 +89,12 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
onDuplicate?.(); onDuplicate?.();
}; };
const moveOut = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onMove?.();
};
const remove = (e: React.MouseEvent) => { const remove = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setOpen(false); setOpen(false);
@@ -101,6 +107,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
ref={btnRef} ref={btnRef}
className={`row-menu-btn ${className}`} className={`row-menu-btn ${className}`}
data-open={open} data-open={open}
tabIndex={tabIndex}
title={label} title={label}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={toggle} onClick={toggle}
@@ -136,6 +143,12 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
Duplicate Duplicate
</button> </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}> <button className="row-menu-item danger" onClick={remove}>
<Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} /> <Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} />
Delete 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 type { ReactNode } from "react";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { listSystemFonts } from "../ipc"; import { listSystemFonts } from "../ipc";
@@ -14,6 +14,7 @@ import {
pairingFor, pairingFor,
} from "../model/fonts"; } from "../model/fonts";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
const LANGUAGES = [ const LANGUAGES = [
@@ -86,6 +87,13 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
})); }));
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []); const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null); 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(() => { useEffect(() => {
if (systemFontCache) return; if (systemFontCache) return;
@@ -96,6 +104,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
}, []); }, []);
useEscapeLayer(true, onClose); useEscapeLayer(true, onClose);
useFocusTrap(panelRef);
if (!book) return null; if (!book) return null;
@@ -115,7 +124,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
return ( return (
<div className="overlay" onClick={onClose}> <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"> <div className="panel-head">
<h2>Book setup</h2> <h2>Book setup</h2>
<button className="icon-btn" onClick={onClose} title="Close"> <button className="icon-btn" onClick={onClose} title="Close">
@@ -124,7 +133,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
</div> </div>
<div className="panel-body"> <div className="panel-body">
<Field label="Title"> <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>
<Field label="Subtitle"> <Field label="Subtitle">
<input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} /> <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 { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { RowMenu } from "./RowMenu"; import { RowMenu } from "./RowMenu";
import { MoveChapterDialog } from "./MoveChapterDialog";
import { relativeTime } from "../time"; import { relativeTime } from "../time";
import { isDesktop } from "../ipc";
interface Row { interface Row {
chapter: Chapter; chapter: Chapter;
@@ -37,6 +39,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const [dragIndex, setDragIndex] = useState<number | null>(null); const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null); const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | 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 [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now()); 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) => { 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 === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
setActiveChapter(id); setActiveChapter(id);
@@ -240,6 +244,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
</span> </span>
<RowMenu <RowMenu
label="Page options" label="Page options"
tabIndex={row.chapter.id === tabStopId ? 0 : -1}
onOpenChange={(open) => onOpenChange={(open) =>
setMenuOpenId((cur) => (open ? row.chapter.id : cur === row.chapter.id ? null : cur)) 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} marginHidden={!!row.chapter.noMargin}
onToggleMargin={() => setChapterNoMargin(row.chapter.id, !row.chapter.noMargin)} onToggleMargin={() => setChapterNoMargin(row.chapter.id, !row.chapter.noMargin)}
onDuplicate={() => duplicateChapter(row.chapter.id)} onDuplicate={() => duplicateChapter(row.chapter.id)}
onMove={isDesktop ? () => setPendingMove({ chapter: row.chapter, label: rowLabel }) : undefined}
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })} onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
/> />
</li> </li>
@@ -276,6 +282,14 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
)} )}
</div> </div>
{pendingMove && (
<MoveChapterDialog
chapter={pendingMove.chapter}
label={pendingMove.label}
onClose={() => setPendingMove(null)}
/>
)}
{pendingDelete && ( {pendingDelete && (
<ConfirmDialog <ConfirmDialog
title="Delete" title="Delete"
+5 -1
View File
@@ -1,6 +1,8 @@
import { useRef } from "react";
import { useUpdater } from "../store/useUpdater"; import { useUpdater } from "../store/useUpdater";
import { installUpdate, dismissUpdate } from "../updater"; import { installUpdate, dismissUpdate } from "../updater";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
const TITLES: Record<string, string> = { const TITLES: Record<string, string> = {
@@ -21,7 +23,9 @@ export function UpdateDialog() {
const error = useUpdater((s) => s.error); const error = useUpdater((s) => s.error);
const busy = phase === "downloading" || phase === "installing"; const busy = phase === "downloading" || phase === "installing";
const panelRef = useRef<HTMLDivElement>(null);
useEscapeLayer(phase !== "idle" && !busy, dismissUpdate); useEscapeLayer(phase !== "idle" && !busy, dismissUpdate);
useFocusTrap(panelRef, phase !== "idle");
if (phase === "idle") return null; if (phase === "idle") return null;
@@ -30,7 +34,7 @@ export function UpdateDialog() {
return ( return (
<div className="overlay" onClick={close}> <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"> <div className="panel-head">
<h2>{TITLES[phase]}</h2> <h2>{TITLES[phase]}</h2>
{!busy && ( {!busy && (
+62 -64
View File
@@ -1,9 +1,9 @@
import { useEffect, useLayoutEffect, useRef } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"; import { EditorContent, type Editor as TiptapEditor } from "@tiptap/react";
import { EditorState } from "@tiptap/pm/state"; import { EditorState } from "@tiptap/pm/state";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions";
import { loadPosition, savePosition, type ChapterPosition } from "./positions"; import { loadPosition, savePosition, type ChapterPosition } from "./positions";
import { loadChapterState, saveChapterState, sharedEditor, type ChapterState } from "./session";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
interface EditorProps { interface EditorProps {
@@ -15,14 +15,6 @@ interface EditorProps {
onContentError: (error: Error) => void; 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 { function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState {
const base = editor.view.state; const base = editor.view.state;
try { 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) { export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError); const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError; onContentErrorRef.current = onContentError;
const cache = useRef(new Map<string, Cached>()); const [editor] = useState(() =>
const activeId = useRef(chapterId); sharedEditor({
onUpdate: (next) => onChangeRef.current(next),
onContentError: (error) => onContentErrorRef.current(error),
})
);
const active = useRef({ bookId: "", chapterId: "" });
const latest = useRef<ChapterPosition | null>(null); const latest = useRef<ChapterPosition | null>(null);
const restoreToken = useRef(0); 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 scrollerOf = (ed: TiptapEditor) => ed.view.dom.closest(".editor-pane") as HTMLElement | null;
const restorePosition = (ed: TiptapEditor, id: string, focus: boolean) => { 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 scroller = scrollerOf(ed);
const { from, to } = ed.state.selection; const { from, to } = ed.state.selection;
const scroll = scroller?.scrollTop ?? 0; const scroll = scroller?.scrollTop ?? loadChapterState(prevBook, prevChapter)?.scroll ?? 0;
const stored = useBook.getState().book?.chapters.find((c) => c.id === id)?.content ?? cache.current.get(id)?.content ?? ed.getJSON(); const stored = useBook.getState().book?.chapters.find((c) => c.id === prevChapter)?.content ?? ed.getJSON();
cache.current.set(id, { state: ed.view.state, content: stored, scroll }); saveChapterState(prevBook, prevChapter, { state: ed.view.state, content: stored, scroll });
savePosition(bookId, id, { from, to, 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(() => { useEffect(() => {
if (!editor) return;
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
activeId.current = chapterId;
restorePosition(editor, chapterId, true);
onReady(editor); onReady(editor);
return () => onReady(null); return () => {
stash(editor);
onReady(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]); }, [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(() => { useEffect(() => {
if (!editor) return;
const scroller = scrollerOf(editor); const scroller = scrollerOf(editor);
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
const capture = () => { const capture = () => {
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
const scroll = scroller?.scrollTop ?? 0; const scroll = scroller?.scrollTop ?? 0;
latest.current = { from, to, scroll }; 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; if (entry) entry.scroll = scroll;
}; };
const persist = () => { const persist = () => {
capture(); capture();
clearTimeout(timer); 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); editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true }); scroller?.addEventListener("scroll", persist, { passive: true });
@@ -145,9 +143,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
clearTimeout(timer); clearTimeout(timer);
editor.off("selectionUpdate", persist); editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", 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" />; 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 baseWidth = node.attrs.width ?? FIGURE_WIDTH[placement as FigurePlacement] ?? 100;
const width = resizable ? (dragWidth ?? baseWidth) : null; 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) => { const startResize = (e: React.PointerEvent) => {
e.preventDefault(); e.preventDefault();
const wrapper = frameRef.current?.closest(".figure") as HTMLElement | null; 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}> <div className="figure-frame" ref={frameRef}>
{src ? <img src={src} alt={alt} /> : <div className="figure-empty">No image</div>} {src ? <img src={src} alt={alt} /> : <div className="figure-empty">No image</div>}
{selected && src && resizable && ( {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> </div>
<input <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 type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { useEscapeLayer } from "../escape"; import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import type { Alignment } from "./align"; import type { Alignment } from "./align";
const ALIGN_ICONS: Record<Alignment, string> = { const ALIGN_ICONS: Record<Alignment, string> = {
@@ -28,7 +29,8 @@ function normalizeUrl(url: string): string {
export function FloatingToolbar({ editor }: { editor: Editor | null }) { export function FloatingToolbar({ editor }: { editor: Editor | null }) {
const fileRef = useRef<HTMLInputElement>(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 [linkOpen, setLinkOpen] = useState(false);
const [linkValue, setLinkValue] = useState(""); const [linkValue, setLinkValue] = useState("");
const [alignOpen, setAlignOpen] = useState(false); const [alignOpen, setAlignOpen] = useState(false);
@@ -40,9 +42,8 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
setLinkOpen(true); setLinkOpen(true);
}; };
useEffect(() => { useFocusTrap(linkPopRef, linkOpen);
if (linkOpen) linkInputRef.current?.focus(); useFocusTrap(alignPopRef, alignOpen);
}, [linkOpen]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
@@ -130,7 +131,7 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
{alignOpen && ( {alignOpen && (
<> <>
<div className="link-pop-backdrop" onMouseDown={() => setAlignOpen(false)} /> <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) => ( {(["left", "center", "right"] as Alignment[]).map((value) => (
<button <button
key={value} key={value}
@@ -153,9 +154,8 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
{linkOpen && ( {linkOpen && (
<> <>
<div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} /> <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 <input
ref={linkInputRef}
className="link-input" className="link-input"
value={linkValue} value={linkValue}
placeholder="https://…" 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 { invoke } from "@tauri-apps/api/core";
import { isDesktop } from "./ipc"; 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 { export interface BookSummary {
id: string; id: string;
@@ -39,6 +48,23 @@ export async function deleteBook(id: string): Promise<void> {
await invoke("delete_book", { id }); 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 { export function newBook(): Book {
return createBook(); 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() }; 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 { export function cloneChapter(chapter: Chapter): Chapter {
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`; const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() }; return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
+49 -7
View File
@@ -7,6 +7,7 @@ import {
gdriveDisconnect, gdriveDisconnect,
gdriveRestore, gdriveRestore,
gdriveStatus, gdriveStatus,
gdriveSync,
} from "../backup"; } from "../backup";
import { isDesktop } from "../ipc"; import { isDesktop } from "../ipc";
import { useBook } from "./useBook"; import { useBook } from "./useBook";
@@ -18,6 +19,7 @@ interface BackupState {
email: string | null; email: string | null;
lastBackup: number | null; lastBackup: number | null;
pending: boolean; pending: boolean;
needsReauth: boolean;
phase: Phase; phase: Phase;
error: string | null; error: string | null;
authUrl: string | null; authUrl: string | null;
@@ -33,6 +35,7 @@ interface BackupState {
copyAuthUrl: () => Promise<void>; copyAuthUrl: () => Promise<void>;
disconnect: () => Promise<void>; disconnect: () => Promise<void>;
backup: (silent?: boolean) => Promise<void>; backup: (silent?: boolean) => Promise<void>;
sync: (silent?: boolean) => Promise<void>;
restore: () => Promise<void>; restore: () => Promise<void>;
restoreFromDrive: () => Promise<void>; restoreFromDrive: () => Promise<void>;
openSettings: () => void; openSettings: () => void;
@@ -43,11 +46,31 @@ function notify(message: string) {
useBook.getState().setNotice(message); 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) => ({ export const useBackup = create<BackupState>((set, get) => ({
connected: false, connected: false,
email: null, email: null,
lastBackup: null, lastBackup: null,
pending: false, pending: false,
needsReauth: false,
phase: "idle", phase: "idle",
error: null, error: null,
authUrl: null, authUrl: null,
@@ -60,6 +83,7 @@ export const useBackup = create<BackupState>((set, get) => ({
email: status.email, email: status.email,
lastBackup: status.lastBackup, lastBackup: status.lastBackup,
pending: status.pending, pending: status.pending,
needsReauth: status.needsReauth,
}), }),
refresh: async () => { refresh: async () => {
if (!isDesktop) return; if (!isDesktop) return;
@@ -75,7 +99,7 @@ export const useBackup = create<BackupState>((set, get) => ({
.then((url) => set({ authUrl: url })) .then((url) => set({ authUrl: url }))
.catch((e) => { .catch((e) => {
set({ phase: "error", error: String(e), resolveConnect: null }); set({ phase: "error", error: String(e), resolveConnect: null });
notify(`Could not connect: ${e}`); notify(describe(e, "Could not connect"));
resolve(false); resolve(false);
}); });
}), }),
@@ -91,11 +115,13 @@ export const useBackup = create<BackupState>((set, get) => ({
await get().refresh(); await get().refresh();
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null }); set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
notify("Connected to Google Drive"); notify("Connected to Google Drive");
} else { await get().sync();
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null }); resolve?.(true);
notify(`Could not connect: ${error ?? "authorization failed"}`); 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: () => { openAuthUrl: () => {
const url = get().authUrl; 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"); if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
} catch (e) { } catch (e) {
set({ phase: "error", error: String(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 () => { 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"); notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
} catch (e) { } catch (e) {
set({ phase: "error", error: String(e) }); set({ phase: "error", error: String(e) });
notify(`Restore failed: ${e}`); await get().refresh();
notify(describe(e, "Restore failed"));
} }
}, },
restoreFromDrive: async () => { restoreFromDrive: async () => {
+4 -1
View File
@@ -14,7 +14,7 @@ import {
normalizeBook, normalizeBook,
} from "../model/book"; } from "../model/book";
import { loadActiveChapter, saveActiveChapter } from "../editor/positions"; import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
import { saveBook } from "../library"; import { rememberLastBook, saveBook } from "../library";
import { applyBookFonts, resetBookFonts } from "../book-style"; import { applyBookFonts, resetBookFonts } from "../book-style";
export const COVER_ID = "__cover__"; 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 valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? ""; const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
applyBookFonts(normalized.settings.fonts); applyBookFonts(normalized.settings.fonts);
rememberLastBook(normalized.id);
set({ book: normalized, activeChapterId, dirty: false, pendingTitleFocus: null }); set({ book: normalized, activeChapterId, dirty: false, pendingTitleFocus: null });
}, },
closeBook: () => { closeBook: () => {
flushOutgoing(get().book, get().dirty); flushOutgoing(get().book, get().dirty);
rememberLastBook(null);
resetBookFonts(); resetBookFonts();
set({ book: null, activeChapterId: "", dirty: false, pendingTitleFocus: null }); 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); const index = s.book.chapters.findIndex((c) => c.id === id);
if (index === -1) return {}; if (index === -1) return {};
const chapters = s.book.chapters.filter((c) => c.id !== id); const chapters = s.book.chapters.filter((c) => c.id !== id);
if (!chapters.length) chapters.push(createChapter(""));
const activeChapterId = const activeChapterId =
s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId; s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId;
return { dirty: true, activeChapterId, book: { ...s.book, chapters } }; return { dirty: true, activeChapterId, book: { ...s.book, chapters } };
+84 -1
View File
@@ -169,6 +169,14 @@ body.resizing .pane-resizer::after {
opacity: 0.55; opacity: 0.55;
} }
.pane-resizer:focus-visible {
outline: none;
}
.pane-resizer:focus-visible::after {
opacity: 1;
}
body.resizing { body.resizing {
cursor: col-resize; cursor: col-resize;
user-select: none; user-select: none;
@@ -192,6 +200,7 @@ body.resizing {
width: 0; width: 0;
border-right-width: 0; border-right-width: 0;
overflow: hidden; overflow: hidden;
visibility: hidden;
} }
body.resizing .sidebar { body.resizing .sidebar {
@@ -401,6 +410,7 @@ body.resizing .sidebar {
} }
.chapter:hover .row-menu-btn, .chapter:hover .row-menu-btn,
.chapter .row-menu-btn:focus-visible,
.chapter .row-menu-btn[data-open="true"] { .chapter .row-menu-btn[data-open="true"] {
opacity: 0.7; opacity: 0.7;
} }
@@ -1600,6 +1610,7 @@ body.resizing .sidebar {
} }
.card-book:hover .card-menu, .card-book:hover .card-menu,
.card-menu:focus-visible,
.card-menu[data-open="true"] { .card-menu[data-open="true"] {
opacity: 1; opacity: 1;
} }
@@ -1628,6 +1639,63 @@ body.resizing .sidebar {
color: var(--ink); 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 { .update-notes {
max-height: 180px; max-height: 180px;
overflow-y: auto; overflow-y: auto;
@@ -2779,9 +2847,22 @@ body.resizing .sidebar {
.backup-actions { .backup-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: 10px; 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 { .backup-note {
margin: 18px 0 0; margin: 18px 0 0;
color: var(--ink-faint); color: var(--ink-faint);
@@ -2815,11 +2896,13 @@ body.resizing .sidebar {
padding-left: env(safe-area-inset-left); padding-left: env(safe-area-inset-left);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
transform: translateX(-100%); 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 { .app[data-compact="true"][data-sidebar="true"] .sidebar {
transform: translateX(0); transform: translateX(0);
visibility: visible;
} }
.app[data-compact="true"] .dock { .app[data-compact="true"] .dock {