mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df1a447a79 | ||
|
|
bcd5eb9ebc |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "margin-app",
|
||||
"private": true,
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.17",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "margin-app"
|
||||
version = "0.1.16"
|
||||
version = "0.1.17"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
+163
-16
@@ -15,6 +15,7 @@ use tauri::{Emitter, Manager};
|
||||
|
||||
const FOLDER_NAME: &str = "margin";
|
||||
const DICTIONARY_NAME: &str = "custom-dictionary.txt";
|
||||
const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.file";
|
||||
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
|
||||
const AUTH_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
@@ -58,6 +59,7 @@ pub struct Session {
|
||||
access_expiry: u64,
|
||||
email: Option<String>,
|
||||
folder_id: Option<String>,
|
||||
scopes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
@@ -68,6 +70,8 @@ struct BackupState {
|
||||
folder_id: Option<String>,
|
||||
last_backup: Option<u64>,
|
||||
#[serde(default)]
|
||||
scopes: Option<String>,
|
||||
#[serde(default)]
|
||||
files: HashMap<String, FileRecord>,
|
||||
}
|
||||
|
||||
@@ -84,6 +88,7 @@ pub struct Status {
|
||||
email: Option<String>,
|
||||
last_backup: Option<u64>,
|
||||
pending: bool,
|
||||
needs_reauth: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -92,6 +97,15 @@ pub struct RestoreResult {
|
||||
restored: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncOutcome {
|
||||
uploaded: usize,
|
||||
downloaded: usize,
|
||||
#[serde(flatten)]
|
||||
status: Status,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupOutcome {
|
||||
@@ -164,6 +178,13 @@ fn urlencode(s: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
||||
}
|
||||
|
||||
fn has_drive_scope(scope: &str) -> bool {
|
||||
scope.split_whitespace().any(|s| s == DRIVE_SCOPE)
|
||||
}
|
||||
|
||||
const REAUTH_MESSAGE: &str =
|
||||
"margin needs the \"See, edit, create and delete only the specific Google Drive files you use with this app\" permission. Connect again and tick that box.";
|
||||
|
||||
fn drive_escape(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('\'', "\\'")
|
||||
}
|
||||
@@ -260,6 +281,8 @@ struct TokenResponse {
|
||||
refresh_token: Option<String>,
|
||||
#[serde(default)]
|
||||
expires_in: u64,
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -484,7 +507,29 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
|
||||
}
|
||||
let refresh = refresh.ok_or("Not connected to Google Drive.")?;
|
||||
let creds = load_credentials()?;
|
||||
let tokens = refresh_access_token(&creds, &refresh).await?;
|
||||
let tokens = match refresh_access_token(&creds, &refresh).await {
|
||||
Ok(tokens) => tokens,
|
||||
Err(e) => {
|
||||
if e.contains("invalid_grant") {
|
||||
forget_credentials(app, state);
|
||||
return Err(
|
||||
"Google Drive access has expired. Connect again to keep backing up.".to_string()
|
||||
);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Some(granted) = &tokens.scope {
|
||||
if !has_drive_scope(granted) {
|
||||
let mut session = state.0.lock().unwrap();
|
||||
session.scopes = Some(granted.clone());
|
||||
drop(session);
|
||||
let mut stored = load_state(app);
|
||||
stored.scopes = Some(granted.clone());
|
||||
let _ = save_state(app, &stored);
|
||||
return Err(REAUTH_MESSAGE.to_string());
|
||||
}
|
||||
}
|
||||
let access_token = tokens.access_token.clone();
|
||||
{
|
||||
let mut session = state.0.lock().unwrap();
|
||||
@@ -493,10 +538,18 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
|
||||
if let Some(rotated) = &tokens.refresh_token {
|
||||
session.refresh_token = Some(rotated.clone());
|
||||
}
|
||||
if let Some(granted) = &tokens.scope {
|
||||
session.scopes = Some(granted.clone());
|
||||
}
|
||||
}
|
||||
if let Some(rotated) = &tokens.refresh_token {
|
||||
if tokens.refresh_token.is_some() || tokens.scope.is_some() {
|
||||
let mut stored = load_state(app);
|
||||
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<BackupOutcome, String> {
|
||||
let access_token = valid_access_token(&app, &state).await?;
|
||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||
let files = collect_local_files(&app)?;
|
||||
let mut stored = load_state(&app);
|
||||
async fn push_local_files(
|
||||
app: &tauri::AppHandle,
|
||||
access_token: &str,
|
||||
folder_id: &str,
|
||||
stored: &mut BackupState,
|
||||
) -> Result<usize, String> {
|
||||
let files = collect_local_files(app)?;
|
||||
let mut uploaded = 0;
|
||||
for (name, bytes) in &files {
|
||||
let hash = hash_bytes(bytes);
|
||||
@@ -739,18 +823,27 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let drive_id = match &existing {
|
||||
Some(record) => Some(record.drive_id.clone()),
|
||||
None => find_file(&access_token, &folder_id, name).await?.map(|file| file.id),
|
||||
let drive_id = match existing.filter(|record| !record.drive_id.is_empty()) {
|
||||
Some(record) => Some(record.drive_id),
|
||||
None => find_file(access_token, folder_id, name).await?.map(|file| file.id),
|
||||
};
|
||||
let result = upload_file(&access_token, &folder_id, name, bytes, drive_id).await?;
|
||||
let result = upload_file(access_token, folder_id, name, bytes, drive_id).await?;
|
||||
stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id });
|
||||
uploaded += 1;
|
||||
}
|
||||
Ok(uploaded)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
|
||||
let access_token = valid_access_token(&app, &state).await?;
|
||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||
let mut stored = load_state(&app);
|
||||
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
|
||||
if uploaded > 0 {
|
||||
stored.last_backup = Some(now());
|
||||
save_state(&app, &stored)?;
|
||||
}
|
||||
save_state(&app, &stored)?;
|
||||
Ok(BackupOutcome {
|
||||
uploaded,
|
||||
status: Status {
|
||||
@@ -758,6 +851,60 @@ pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDrive
|
||||
email: stored.email.clone(),
|
||||
last_backup: stored.last_backup,
|
||||
pending: false,
|
||||
needs_reauth: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gdrive_sync(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<SyncOutcome, String> {
|
||||
let access_token = valid_access_token(&app, &state).await?;
|
||||
let folder_id = ensure_folder_id(&app, &state, &access_token).await?;
|
||||
let remote = list_in_folder(&access_token, &folder_id).await?;
|
||||
let mut stored = load_state(&app);
|
||||
let mut downloaded = 0;
|
||||
|
||||
for file in &remote {
|
||||
let is_dictionary = file.name == DICTIONARY_NAME;
|
||||
if !is_dictionary && !safe_book_name(&file.name) {
|
||||
continue;
|
||||
}
|
||||
let destination = if is_dictionary {
|
||||
dictionary_path(&app)?
|
||||
} else {
|
||||
crate::library::library_dir(&app)?.join(&file.name)
|
||||
};
|
||||
if destination.exists() {
|
||||
let record = stored
|
||||
.files
|
||||
.entry(file.name.clone())
|
||||
.or_insert_with(|| FileRecord { hash: String::new(), drive_id: file.id.clone() });
|
||||
record.drive_id = file.id.clone();
|
||||
continue;
|
||||
}
|
||||
let bytes = download_file(&access_token, &file.id).await?;
|
||||
crate::project::atomic_write(&destination, &bytes, false)?;
|
||||
stored.files.insert(
|
||||
file.name.clone(),
|
||||
FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() },
|
||||
);
|
||||
downloaded += 1;
|
||||
}
|
||||
|
||||
let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?;
|
||||
if uploaded > 0 || downloaded > 0 {
|
||||
stored.last_backup = Some(now());
|
||||
}
|
||||
save_state(&app, &stored)?;
|
||||
Ok(SyncOutcome {
|
||||
uploaded,
|
||||
downloaded,
|
||||
status: Status {
|
||||
connected: true,
|
||||
email: stored.email.clone(),
|
||||
last_backup: stored.last_backup,
|
||||
pending: false,
|
||||
needs_reauth: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+40
-3
@@ -97,6 +97,12 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
|
||||
app_submenu.insert(&settings, 3)?;
|
||||
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
|
||||
}
|
||||
if let Some(window) = find_submenu("Window") {
|
||||
let show_window = MenuItemBuilder::with_id("show-window", "Open Window")
|
||||
.accelerator("CmdOrCtrl+Shift+M")
|
||||
.build(handle)?;
|
||||
window.append_items(&[&PredefinedMenuItem::separator(handle)?, &show_window])?;
|
||||
}
|
||||
if let Some(view) = find_submenu("View") {
|
||||
let toggle_chapters = MenuItemBuilder::with_id("toggle-chapters", "Toggle Chapters")
|
||||
.accelerator("CmdOrCtrl+\\")
|
||||
@@ -177,10 +183,14 @@ pub fn run() {
|
||||
) {
|
||||
app.emit("menu-action", event.id().0.as_str()).ok();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if event.id().0.as_str() == "show-window" {
|
||||
open_main_window(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
builder
|
||||
let app = builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
epub::package_epub,
|
||||
epub::unzip_epub,
|
||||
@@ -200,9 +210,36 @@ pub fn run() {
|
||||
gdrive::gdrive_disconnect,
|
||||
gdrive::gdrive_status,
|
||||
gdrive::gdrive_backup,
|
||||
gdrive::gdrive_sync,
|
||||
gdrive::gdrive_restore,
|
||||
gdrive::gdrive_list_backups
|
||||
])
|
||||
.run(context)
|
||||
.expect("error while running margin");
|
||||
.build(context)
|
||||
.expect("error while building margin");
|
||||
|
||||
app.run(|_app, _event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
match &_event {
|
||||
tauri::RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(),
|
||||
tauri::RunEvent::Reopen { has_visible_windows: false, .. } => open_main_window(_app),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn open_main_window(app: &tauri::AppHandle) {
|
||||
use tauri::Manager;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
return;
|
||||
}
|
||||
let Some(config) = app.config().app.windows.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
if let Ok(builder) = tauri::WebviewWindowBuilder::from_config(app, &config) {
|
||||
let _ = builder.build();
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
books.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
|
||||
Ok(books)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Margin",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.17",
|
||||
"identifier": "studio.margin.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+21
-8
@@ -11,7 +11,7 @@ import { useBook } from "./store/useBook";
|
||||
import { useBackup } from "./store/useBackup";
|
||||
import { useExportPreview } from "./store/useExportPreview";
|
||||
import { isDesktop } from "./ipc";
|
||||
import { createAndOpenBook, saveBook } from "./library";
|
||||
import { createAndOpenBook, lastBookId, loadBook, saveBook } from "./library";
|
||||
import { runExport } from "./export/run";
|
||||
import { checkForUpdates } from "./updater";
|
||||
|
||||
@@ -51,12 +51,7 @@ function App() {
|
||||
if ((!book || !dirty) && !connected) return;
|
||||
event.preventDefault();
|
||||
if (book && dirty) await saveBook(book).catch(() => {});
|
||||
if (connected) {
|
||||
await Promise.race([
|
||||
useBackup.getState().backup(true),
|
||||
new Promise((resolve) => setTimeout(resolve, 8000)),
|
||||
]);
|
||||
}
|
||||
if (connected) useBackup.getState().backup(true);
|
||||
win.destroy();
|
||||
});
|
||||
return () => {
|
||||
@@ -76,16 +71,34 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
let first = true;
|
||||
const tick = async () => {
|
||||
await useBackup.getState().refresh();
|
||||
const state = useBackup.getState();
|
||||
if (state.connected && state.pending) state.backup(true);
|
||||
if (!state.connected || state.needsReauth) return;
|
||||
if (first) {
|
||||
first = false;
|
||||
state.sync(true);
|
||||
} else if (state.pending) {
|
||||
state.backup(true);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 15 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
const id = lastBookId();
|
||||
if (!id || useBook.getState().book) return;
|
||||
loadBook(id)
|
||||
.then((last) => {
|
||||
if (!useBook.getState().book) useBook.getState().openBook(last);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
||||
|
||||
+11
-1
@@ -6,6 +6,7 @@ export interface BackupStatus {
|
||||
email: string | null;
|
||||
lastBackup: number | null;
|
||||
pending: boolean;
|
||||
needsReauth: boolean;
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
@@ -16,11 +17,16 @@ export interface BackupOutcome extends BackupStatus {
|
||||
uploaded: number;
|
||||
}
|
||||
|
||||
export interface SyncOutcome extends BackupStatus {
|
||||
uploaded: number;
|
||||
downloaded: number;
|
||||
}
|
||||
|
||||
export interface RemoteBackup {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false };
|
||||
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false, needsReauth: false };
|
||||
|
||||
export async function gdriveStatus(): Promise<BackupStatus> {
|
||||
if (!isDesktop) return OFFLINE;
|
||||
@@ -39,6 +45,10 @@ export async function gdriveBackup(): Promise<BackupOutcome> {
|
||||
return invoke<BackupOutcome>("gdrive_backup");
|
||||
}
|
||||
|
||||
export async function gdriveSync(): Promise<SyncOutcome> {
|
||||
return invoke<SyncOutcome>("gdrive_sync");
|
||||
}
|
||||
|
||||
export async function gdriveRestore(): Promise<RestoreResult> {
|
||||
return invoke<RestoreResult>("gdrive_restore");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PAGE_TYPES } from "../model/book";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useFocusTrap } from "../focus";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface AddPageMenuProps {
|
||||
@@ -40,10 +41,7 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
|
||||
}, [open]);
|
||||
useFocusTrap(popRef, open);
|
||||
|
||||
useEscapeLayer(open, () => {
|
||||
setOpen(false);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useFocusTrap } from "../focus";
|
||||
import { formatBackupTime, gdriveListBackups } from "../backup";
|
||||
|
||||
export function BackupSettings() {
|
||||
@@ -12,10 +13,12 @@ export function BackupSettings() {
|
||||
const email = useBackup((s) => s.email);
|
||||
const lastBackup = useBackup((s) => s.lastBackup);
|
||||
const pending = useBackup((s) => s.pending);
|
||||
const needsReauth = useBackup((s) => s.needsReauth);
|
||||
const phase = useBackup((s) => s.phase);
|
||||
const connect = useBackup((s) => s.connect);
|
||||
const disconnect = useBackup((s) => s.disconnect);
|
||||
const backup = useBackup((s) => s.backup);
|
||||
const sync = useBackup((s) => s.sync);
|
||||
const restore = useBackup((s) => s.restore);
|
||||
const cancelConnect = useBackup((s) => s.cancelConnect);
|
||||
const openAuthUrl = useBackup((s) => s.openAuthUrl);
|
||||
@@ -23,10 +26,13 @@ export function BackupSettings() {
|
||||
|
||||
const [remoteCount, setRemoteCount] = useState<number | null>(null);
|
||||
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const working = phase === "working";
|
||||
|
||||
useFocusTrap(panelRef, open && !confirmRestore);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !connected) {
|
||||
if (!open || !connected || needsReauth) {
|
||||
setRemoteCount(null);
|
||||
return;
|
||||
}
|
||||
@@ -37,7 +43,7 @@ export function BackupSettings() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, connected, lastBackup]);
|
||||
}, [open, connected, needsReauth, lastBackup]);
|
||||
|
||||
useEscapeLayer(open, close);
|
||||
|
||||
@@ -46,7 +52,7 @@ export function BackupSettings() {
|
||||
return (
|
||||
<>
|
||||
<div className="overlay" onClick={close}>
|
||||
<div className="panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div ref={panelRef} className="panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="panel-head">
|
||||
<h2>Backup & Sync</h2>
|
||||
<button className="icon-btn" onClick={close} title="Close">
|
||||
@@ -100,27 +106,51 @@ export function BackupSettings() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="backup-status-row">
|
||||
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
||||
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
||||
</div>
|
||||
{needsReauth ? (
|
||||
<>
|
||||
<div className="backup-warn">
|
||||
margin can't reach your Drive folder. The permission to create and edit its own files was
|
||||
not granted, so nothing is being backed up.
|
||||
</div>
|
||||
<div className="backup-actions">
|
||||
<button className="btn-primary" disabled={working} onClick={connect}>
|
||||
{working ? "Working…" : "Reconnect Google Drive"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="backup-note">
|
||||
On the Google screen, tick <strong>See, edit, create and delete only the specific Google Drive
|
||||
files you use with this app</strong> before continuing.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="backup-status-row">
|
||||
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
||||
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
||||
</div>
|
||||
|
||||
<div className="backup-actions">
|
||||
<button className="btn-primary" disabled={working} onClick={() => backup()}>
|
||||
{working ? "Working…" : "Back up now"}
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={working || remoteCount === 0}
|
||||
onClick={() => setConfirmRestore(true)}
|
||||
>
|
||||
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="backup-actions">
|
||||
<button className="btn-primary" disabled={working} onClick={() => sync()}>
|
||||
{working ? "Working…" : "Sync now"}
|
||||
</button>
|
||||
<button className="btn-ghost" disabled={working} onClick={() => backup()}>
|
||||
Back up only
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
disabled={working || remoteCount === 0}
|
||||
onClick={() => setConfirmRestore(true)}
|
||||
>
|
||||
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="backup-note">
|
||||
Backs up automatically when you close margin, and every 15 minutes while there are changes.
|
||||
</p>
|
||||
<p className="backup-note">
|
||||
Syncs on launch and right after connecting, backs up when you close margin, and every 15 minutes
|
||||
while there are changes.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useFocusTrap } from "../focus";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
@@ -12,16 +13,18 @@ interface ConfirmDialogProps {
|
||||
|
||||
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
confirmRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEscapeLayer(true, onClose);
|
||||
useFocusTrap(panelRef);
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
||||
<div ref={panelRef} className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="panel-head">
|
||||
<h2>{title}</h2>
|
||||
<button className="icon-btn" onClick={onClose} title="Close">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Sidebar } from "./Sidebar";
|
||||
import { Dock } from "./Dock";
|
||||
import { ResizeHandle } from "./ResizeHandle";
|
||||
import { Icon } from "./Icon";
|
||||
import { Menu } from "./Menu";
|
||||
import { Settings } from "./Settings";
|
||||
import { BackupButton } from "./BackupButton";
|
||||
import { CoverView } from "./CoverView";
|
||||
@@ -23,6 +24,7 @@ import { WIDTH_OPTIONS } from "../width";
|
||||
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
||||
import { saveBook } from "../library";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { focusTrapped } from "../focus";
|
||||
import { isDesktop, runWritingTool } from "../ipc";
|
||||
import { useCompact } from "../useMedia";
|
||||
import { issueSignature, rememberWord, runProof } from "../proofing";
|
||||
@@ -57,7 +59,7 @@ export function EditorView() {
|
||||
editorRef.current = editor;
|
||||
const lastChapterRef = useRef<string>("");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
|
||||
const [dock, setDock] = useState(!isCompact);
|
||||
const [dock, setDock] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
@@ -119,7 +121,7 @@ export function EditorView() {
|
||||
}, []);
|
||||
|
||||
const focusEditorSoon = useCallback(() => {
|
||||
if (useBook.getState().pendingTitleFocus) return;
|
||||
if (useBook.getState().pendingTitleFocus || focusTrapped()) return;
|
||||
requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false }));
|
||||
}, []);
|
||||
|
||||
@@ -162,6 +164,7 @@ export function EditorView() {
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (focusTrapped()) return;
|
||||
if (e.metaKey && e.altKey && e.key.toLowerCase() === "f") {
|
||||
e.preventDefault();
|
||||
openFind(true);
|
||||
@@ -214,7 +217,7 @@ export function EditorView() {
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpen(!isCompact);
|
||||
setDock(!isCompact);
|
||||
if (isCompact) setDock(false);
|
||||
}, [isCompact]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -305,25 +308,20 @@ export function EditorView() {
|
||||
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
||||
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
|
||||
</button>
|
||||
{widthOpen && (
|
||||
<>
|
||||
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} />
|
||||
<div className="menu">
|
||||
{WIDTH_OPTIONS.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
data-on={width === w.id}
|
||||
onClick={() => {
|
||||
setWidth(w.id);
|
||||
setWidthOpen(false);
|
||||
}}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Menu open={widthOpen} onClose={() => setWidthOpen(false)}>
|
||||
{WIDTH_OPTIONS.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
data-on={width === w.id}
|
||||
onClick={() => {
|
||||
setWidth(w.id);
|
||||
setWidthOpen(false);
|
||||
}}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{!isCompact && (
|
||||
@@ -331,15 +329,10 @@ export function EditorView() {
|
||||
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
|
||||
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<>
|
||||
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
|
||||
<div className="menu">
|
||||
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
|
||||
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Menu open={exportOpen} onClose={() => setExportOpen(false)}>
|
||||
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
|
||||
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{!isCompact && (
|
||||
@@ -362,56 +355,51 @@ export function EditorView() {
|
||||
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options">
|
||||
<Icon d="M5 12h.01M12 12h.01M19 12h.01" />
|
||||
</button>
|
||||
{moreOpen && (
|
||||
<>
|
||||
<div className="menu-backdrop" onClick={() => setMoreOpen(false)} />
|
||||
<div className="menu">
|
||||
<div className="menu-label">Editor width</div>
|
||||
{WIDTH_OPTIONS.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
data-on={width === w.id}
|
||||
onClick={() => {
|
||||
setWidth(w.id);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
<Menu open={moreOpen} onClose={() => setMoreOpen(false)}>
|
||||
<div className="menu-label">Editor width</div>
|
||||
{WIDTH_OPTIONS.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
data-on={width === w.id}
|
||||
onClick={() => {
|
||||
setWidth(w.id);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="menu-sep" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
handleExport("pdf");
|
||||
}}
|
||||
>
|
||||
Export PDF…
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
handleExport("epub");
|
||||
}}
|
||||
>
|
||||
Export EPUB…
|
||||
</button>
|
||||
{proofingAvailable && !coverActive && (
|
||||
<>
|
||||
<div className="menu-sep" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
handleExport("pdf");
|
||||
}}
|
||||
>
|
||||
Export PDF…
|
||||
<button data-on={spelling} onClick={toggleSpelling}>
|
||||
Check spelling
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
handleExport("epub");
|
||||
}}
|
||||
>
|
||||
Export EPUB…
|
||||
<button data-on={grammar} onClick={toggleGrammar}>
|
||||
Check grammar
|
||||
</button>
|
||||
{proofingAvailable && !coverActive && (
|
||||
<>
|
||||
<div className="menu-sep" />
|
||||
<button data-on={spelling} onClick={toggleSpelling}>
|
||||
Check spelling
|
||||
</button>
|
||||
<button data-on={grammar} onClick={toggleGrammar}>
|
||||
Check grammar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="menu-sep" />
|
||||
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="menu-sep" />
|
||||
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
|
||||
import { compilePdf } from "../ipc";
|
||||
import { saveBytes } from "../project";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useFocusTrap } from "../focus";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
@@ -83,6 +84,7 @@ function ExportPreviewModal() {
|
||||
}, [book]);
|
||||
|
||||
useEscapeLayer(true, close);
|
||||
useFocusTrap(panelRef);
|
||||
|
||||
useEffect(() => {
|
||||
const el = panelRef.current;
|
||||
@@ -168,7 +170,7 @@ function ExportPreviewModal() {
|
||||
<button className="icon-btn" disabled={!pdf || zoom <= ZOOM_MIN} onClick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out">
|
||||
<Icon d="M5 12h14" />
|
||||
</button>
|
||||
{!compact && <span>{Math.round(zoom * 100)}%</span>}
|
||||
{!compact && <span>{zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`}</span>}
|
||||
<button className="icon-btn" disabled={!pdf || zoom >= ZOOM_MAX} onClick={() => adjustZoom(ZOOM_STEP)} title="Zoom in">
|
||||
<Icon d="M12 5v14M5 12h14" />
|
||||
</button>
|
||||
@@ -207,7 +209,7 @@ function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; on
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
||||
const [baseRatio, setBaseRatio] = useState<number | null>(null);
|
||||
const [stageWidth, setStageWidth] = useState(0);
|
||||
const [stage, setStage] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -229,21 +231,21 @@ function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; on
|
||||
}, [data, onPages]);
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
const update = () => setStageWidth(stage.clientWidth);
|
||||
const el = stageRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setStage({ width: el.clientWidth, height: el.clientHeight });
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(stage);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [doc]);
|
||||
|
||||
const fit = Math.max(280, stageWidth - 56);
|
||||
const displayWidth = Math.round(fit * zoom);
|
||||
const pageFit = Math.max(240, Math.min(stage.width - 56, (stage.height - 56) / (baseRatio ?? 1)));
|
||||
const displayWidth = Math.round(pageFit * zoom);
|
||||
|
||||
return (
|
||||
<div className="preview-stage" ref={stageRef}>
|
||||
{doc && baseRatio !== null && stageWidth > 0 && (
|
||||
{doc && baseRatio !== null && stage.width > 0 && (
|
||||
<div className="preview-col">
|
||||
{Array.from({ length: doc.numPages }, (_, i) => (
|
||||
<PdfPage
|
||||
|
||||
@@ -96,7 +96,12 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
||||
};
|
||||
}, [editor, pendingNav, activeChapterId]);
|
||||
|
||||
useEscapeLayer(open, onClose);
|
||||
const close = () => {
|
||||
editor?.commands.focus();
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEscapeLayer(open, close);
|
||||
|
||||
if (!open || !editor) return null;
|
||||
|
||||
@@ -239,7 +244,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
||||
<button className="find-toggle" data-on={wholeWord} title="Whole word" onClick={() => setWholeWord((v) => !v)}>
|
||||
<span className="find-ww">ab</span>
|
||||
</button>
|
||||
<button className="find-btn" title="Close (Esc)" onClick={onClose}>
|
||||
<button className="find-btn" title="Close (Esc)" onClick={close}>
|
||||
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useFocusTrap } from "../focus";
|
||||
import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing";
|
||||
|
||||
function humanize(category: string): string {
|
||||
@@ -20,6 +21,8 @@ const POP_WIDTH = 264;
|
||||
export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) {
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useFocusTrap(popRef);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (popRef.current?.contains(e.target as Node)) return;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PointerEvent } from "react";
|
||||
import type { KeyboardEvent, PointerEvent } from "react";
|
||||
import { applyPaneWidth, currentPaneWidth, resetPaneWidth, type Pane } from "../panes";
|
||||
|
||||
const STEP = 16;
|
||||
|
||||
export function ResizeHandle({ pane }: { pane: Pane }) {
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -23,12 +25,26 @@ export function ResizeHandle({ pane }: { pane: Pane }) {
|
||||
handle.addEventListener("pointerup", onUp);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const towards = pane === "sidebar" ? 1 : -1;
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
applyPaneWidth(pane, currentPaneWidth(pane) + (e.key === "ArrowRight" ? STEP : -STEP) * towards);
|
||||
} else if (e.key === "Home" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
resetPaneWidth(pane);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pane-resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={pane === "sidebar" ? "Resize chapters panel" : "Resize preview panel"}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onKeyDown}
|
||||
onDoubleClick={() => resetPaneWidth(pane)}
|
||||
title="Drag to resize · double-click to reset"
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useFocusTrap } from "../focus";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface RowMenuProps {
|
||||
label: string;
|
||||
onDuplicate?: () => void;
|
||||
onMove?: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleTitle?: () => void;
|
||||
titleHidden?: boolean;
|
||||
@@ -13,9 +15,10 @@ interface RowMenuProps {
|
||||
marginHidden?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
className?: string;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "" }: RowMenuProps) {
|
||||
export function RowMenu({ label, onDuplicate, onMove, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "", tabIndex }: RowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,10 +37,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
|
||||
}, [open]);
|
||||
useFocusTrap(popRef, open);
|
||||
|
||||
useEscapeLayer(open, () => {
|
||||
setOpen(false);
|
||||
@@ -89,6 +89,12 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
|
||||
onDuplicate?.();
|
||||
};
|
||||
|
||||
const moveOut = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onMove?.();
|
||||
};
|
||||
|
||||
const remove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
@@ -101,6 +107,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
|
||||
ref={btnRef}
|
||||
className={`row-menu-btn ${className}`}
|
||||
data-open={open}
|
||||
tabIndex={tabIndex}
|
||||
title={label}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={toggle}
|
||||
@@ -136,6 +143,12 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
|
||||
Duplicate
|
||||
</button>
|
||||
)}
|
||||
{onMove && (
|
||||
<button className="row-menu-item" onClick={moveOut}>
|
||||
<Icon d="M4 12h13M13 8l4 4-4 4M20 4v16" size={14} />
|
||||
Move to book…
|
||||
</button>
|
||||
)}
|
||||
<button className="row-menu-item danger" onClick={remove}>
|
||||
<Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} />
|
||||
Delete
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useBook } from "../store/useBook";
|
||||
import { listSystemFonts } from "../ipc";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
pairingFor,
|
||||
} from "../model/fonts";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useFocusTrap } from "../focus";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
const LANGUAGES = [
|
||||
@@ -86,6 +87,13 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
}));
|
||||
const [system, setSystem] = useState<string[]>(() => systemFontCache ?? []);
|
||||
const [advanced, setAdvanced] = useState(() => pairingFor(draft.fonts) === null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus();
|
||||
titleRef.current?.select();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFontCache) return;
|
||||
@@ -96,6 +104,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
}, []);
|
||||
|
||||
useEscapeLayer(true, onClose);
|
||||
useFocusTrap(panelRef);
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
@@ -115,7 +124,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div ref={panelRef} className="panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="panel-head">
|
||||
<h2>Book setup</h2>
|
||||
<button className="icon-btn" onClick={onClose} title="Close">
|
||||
@@ -124,7 +133,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<Field label="Title">
|
||||
<input autoFocus value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} />
|
||||
<input ref={titleRef} value={draft.title} placeholder="Untitled" onChange={(e) => set({ title: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Subtitle">
|
||||
<input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} />
|
||||
|
||||
@@ -5,7 +5,9 @@ import { AddPageMenu } from "./AddPageMenu";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Icon } from "./Icon";
|
||||
import { RowMenu } from "./RowMenu";
|
||||
import { MoveChapterDialog } from "./MoveChapterDialog";
|
||||
import { relativeTime } from "../time";
|
||||
import { isDesktop } from "../ipc";
|
||||
|
||||
interface Row {
|
||||
chapter: Chapter;
|
||||
@@ -37,6 +39,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
|
||||
const [pendingMove, setPendingMove] = useState<{ chapter: Chapter; label: string } | null>(null);
|
||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
@@ -142,6 +145,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
});
|
||||
|
||||
const onRowKeyDown = (e: React.KeyboardEvent, index: number, id: string) => {
|
||||
if ((e.target as HTMLElement).closest(".row-menu-btn")) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setActiveChapter(id);
|
||||
@@ -240,6 +244,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
</span>
|
||||
<RowMenu
|
||||
label="Page options"
|
||||
tabIndex={row.chapter.id === tabStopId ? 0 : -1}
|
||||
onOpenChange={(open) =>
|
||||
setMenuOpenId((cur) => (open ? row.chapter.id : cur === row.chapter.id ? null : cur))
|
||||
}
|
||||
@@ -248,6 +253,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
marginHidden={!!row.chapter.noMargin}
|
||||
onToggleMargin={() => setChapterNoMargin(row.chapter.id, !row.chapter.noMargin)}
|
||||
onDuplicate={() => duplicateChapter(row.chapter.id)}
|
||||
onMove={isDesktop ? () => setPendingMove({ chapter: row.chapter, label: rowLabel }) : undefined}
|
||||
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
|
||||
/>
|
||||
</li>
|
||||
@@ -276,6 +282,14 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingMove && (
|
||||
<MoveChapterDialog
|
||||
chapter={pendingMove.chapter}
|
||||
label={pendingMove.label}
|
||||
onClose={() => setPendingMove(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useRef } from "react";
|
||||
import { useUpdater } from "../store/useUpdater";
|
||||
import { installUpdate, dismissUpdate } from "../updater";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useFocusTrap } from "../focus";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
@@ -21,7 +23,9 @@ export function UpdateDialog() {
|
||||
const error = useUpdater((s) => s.error);
|
||||
|
||||
const busy = phase === "downloading" || phase === "installing";
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useEscapeLayer(phase !== "idle" && !busy, dismissUpdate);
|
||||
useFocusTrap(panelRef, phase !== "idle");
|
||||
|
||||
if (phase === "idle") return null;
|
||||
|
||||
@@ -30,7 +34,7 @@ export function UpdateDialog() {
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={close}>
|
||||
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
||||
<div ref={panelRef} className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="panel-head">
|
||||
<h2>{TITLES[phase]}</h2>
|
||||
{!busy && (
|
||||
|
||||
+62
-64
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { EditorContent, type Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { editorExtensions } from "./extensions";
|
||||
import { loadPosition, savePosition, type ChapterPosition } from "./positions";
|
||||
import { loadChapterState, saveChapterState, sharedEditor, type ChapterState } from "./session";
|
||||
import { useBook } from "../store/useBook";
|
||||
|
||||
interface EditorProps {
|
||||
@@ -15,14 +15,6 @@ interface EditorProps {
|
||||
onContentError: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface Cached {
|
||||
state: EditorState;
|
||||
content: JSONContent;
|
||||
scroll: number;
|
||||
}
|
||||
|
||||
const SCROLL_KEEPOUT = { top: 32, right: 0, bottom: 96, left: 0 };
|
||||
|
||||
function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState {
|
||||
const base = editor.view.state;
|
||||
try {
|
||||
@@ -35,31 +27,33 @@ function buildState(editor: TiptapEditor, content: JSONContent, onError: (error:
|
||||
}
|
||||
}
|
||||
|
||||
function stillMatches(editor: TiptapEditor, entry: ChapterState, content: JSONContent): boolean {
|
||||
if (entry.state.schema !== editor.schema) return false;
|
||||
if (entry.content === content) return true;
|
||||
try {
|
||||
return entry.state.doc.eq(editor.schema.nodeFromJSON(content));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const onContentErrorRef = useRef(onContentError);
|
||||
onContentErrorRef.current = onContentError;
|
||||
|
||||
const cache = useRef(new Map<string, Cached>());
|
||||
const activeId = useRef(chapterId);
|
||||
const [editor] = useState(() =>
|
||||
sharedEditor({
|
||||
onUpdate: (next) => onChangeRef.current(next),
|
||||
onContentError: (error) => onContentErrorRef.current(error),
|
||||
})
|
||||
);
|
||||
|
||||
const active = useRef({ bookId: "", chapterId: "" });
|
||||
const latest = useRef<ChapterPosition | null>(null);
|
||||
const restoreToken = useRef(0);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: editorExtensions,
|
||||
content,
|
||||
immediatelyRender: false,
|
||||
enableContentCheck: true,
|
||||
editorProps: {
|
||||
attributes: { class: "prose" },
|
||||
scrollThreshold: SCROLL_KEEPOUT,
|
||||
scrollMargin: SCROLL_KEEPOUT,
|
||||
},
|
||||
onContentError: ({ error }) => onContentErrorRef.current(error),
|
||||
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
|
||||
});
|
||||
|
||||
const scrollerOf = (ed: TiptapEditor) => ed.view.dom.closest(".editor-pane") as HTMLElement | null;
|
||||
|
||||
const restorePosition = (ed: TiptapEditor, id: string, focus: boolean) => {
|
||||
@@ -82,62 +76,66 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
|
||||
}
|
||||
};
|
||||
|
||||
const stash = (ed: TiptapEditor, id: string) => {
|
||||
const stash = (ed: TiptapEditor) => {
|
||||
const { bookId: prevBook, chapterId: prevChapter } = active.current;
|
||||
if (!prevChapter) return;
|
||||
const scroller = scrollerOf(ed);
|
||||
const { from, to } = ed.state.selection;
|
||||
const scroll = scroller?.scrollTop ?? 0;
|
||||
const stored = useBook.getState().book?.chapters.find((c) => c.id === id)?.content ?? cache.current.get(id)?.content ?? ed.getJSON();
|
||||
cache.current.set(id, { state: ed.view.state, content: stored, scroll });
|
||||
savePosition(bookId, id, { from, to, scroll });
|
||||
const scroll = scroller?.scrollTop ?? loadChapterState(prevBook, prevChapter)?.scroll ?? 0;
|
||||
const stored = useBook.getState().book?.chapters.find((c) => c.id === prevChapter)?.content ?? ed.getJSON();
|
||||
saveChapterState(prevBook, prevChapter, { state: ed.view.state, content: stored, scroll });
|
||||
savePosition(prevBook, prevChapter, { from, to, scroll });
|
||||
};
|
||||
|
||||
const applyChapter = (ed: TiptapEditor, id: string, next: JSONContent, focus: boolean) => {
|
||||
const entry = loadChapterState(bookId, id);
|
||||
if (entry && stillMatches(ed, entry, next)) {
|
||||
entry.content = next;
|
||||
ed.view.updateState(entry.state);
|
||||
const scroller = scrollerOf(ed);
|
||||
if (scroller) scroller.scrollTop = entry.scroll;
|
||||
++restoreToken.current;
|
||||
if (focus) ed.commands.focus(undefined, { scrollIntoView: false });
|
||||
return;
|
||||
}
|
||||
ed.view.updateState(buildState(ed, next, onContentErrorRef.current));
|
||||
saveChapterState(bookId, id, { state: ed.view.state, content: next, scroll: 0 });
|
||||
restorePosition(ed, id, focus);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const prev = active.current;
|
||||
if (prev.bookId === bookId && prev.chapterId === chapterId) return;
|
||||
stash(editor);
|
||||
applyChapter(editor, chapterId, content, !prev.chapterId);
|
||||
active.current = { bookId, chapterId };
|
||||
latest.current = null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bookId, chapterId, content, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
|
||||
activeId.current = chapterId;
|
||||
restorePosition(editor, chapterId, true);
|
||||
onReady(editor);
|
||||
return () => onReady(null);
|
||||
return () => {
|
||||
stash(editor);
|
||||
onReady(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editor) return;
|
||||
const prev = activeId.current;
|
||||
if (prev === chapterId) return;
|
||||
|
||||
stash(editor, prev);
|
||||
const entry = cache.current.get(chapterId);
|
||||
if (entry && entry.content === content) {
|
||||
editor.view.updateState(entry.state);
|
||||
const scroller = scrollerOf(editor);
|
||||
if (scroller) scroller.scrollTop = entry.scroll;
|
||||
++restoreToken.current;
|
||||
} else {
|
||||
editor.view.updateState(buildState(editor, content, onContentErrorRef.current));
|
||||
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
|
||||
restorePosition(editor, chapterId, false);
|
||||
}
|
||||
activeId.current = chapterId;
|
||||
latest.current = null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chapterId, content, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const scroller = scrollerOf(editor);
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const capture = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
const scroll = scroller?.scrollTop ?? 0;
|
||||
latest.current = { from, to, scroll };
|
||||
const entry = cache.current.get(activeId.current);
|
||||
const entry = loadChapterState(active.current.bookId, active.current.chapterId);
|
||||
if (entry) entry.scroll = scroll;
|
||||
};
|
||||
const persist = () => {
|
||||
capture();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => savePosition(bookId, activeId.current, latest.current!), 400);
|
||||
timer = setTimeout(() => savePosition(active.current.bookId, active.current.chapterId, latest.current!), 400);
|
||||
};
|
||||
editor.on("selectionUpdate", persist);
|
||||
scroller?.addEventListener("scroll", persist, { passive: true });
|
||||
@@ -145,9 +143,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
|
||||
clearTimeout(timer);
|
||||
editor.off("selectionUpdate", persist);
|
||||
scroller?.removeEventListener("scroll", persist);
|
||||
if (latest.current) savePosition(bookId, activeId.current, latest.current);
|
||||
if (latest.current) savePosition(active.current.bookId, active.current.chapterId, latest.current);
|
||||
};
|
||||
}, [editor, bookId]);
|
||||
}, [editor]);
|
||||
|
||||
return <EditorContent editor={editor} className="editor-host" />;
|
||||
}
|
||||
@@ -20,6 +20,14 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
|
||||
const baseWidth = node.attrs.width ?? FIGURE_WIDTH[placement as FigurePlacement] ?? 100;
|
||||
const width = resizable ? (dragWidth ?? baseWidth) : null;
|
||||
|
||||
const nudge = (delta: number) => updateAttributes({ width: clampWidth((width ?? baseWidth) + delta) });
|
||||
|
||||
const onHandleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
nudge(e.key === "ArrowRight" ? 5 : -5);
|
||||
};
|
||||
|
||||
const startResize = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
const wrapper = frameRef.current?.closest(".figure") as HTMLElement | null;
|
||||
@@ -52,7 +60,13 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
|
||||
<div className="figure-frame" ref={frameRef}>
|
||||
{src ? <img src={src} alt={alt} /> : <div className="figure-empty">No image</div>}
|
||||
{selected && src && resizable && (
|
||||
<div className="figure-handle" onPointerDown={startResize} title="Drag to resize" />
|
||||
<button
|
||||
className="figure-handle"
|
||||
aria-label="Resize image"
|
||||
title="Drag or press arrow keys to resize"
|
||||
onPointerDown={startResize}
|
||||
onKeyDown={onHandleKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { useFocusTrap } from "../focus";
|
||||
import type { Alignment } from "./align";
|
||||
|
||||
const ALIGN_ICONS: Record<Alignment, string> = {
|
||||
@@ -28,7 +29,8 @@ function normalizeUrl(url: string): string {
|
||||
|
||||
export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||
const linkPopRef = useRef<HTMLDivElement>(null);
|
||||
const alignPopRef = useRef<HTMLDivElement>(null);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkValue, setLinkValue] = useState("");
|
||||
const [alignOpen, setAlignOpen] = useState(false);
|
||||
@@ -40,9 +42,8 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
setLinkOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (linkOpen) linkInputRef.current?.focus();
|
||||
}, [linkOpen]);
|
||||
useFocusTrap(linkPopRef, linkOpen);
|
||||
useFocusTrap(alignPopRef, alignOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
@@ -130,7 +131,7 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
{alignOpen && (
|
||||
<>
|
||||
<div className="link-pop-backdrop" onMouseDown={() => setAlignOpen(false)} />
|
||||
<div className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div ref={alignPopRef} className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||
{(["left", "center", "right"] as Alignment[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
@@ -153,9 +154,8 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
{linkOpen && (
|
||||
<>
|
||||
<div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} />
|
||||
<div className="link-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div ref={linkPopRef} className="link-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={linkInputRef}
|
||||
className="link-input"
|
||||
value={linkValue}
|
||||
placeholder="https://…"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,6 +1,15 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { isDesktop } from "./ipc";
|
||||
import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book";
|
||||
import {
|
||||
type Book,
|
||||
type Chapter,
|
||||
SCHEMA_VERSION,
|
||||
createBook,
|
||||
insertByKind,
|
||||
normalizeBook,
|
||||
schemaVersion,
|
||||
starterBook,
|
||||
} from "./model/book";
|
||||
|
||||
export interface BookSummary {
|
||||
id: string;
|
||||
@@ -39,6 +48,23 @@ export async function deleteBook(id: string): Promise<void> {
|
||||
await invoke("delete_book", { id });
|
||||
}
|
||||
|
||||
export async function moveChapterToBook(chapter: Chapter, targetId: string): Promise<void> {
|
||||
const target = normalizeBook(await loadBook(targetId));
|
||||
const moved = { ...chapter, content: structuredClone(chapter.content), updatedAt: Date.now() };
|
||||
await saveBook({ ...target, chapters: insertByKind(target.chapters, moved) });
|
||||
}
|
||||
|
||||
const LAST_BOOK_KEY = "margin-last-book";
|
||||
|
||||
export function lastBookId(): string | null {
|
||||
return localStorage.getItem(LAST_BOOK_KEY);
|
||||
}
|
||||
|
||||
export function rememberLastBook(id: string | null): void {
|
||||
if (id) localStorage.setItem(LAST_BOOK_KEY, id);
|
||||
else localStorage.removeItem(LAST_BOOK_KEY);
|
||||
}
|
||||
|
||||
export function newBook(): Book {
|
||||
return createBook();
|
||||
}
|
||||
|
||||
@@ -171,6 +171,19 @@ export function createPart(title = ""): Chapter {
|
||||
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: "part", updatedAt: Date.now() };
|
||||
}
|
||||
|
||||
export function insertByKind(chapters: Chapter[], chapter: Chapter): Chapter[] {
|
||||
const kind = chapterKind(chapter);
|
||||
const next = [...chapters];
|
||||
const at =
|
||||
kind === "front"
|
||||
? next.filter((c) => chapterKind(c) === "front").length
|
||||
: kind === "back"
|
||||
? next.length
|
||||
: next.length - next.filter((c) => chapterKind(c) === "back").length;
|
||||
next.splice(at, 0, chapter);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function cloneChapter(chapter: Chapter): Chapter {
|
||||
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
|
||||
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
|
||||
|
||||
+49
-7
@@ -7,6 +7,7 @@ import {
|
||||
gdriveDisconnect,
|
||||
gdriveRestore,
|
||||
gdriveStatus,
|
||||
gdriveSync,
|
||||
} from "../backup";
|
||||
import { isDesktop } from "../ipc";
|
||||
import { useBook } from "./useBook";
|
||||
@@ -18,6 +19,7 @@ interface BackupState {
|
||||
email: string | null;
|
||||
lastBackup: number | null;
|
||||
pending: boolean;
|
||||
needsReauth: boolean;
|
||||
phase: Phase;
|
||||
error: string | null;
|
||||
authUrl: string | null;
|
||||
@@ -33,6 +35,7 @@ interface BackupState {
|
||||
copyAuthUrl: () => Promise<void>;
|
||||
disconnect: () => Promise<void>;
|
||||
backup: (silent?: boolean) => Promise<void>;
|
||||
sync: (silent?: boolean) => Promise<void>;
|
||||
restore: () => Promise<void>;
|
||||
restoreFromDrive: () => Promise<void>;
|
||||
openSettings: () => void;
|
||||
@@ -43,11 +46,31 @@ function notify(message: string) {
|
||||
useBook.getState().setNotice(message);
|
||||
}
|
||||
|
||||
const RECONNECT = /Connect again/;
|
||||
|
||||
function describe(error: unknown, fallback: string): string {
|
||||
const message = String(error).replace(/^Error:\s*/, "");
|
||||
return RECONNECT.test(message) ? message : `${fallback}: ${message}`;
|
||||
}
|
||||
|
||||
function plural(n: number, word: string): string {
|
||||
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function syncMessage(uploaded: number, downloaded: number): string {
|
||||
if (!uploaded && !downloaded) return "Everything is already in sync";
|
||||
const parts = [];
|
||||
if (downloaded) parts.push(`brought back ${plural(downloaded, "file")}`);
|
||||
if (uploaded) parts.push(`backed up ${plural(uploaded, "file")}`);
|
||||
return `Synced with Google Drive: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
export const useBackup = create<BackupState>((set, get) => ({
|
||||
connected: false,
|
||||
email: null,
|
||||
lastBackup: null,
|
||||
pending: false,
|
||||
needsReauth: false,
|
||||
phase: "idle",
|
||||
error: null,
|
||||
authUrl: null,
|
||||
@@ -60,6 +83,7 @@ export const useBackup = create<BackupState>((set, get) => ({
|
||||
email: status.email,
|
||||
lastBackup: status.lastBackup,
|
||||
pending: status.pending,
|
||||
needsReauth: status.needsReauth,
|
||||
}),
|
||||
refresh: async () => {
|
||||
if (!isDesktop) return;
|
||||
@@ -75,7 +99,7 @@ export const useBackup = create<BackupState>((set, get) => ({
|
||||
.then((url) => set({ authUrl: url }))
|
||||
.catch((e) => {
|
||||
set({ phase: "error", error: String(e), resolveConnect: null });
|
||||
notify(`Could not connect: ${e}`);
|
||||
notify(describe(e, "Could not connect"));
|
||||
resolve(false);
|
||||
});
|
||||
}),
|
||||
@@ -91,11 +115,13 @@ export const useBackup = create<BackupState>((set, get) => ({
|
||||
await get().refresh();
|
||||
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
|
||||
notify("Connected to Google Drive");
|
||||
} else {
|
||||
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
|
||||
notify(`Could not connect: ${error ?? "authorization failed"}`);
|
||||
await get().sync();
|
||||
resolve?.(true);
|
||||
return;
|
||||
}
|
||||
resolve?.(ok);
|
||||
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
|
||||
notify(describe(error ?? "authorization failed", "Could not connect"));
|
||||
resolve?.(false);
|
||||
},
|
||||
openAuthUrl: () => {
|
||||
const url = get().authUrl;
|
||||
@@ -132,7 +158,22 @@ export const useBackup = create<BackupState>((set, get) => ({
|
||||
if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
if (!silent) notify(`Backup failed: ${e}`);
|
||||
await get().refresh();
|
||||
if (!silent || RECONNECT.test(String(e))) notify(describe(e, "Backup failed"));
|
||||
}
|
||||
},
|
||||
sync: async (silent = false) => {
|
||||
if (!get().connected || get().phase === "working") return;
|
||||
set({ phase: "working", error: null });
|
||||
try {
|
||||
const outcome = await gdriveSync();
|
||||
get().apply(outcome);
|
||||
set((s) => ({ phase: "idle", restoreNonce: outcome.downloaded > 0 ? s.restoreNonce + 1 : s.restoreNonce }));
|
||||
if (!silent) notify(syncMessage(outcome.uploaded, outcome.downloaded));
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
await get().refresh();
|
||||
if (!silent || RECONNECT.test(String(e))) notify(describe(e, "Sync failed"));
|
||||
}
|
||||
},
|
||||
restore: async () => {
|
||||
@@ -144,7 +185,8 @@ export const useBackup = create<BackupState>((set, get) => ({
|
||||
notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
|
||||
} catch (e) {
|
||||
set({ phase: "error", error: String(e) });
|
||||
notify(`Restore failed: ${e}`);
|
||||
await get().refresh();
|
||||
notify(describe(e, "Restore failed"));
|
||||
}
|
||||
},
|
||||
restoreFromDrive: async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
normalizeBook,
|
||||
} from "../model/book";
|
||||
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
|
||||
import { saveBook } from "../library";
|
||||
import { rememberLastBook, saveBook } from "../library";
|
||||
import { applyBookFonts, resetBookFonts } from "../book-style";
|
||||
|
||||
export const COVER_ID = "__cover__";
|
||||
@@ -70,10 +70,12 @@ export const useBook = create<BookState>((set, get) => ({
|
||||
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
|
||||
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
|
||||
applyBookFonts(normalized.settings.fonts);
|
||||
rememberLastBook(normalized.id);
|
||||
set({ book: normalized, activeChapterId, dirty: false, pendingTitleFocus: null });
|
||||
},
|
||||
closeBook: () => {
|
||||
flushOutgoing(get().book, get().dirty);
|
||||
rememberLastBook(null);
|
||||
resetBookFonts();
|
||||
set({ book: null, activeChapterId: "", dirty: false, pendingTitleFocus: null });
|
||||
},
|
||||
@@ -163,6 +165,7 @@ export const useBook = create<BookState>((set, get) => ({
|
||||
const index = s.book.chapters.findIndex((c) => c.id === id);
|
||||
if (index === -1) return {};
|
||||
const chapters = s.book.chapters.filter((c) => c.id !== id);
|
||||
if (!chapters.length) chapters.push(createChapter(""));
|
||||
const activeChapterId =
|
||||
s.activeChapterId === id ? (chapters[index] ?? chapters[index - 1])?.id ?? COVER_ID : s.activeChapterId;
|
||||
return { dirty: true, activeChapterId, book: { ...s.book, chapters } };
|
||||
|
||||
+84
-1
@@ -169,6 +169,14 @@ body.resizing .pane-resizer::after {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.pane-resizer:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pane-resizer:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body.resizing {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
@@ -192,6 +200,7 @@ body.resizing {
|
||||
width: 0;
|
||||
border-right-width: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
body.resizing .sidebar {
|
||||
@@ -401,6 +410,7 @@ body.resizing .sidebar {
|
||||
}
|
||||
|
||||
.chapter:hover .row-menu-btn,
|
||||
.chapter .row-menu-btn:focus-visible,
|
||||
.chapter .row-menu-btn[data-open="true"] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -1600,6 +1610,7 @@ body.resizing .sidebar {
|
||||
}
|
||||
|
||||
.card-book:hover .card-menu,
|
||||
.card-menu:focus-visible,
|
||||
.card-menu[data-open="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1628,6 +1639,63 @@ body.resizing .sidebar {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.panel-move {
|
||||
width: 440px;
|
||||
}
|
||||
|
||||
.move-empty {
|
||||
margin: 0;
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.move-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.move-target {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.move-target:hover:not(:disabled),
|
||||
.move-target:focus-visible {
|
||||
background: var(--raised);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.move-target:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.move-title {
|
||||
font-family: var(--font-book);
|
||||
font-size: var(--t-4);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.move-author {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-1);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.update-notes {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
@@ -2779,9 +2847,22 @@ body.resizing .sidebar {
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-warn {
|
||||
margin: 18px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--raised);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
line-height: 1.5;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.backup-note {
|
||||
margin: 18px 0 0;
|
||||
color: var(--ink-faint);
|
||||
@@ -2815,11 +2896,13 @@ body.resizing .sidebar {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
border-right: 1px solid var(--line);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 260ms var(--ease);
|
||||
visibility: hidden;
|
||||
transition: transform 260ms var(--ease), visibility 260ms var(--ease);
|
||||
}
|
||||
|
||||
.app[data-compact="true"][data-sidebar="true"] .sidebar {
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.app[data-compact="true"] .dock {
|
||||
|
||||
Reference in new issue
Block a user