24 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
github-actions[bot] cfc93fc97d chore(release): v0.1.16 2026-08-10 15:00:26 +00:00
pj b954a3135d Straight through shortcut 2026-08-10 18:44:46 +05:30
pj 9e310a34bb feat(macos): trigger Apple Intelligence writing tools from the editor 2026-08-10 18:37:23 +05:30
pj fe39274ed7 feat(nav): peel overlays with esc, close the book on a second esc 2026-08-10 18:37:23 +05:30
pj 2e15dc2dc9 feat(editor): open the link popover with cmd+k 2026-08-10 18:36:52 +05:30
pj dad0ac86d0 feat(chapters): focus the title field when adding a chapter 2026-08-10 18:36:39 +05:30
pj efd45cf61e feat(library): show last edited time on book cards 2026-08-10 18:36:30 +05:30
pj 742e42d65b feat(editor): keep the caret clear of the floating toolbar 2026-08-10 18:36:18 +05:30
pj abcd133b19 chore: sync Cargo.lock with package version 2026-08-10 18:36:18 +05:30
github-actions[bot] 4fa14306d1 chore(release): v0.1.15 2026-07-15 04:02:28 +00:00
pj acf78dfe29 feat(menu): reorganize native menu bar with discoverable actions
Move Check for Updates into the macOS app menu and add Settings (Cmd+,),
Save (Cmd+S), Find (Cmd+F), chapter navigation, and a Help > Report an
Issue link. Route new menu actions through menu-action events and trim
duplicate editor keybindings.
2026-07-15 09:31:57 +05:30
pj 866a971729 feat(updater): show in-app progress during app update
Replace native update dialogs with an in-app modal that shows a live
download progress bar and installing state, so updates no longer feel
abrupt. Drives the plugin's downloadAndInstall progress events through a
zustand store.
2026-07-15 09:31:57 +05:30
github-actions[bot] 7ea33b5bfb chore(release): v0.1.14 2026-07-15 04:00:48 +00:00
github-actions[bot] 0abeb3c5d4 chore(release): v0.1.13 2026-07-14 10:37:34 +00:00
pj a31f99cb76 fix keyboard fixed 2026-07-14 16:07:05 +05:30
pj 20365dd02d fix(proofing): stop flagging bare URLs as misspelled on macOS
NSSpellChecker's implicit URL handling is unreliable for URLs alone on a
line (a bookmark list), flagging the whole URL as one misspelling. Request
the link checking type alongside spelling so the data detector claims URL
ranges, then keep only spelling results. Real misspellings are unaffected.
2026-07-14 16:07:05 +05:30
github-actions[bot] af389c7097 chore(release): v0.1.12 2026-07-13 15:00:54 +00:00
pj f11ddd290f fix(proofing): use macOS NSSpellChecker for spelling on macOS
The bundled Hunspell dictionary (~49k SCOWL roots) lacked common words like
"cybersecurity", "scalable", and "assistantship", and checked words inside
URLs. On macOS, spelling now uses the system NSSpellChecker (same engine as
TextEdit/Safari): far better vocabulary, skips URLs, and respects words the
user has taught macOS. The custom/tech dictionaries and Harper grammar are
unchanged; non-macOS builds keep the Hunspell fallback.
2026-07-13 20:27:58 +05:30
github-actions[bot] 4975e76358 chore(release): v0.1.11 2026-07-13 14:51:25 +00:00
github-actions[bot] efeb64d289 chore(release): v0.1.10 2026-06-27 13:49:24 +00:00
pj cd41a02737 changes 2026-06-27 09:48:42 -04:00
pj 5a2ada9e9e ci(release): pin macOS runner to macos-26 2026-06-27 09:48:42 -04:00
47 changed files with 2097 additions and 355 deletions

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
fail-fast: false
matrix:
include:
- os: macos-latest
- os: macos-26
args: "--target universal-apple-darwin --config src-tauri/tauri.release.conf.json"
rust-targets: "aarch64-apple-darwin,x86_64-apple-darwin"
- os: ubuntu-latest
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "margin-app",
"private": true,
"version": "0.1.9",
"version": "0.1.17",
"type": "module",
"scripts": {
"dev": "vite",
+26 -1
View File
@@ -4713,11 +4713,14 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "margin-app"
version = "0.1.7"
version = "0.1.15"
dependencies = [
"base64 0.22.1",
"fontdb",
"harper-core",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"rand 0.8.6",
"reqwest 0.12.28",
"serde",
@@ -5138,9 +5141,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@@ -5160,6 +5171,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-foundation",
]
@@ -5220,6 +5232,19 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
+21 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "margin-app"
version = "0.1.9"
version = "0.1.17"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -31,7 +31,6 @@ fontdb = "0.23"
typst = "0.14.2"
typst-pdf = "0.14.2"
typst-as-lib = "0.15.5"
spellbook = "0.4"
# Pinned exactly: the [patch] stubs below are tied to this version's burn/cubecl graph. A minor
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
@@ -41,6 +40,26 @@ sha2 = "0.10"
rand = "0.8"
url = "2"
# Spelling on macOS uses the system NSSpellChecker (same engine as TextEdit/Safari): far better
# vocabulary than the bundled Hunspell dictionary and it skips URLs. These objc2 crates are already
# in the graph via Tauri, so this adds nothing new to the build.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = [
"NSSpellChecker",
"NSApplication",
"NSEvent",
"NSMenu",
"NSMenuItem",
"NSResponder",
"NSWritingToolsCoordinator",
] }
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
# Other platforms fall back to the bundled Hunspell dictionary via spellbook.
[target.'cfg(not(target_os = "macos"))'.dependencies]
spellbook = "0.4"
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
# prereleases) and links-collides with `zip`'s lzma. Replace it with an empty stub so the
+1
View File
@@ -6,6 +6,7 @@
"permissions": [
"core:default",
"core:window:allow-destroy",
"core:window:allow-start-dragging",
"opener:default",
"dialog:default"
]
+163 -16
View File
@@ -15,6 +15,7 @@ use tauri::{Emitter, Manager};
const FOLDER_NAME: &str = "margin";
const DICTIONARY_NAME: &str = "custom-dictionary.txt";
const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.file";
const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file";
const AUTH_TIMEOUT_SECS: u64 = 120;
@@ -58,6 +59,7 @@ pub struct Session {
access_expiry: u64,
email: Option<String>,
folder_id: Option<String>,
scopes: Option<String>,
}
#[derive(Serialize, Deserialize, Default)]
@@ -68,6 +70,8 @@ struct BackupState {
folder_id: Option<String>,
last_backup: Option<u64>,
#[serde(default)]
scopes: Option<String>,
#[serde(default)]
files: HashMap<String, FileRecord>,
}
@@ -84,6 +88,7 @@ pub struct Status {
email: Option<String>,
last_backup: Option<u64>,
pending: bool,
needs_reauth: bool,
}
#[derive(Serialize)]
@@ -92,6 +97,15 @@ pub struct RestoreResult {
restored: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncOutcome {
uploaded: usize,
downloaded: usize,
#[serde(flatten)]
status: Status,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupOutcome {
@@ -164,6 +178,13 @@ fn urlencode(s: &str) -> String {
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
}
fn has_drive_scope(scope: &str) -> bool {
scope.split_whitespace().any(|s| s == DRIVE_SCOPE)
}
const REAUTH_MESSAGE: &str =
"margin needs the \"See, edit, create and delete only the specific Google Drive files you use with this app\" permission. Connect again and tick that box.";
fn drive_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('\'', "\\'")
}
@@ -260,6 +281,8 @@ struct TokenResponse {
refresh_token: Option<String>,
#[serde(default)]
expires_in: u64,
#[serde(default)]
scope: Option<String>,
}
#[derive(Deserialize)]
@@ -484,7 +507,29 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
}
let refresh = refresh.ok_or("Not connected to Google Drive.")?;
let creds = load_credentials()?;
let tokens = refresh_access_token(&creds, &refresh).await?;
let tokens = match refresh_access_token(&creds, &refresh).await {
Ok(tokens) => tokens,
Err(e) => {
if e.contains("invalid_grant") {
forget_credentials(app, state);
return Err(
"Google Drive access has expired. Connect again to keep backing up.".to_string()
);
}
return Err(e);
}
};
if let Some(granted) = &tokens.scope {
if !has_drive_scope(granted) {
let mut session = state.0.lock().unwrap();
session.scopes = Some(granted.clone());
drop(session);
let mut stored = load_state(app);
stored.scopes = Some(granted.clone());
let _ = save_state(app, &stored);
return Err(REAUTH_MESSAGE.to_string());
}
}
let access_token = tokens.access_token.clone();
{
let mut session = state.0.lock().unwrap();
@@ -493,10 +538,18 @@ async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Resu
if let Some(rotated) = &tokens.refresh_token {
session.refresh_token = Some(rotated.clone());
}
if let Some(granted) = &tokens.scope {
session.scopes = Some(granted.clone());
}
}
if let Some(rotated) = &tokens.refresh_token {
if tokens.refresh_token.is_some() || tokens.scope.is_some() {
let mut stored = load_state(app);
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,
},
})
}
+137 -19
View File
@@ -2,9 +2,12 @@ mod epub;
mod fonts;
mod gdrive;
mod library;
#[cfg(target_os = "macos")]
mod macspell;
mod pdf;
mod project;
mod proofing;
mod writingtools;
#[cfg(desktop)]
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
@@ -14,50 +17,122 @@ use tauri::{Emitter, Runtime};
#[cfg(desktop)]
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
let menu = Menu::default(handle)?;
let new_book = MenuItemBuilder::with_id("new-book", "New Book")
.accelerator("CmdOrCtrl+N")
.build(handle)?;
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…").build(handle)?;
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…").build(handle)?;
let save = MenuItemBuilder::with_id("save", "Save")
.accelerator("CmdOrCtrl+S")
.build(handle)?;
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…")
.accelerator("CmdOrCtrl+Shift+P")
.build(handle)?;
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…")
.accelerator("CmdOrCtrl+Shift+E")
.build(handle)?;
let check_updates =
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
let settings = MenuItemBuilder::with_id("settings", "Settings…")
.accelerator("CmdOrCtrl+,")
.build(handle)?;
let find = MenuItemBuilder::with_id("find", "Find…")
.accelerator("CmdOrCtrl+F")
.build(handle)?;
let report_issue =
MenuItemBuilder::with_id("report-issue", "Report an Issue…").build(handle)?;
let file_submenu = menu
let submenus: Vec<_> = menu
.items()?
.into_iter()
.find_map(|item| match item {
MenuItemKind::Submenu(submenu)
if submenu.text().map(|t| t == "File").unwrap_or(false) =>
{
Some(submenu)
}
.filter_map(|item| match item {
MenuItemKind::Submenu(submenu) => Some(submenu),
_ => None,
});
})
.collect();
match file_submenu {
let find_submenu = |name: &str| {
submenus
.iter()
.find(|submenu| submenu.text().map(|t| t == name).unwrap_or(false))
.cloned()
};
match find_submenu("File") {
Some(submenu) => {
submenu.prepend_items(&[
&new_book,
&PredefinedMenuItem::separator(handle)?,
&save,
&PredefinedMenuItem::separator(handle)?,
&export_pdf,
&export_epub,
&PredefinedMenuItem::separator(handle)?,
&check_updates,
&PredefinedMenuItem::separator(handle)?,
])?;
}
None => {
let submenu = SubmenuBuilder::new(handle, "File")
.item(&new_book)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&save)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&export_pdf)
.item(&export_epub)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&check_updates)
.build()?;
menu.insert(&submenu, 1)?;
}
}
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&PredefinedMenuItem::separator(handle)?, &find])?;
}
if let Some(help) = find_submenu("Help") {
help.append_items(&[&report_issue])?;
}
#[cfg(target_os = "macos")]
{
if let Some(app_submenu) = submenus.first() {
app_submenu.insert(&check_updates, 1)?;
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+\\")
.build(handle)?;
let next_chapter = MenuItemBuilder::with_id("next-chapter", "Next Chapter")
.accelerator("CmdOrCtrl+Alt+Down")
.build(handle)?;
let prev_chapter = MenuItemBuilder::with_id("prev-chapter", "Previous Chapter")
.accelerator("CmdOrCtrl+Alt+Up")
.build(handle)?;
view.prepend_items(&[
&toggle_chapters,
&PredefinedMenuItem::separator(handle)?,
&next_chapter,
&prev_chapter,
&PredefinedMenuItem::separator(handle)?,
])?;
}
}
#[cfg(not(target_os = "macos"))]
{
if let Some(file) = find_submenu("File") {
file.append_items(&[&PredefinedMenuItem::separator(handle)?, &check_updates])?;
}
if let Some(edit) = find_submenu("Edit") {
edit.append_items(&[&settings])?;
}
}
Ok(menu)
}
@@ -83,6 +158,7 @@ pub fn run() {
.manage(gdrive::GDriveState::default())
.setup(|app| {
gdrive::init_session(app.handle());
writingtools::label_writing_tool_shortcuts();
Ok(())
});
@@ -93,14 +169,28 @@ pub fn run() {
.on_menu_event(|app, event| {
if matches!(
event.id().0.as_str(),
"new-book" | "export-pdf" | "export-epub" | "check-updates"
"new-book"
| "save"
| "export-pdf"
| "export-epub"
| "check-updates"
| "settings"
| "find"
| "toggle-chapters"
| "next-chapter"
| "prev-chapter"
| "report-issue"
) {
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,
@@ -115,13 +205,41 @@ pub fn run() {
library::delete_book,
proofing::proof_text,
proofing::remember_word,
writingtools::run_writing_tool,
gdrive::gdrive_connect,
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();
}
}
+34 -1
View File
@@ -1,13 +1,16 @@
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use tauri::Manager;
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BookSummary {
id: String,
title: String,
author: String,
corrupt: bool,
updated_at: u64,
}
fn corrupt_summary(stem: &str) -> BookSummary {
@@ -16,9 +19,33 @@ fn corrupt_summary(stem: &str) -> BookSummary {
title: "Unreadable book".to_string(),
author: String::new(),
corrupt: true,
updated_at: 0,
}
}
fn file_modified_ms(path: &Path) -> u64 {
fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn latest_chapter_ms(value: &serde_json::Value) -> u64 {
value
.get("chapters")
.and_then(|c| c.as_array())
.map(|chapters| {
chapters
.iter()
.filter_map(|c| c.get("updatedAt").and_then(|v| v.as_u64()))
.max()
.unwrap_or(0)
})
.unwrap_or(0)
}
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -81,13 +108,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let updated_at = match latest_chapter_ms(&value) {
0 => file_modified_ms(&path),
ms => ms,
};
books.push(BookSummary {
id: id.to_string(),
title,
author,
corrupt: false,
updated_at,
});
}
books.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
Ok(books)
}
+73
View File
@@ -0,0 +1,73 @@
use std::collections::HashSet;
use objc2::rc::autoreleasepool;
use objc2_app_kit::NSSpellChecker;
use objc2_foundation::{NSRange, NSString, NSTextCheckingType};
pub struct MacIssue {
pub start: usize,
pub end: usize,
pub word: String,
pub suggestions: Vec<String>,
}
fn utf16_to_codepoint(text: &str, utf16_len: usize) -> Vec<usize> {
let mut map = Vec::with_capacity(utf16_len + 1);
let mut cp = 0;
for ch in text.chars() {
for _ in 0..ch.len_utf16() {
map.push(cp);
}
cp += 1;
}
map.push(cp);
map
}
pub fn check(text: &str, custom: &HashSet<String>) -> Vec<MacIssue> {
autoreleasepool(|_| {
let checker = NSSpellChecker::sharedSpellChecker();
let ns = NSString::from_str(text);
let len = ns.length();
let results = unsafe {
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
&ns,
NSRange { location: 0, length: len },
(NSTextCheckingType::Spelling | NSTextCheckingType::Link).bits(),
None,
0,
None,
std::ptr::null_mut(),
)
};
let map = utf16_to_codepoint(text, len);
let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new();
for result in results.iter() {
if result.resultType() != NSTextCheckingType::Spelling {
continue;
}
let range = result.range();
let start = map[range.location.min(len)];
let end = map[(range.location + range.length).min(len)];
let word: String = chars[start..end].iter().collect();
if custom.contains(&word.to_lowercase()) {
continue;
}
let mut suggestions = Vec::new();
if let Some(guesses) =
checker.guessesForWordRange_inString_language_inSpellDocumentWithTag(range, &ns, None, 0)
{
for guess in guesses.iter() {
suggestions.push(guess.to_string());
if suggestions.len() >= 5 {
break;
}
}
}
issues.push(MacIssue { start, end, word, suggestions });
}
issues
})
}
+28 -3
View File
@@ -27,6 +27,7 @@ struct Harper {
}
pub struct Engine {
#[cfg(not(target_os = "macos"))]
speller: Option<spellbook::Dictionary>,
harper: Option<Harper>,
custom: HashSet<String>,
@@ -87,6 +88,7 @@ fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
set
}
#[cfg(not(target_os = "macos"))]
fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> {
let aff = dict_file(app, "index.aff")?;
let dic = dict_file(app, "index.dic")?;
@@ -100,18 +102,22 @@ fn build_harper() -> Harper {
Harper { linter, dict }
}
#[cfg(not(target_os = "macos"))]
fn is_word_char(c: char) -> bool {
c.is_alphabetic()
}
#[cfg(not(target_os = "macos"))]
fn has_irregular_case(word: &str) -> bool {
word.chars().skip(1).any(|c| c.is_uppercase())
}
#[cfg(not(target_os = "macos"))]
fn is_apostrophe(c: char) -> bool {
c == '\'' || c == '\u{2019}'
}
#[cfg(not(target_os = "macos"))]
fn collect_spelling(
speller: &spellbook::Dictionary,
custom: &HashSet<String>,
@@ -143,6 +149,10 @@ fn collect_spelling(
}
let mut suggestions = Vec::new();
speller.suggest(&word, &mut suggestions);
let lower = word.to_lowercase();
if suggestions.iter().any(|s| s.to_lowercase() == lower) {
continue;
}
suggestions.truncate(5);
issues.push(Issue {
start,
@@ -197,6 +207,7 @@ pub async fn proof_text(
let mut custom = load_custom(&app);
load_tech(&app, &mut custom);
*guard = Some(Engine {
#[cfg(not(target_os = "macos"))]
speller: None,
harper: None,
custom,
@@ -207,10 +218,24 @@ pub async fn proof_text(
let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new();
if spelling {
if engine.speller.is_none() {
engine.speller = Some(build_speller(&app)?);
#[cfg(target_os = "macos")]
for mi in crate::macspell::check(&text, &engine.custom) {
issues.push(Issue {
start: mi.start,
end: mi.end,
kind: "spelling".into(),
category: "Spelling".into(),
message: format!("“{}” may be misspelled", mi.word),
suggestions: mi.suggestions,
});
}
#[cfg(not(target_os = "macos"))]
{
if engine.speller.is_none() {
engine.speller = Some(build_speller(&app)?);
}
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
}
collect_spelling(engine.speller.as_ref().unwrap(), &engine.custom, &chars, &mut issues);
}
if grammar {
if engine.harper.is_none() {
+81
View File
@@ -0,0 +1,81 @@
#[cfg(target_os = "macos")]
mod mac {
use objc2::rc::Retained;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSMenu, NSMenuItem};
use objc2_foundation::{NSArray, NSString};
const SHORTCUTS: [(&str, &str); 2] = [("Proofread", "f"), ("Rewrite", "r")];
fn submenu_named(items: &NSArray<NSMenuItem>, title: &str) -> Option<Retained<NSMenu>> {
for i in 0..items.count() {
let item = items.objectAtIndex(i);
if item.title().to_string() == title {
return item.submenu();
}
}
None
}
fn edit_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
let main = NSApplication::sharedApplication(mtm).mainMenu()?;
for i in 0..main.numberOfItems() {
let Some(item) = main.itemAtIndex(i) else { continue };
let Some(submenu) = item.submenu() else { continue };
if submenu.title().to_string() == "Edit" || item.title().to_string() == "Edit" {
return Some(submenu);
}
}
None
}
fn writing_tools_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
submenu_named(&edit_menu(mtm)?.itemArray(), "Writing Tools")
}
pub fn label_shortcuts() {
let Some(mtm) = MainThreadMarker::new() else { return };
let Some(menu) = writing_tools_menu(mtm) else { return };
let items = menu.itemArray();
for i in 0..items.count() {
let item = items.objectAtIndex(i);
let title = item.title().to_string();
let Some((_, key)) = SHORTCUTS.iter().find(|(name, _)| *name == title) else { continue };
item.setKeyEquivalent(&NSString::from_str(key));
item.setKeyEquivalentModifierMask(
NSEventModifierFlags::Shift | NSEventModifierFlags::Option,
);
}
}
pub fn perform(tool: &str) {
let Some(mtm) = MainThreadMarker::new() else { return };
let Some(menu) = writing_tools_menu(mtm) else { return };
let items = menu.itemArray();
for i in 0..items.count() {
if items.objectAtIndex(i).title().to_string() == tool {
menu.performActionForItemAtIndex(i as isize);
return;
}
}
}
}
pub fn label_writing_tool_shortcuts() {
#[cfg(target_os = "macos")]
mac::label_shortcuts();
}
#[tauri::command]
pub fn run_writing_tool(app: tauri::AppHandle, tool: String) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
app.run_on_main_thread(move || mac::perform(&tool))
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "macos"))]
{
let _ = (app, tool);
Ok(())
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Margin",
"version": "0.1.9",
"version": "0.1.17",
"identifier": "studio.margin.app",
"build": {
"beforeDevCommand": "pnpm dev",
+26 -8
View File
@@ -1,15 +1,17 @@
import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Library } from "./components/Library";
import { EditorView } from "./components/EditorView";
import { BackupSettings } from "./components/BackupSettings";
import { ExportPreview } from "./components/ExportPreview";
import { UpdateDialog } from "./components/UpdateDialog";
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";
@@ -28,6 +30,8 @@ function App() {
}
else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false);
else if (event.payload === "report-issue")
openUrl("https://github.com/priyanshujain/margin/issues").catch(() => {});
});
const unlistenWarn = listen<string>("pdf-warnings", (event) => {
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
@@ -47,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 () => {
@@ -72,21 +71,40 @@ 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} />}
{isDesktop && <BackupSettings />}
{isDesktop && <ExportPreview />}
{isDesktop && <UpdateDialog />}
</>
);
}
+11 -1
View File
@@ -6,6 +6,7 @@ export interface BackupStatus {
email: string | null;
lastBackup: number | null;
pending: boolean;
needsReauth: boolean;
}
export interface RestoreResult {
@@ -16,11 +17,16 @@ export interface BackupOutcome extends BackupStatus {
uploaded: number;
}
export interface SyncOutcome extends BackupStatus {
uploaded: number;
downloaded: number;
}
export interface RemoteBackup {
name: string;
}
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false };
const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false, needsReauth: false };
export async function gdriveStatus(): Promise<BackupStatus> {
if (!isDesktop) return OFFLINE;
@@ -39,6 +45,10 @@ export async function gdriveBackup(): Promise<BackupOutcome> {
return invoke<BackupOutcome>("gdrive_backup");
}
export async function gdriveSync(): Promise<SyncOutcome> {
return invoke<SyncOutcome>("gdrive_sync");
}
export async function gdriveRestore(): Promise<RestoreResult> {
return invoke<RestoreResult>("gdrive_restore");
}
+22
View File
@@ -1,5 +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 {
@@ -16,6 +18,7 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const toggle = () => {
if (!open && btnRef.current) {
@@ -38,6 +41,23 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
};
}, [open]);
useFocusTrap(popRef, open);
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
});
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement);
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
items[nextIdx]?.focus();
}
};
const pick = (group: "front" | "back", label: string) => {
setOpen(false);
onAdd(group, label);
@@ -56,9 +76,11 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
</button>
{open && (
<div
ref={popRef}
className="add-page-pop"
style={{ top: coords.top, left: coords.left }}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={onMenuKeyDown}
>
<div className="add-page-group">
<div className="add-page-label">Body</div>
+56 -23
View File
@@ -1,7 +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() {
@@ -11,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);
@@ -22,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;
}
@@ -36,14 +43,16 @@ export function BackupSettings() {
return () => {
active = false;
};
}, [open, connected, lastBackup]);
}, [open, connected, needsReauth, lastBackup]);
useEscapeLayer(open, close);
if (!open) return null;
return (
<>
<div className="overlay" onClick={close}>
<div className="panel" onClick={(e) => e.stopPropagation()}>
<div ref={panelRef} className="panel" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>Backup &amp; Sync</h2>
<button className="icon-btn" onClick={close} title="Close">
@@ -97,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>
+15 -3
View File
@@ -1,4 +1,6 @@
import type { ReactNode } from "react";
import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
interface ConfirmDialogProps {
@@ -10,9 +12,19 @@ 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">
@@ -26,7 +38,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
<button className="btn-ghost" onClick={onClose}>
Cancel
</button>
<button className="btn-danger" onClick={onConfirm}>
<button ref={confirmRef} className="btn-danger" onClick={onConfirm}>
{confirmLabel}
</button>
</div>
+219 -128
View File
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import type { Editor as TiptapEditor } from "@tiptap/react";
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";
@@ -21,11 +23,18 @@ import { useWidth } from "../store/useWidth";
import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library";
import { isDesktop } from "../ipc";
import { useEscapeLayer } from "../escape";
import { focusTrapped } from "../focus";
import { isDesktop, runWritingTool } from "../ipc";
import { useCompact } from "../useMedia";
import { issueSignature, rememberWord, runProof } from "../proofing";
import { runExport } from "../export/run";
const WRITING_TOOLS: Record<string, "Proofread" | "Rewrite"> = {
KeyF: "Proofread",
KeyR: "Rewrite",
};
export function EditorView() {
const book = useBook((s) => s.book);
const activeChapterId = useBook((s) => s.activeChapterId);
@@ -36,6 +45,8 @@ export function EditorView() {
const setChapterContent = useBook((s) => s.setChapterContent);
const setChapterTitle = useBook((s) => s.setChapterTitle);
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
const pendingTitleFocus = useBook((s) => s.pendingTitleFocus);
const clearTitleFocus = useBook((s) => s.clearTitleFocus);
const markSaved = useBook((s) => s.markSaved);
const theme = useTheme((s) => s.theme);
const toggleTheme = useTheme((s) => s.toggle);
@@ -44,8 +55,11 @@ export function EditorView() {
const isCompact = useCompact();
const [editor, setEditor] = useState<TiptapEditor | null>(null);
const editorRef = useRef<TiptapEditor | null>(null);
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);
@@ -53,6 +67,10 @@ export function EditorView() {
const [findOpen, setFindOpen] = useState(false);
const [findExpanded, setFindExpanded] = useState(false);
const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null);
const [escArmed, setEscArmed] = useState(false);
const escArmedRef = useRef(false);
escArmedRef.current = escArmed;
const escTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const spelling = useProofing((s) => s.spelling);
const grammar = useProofing((s) => s.grammar);
@@ -78,11 +96,43 @@ export function EditorView() {
setDock(false);
};
useEscapeLayer(true, () => {
clearTimeout(escTimer.current);
if (escArmedRef.current) {
setEscArmed(false);
useBook.getState().closeBook();
return;
}
(document.activeElement as HTMLElement | null)?.blur();
setEscArmed(true);
escTimer.current = setTimeout(() => setEscArmed(false), 1500);
});
useEscapeLayer(isCompact && (sidebarOpen || dock), closeDrawers);
useEscapeLayer(widthOpen, () => setWidthOpen(false));
useEscapeLayer(exportOpen, () => setExportOpen(false));
useEscapeLayer(moreOpen, () => setMoreOpen(false));
useEscapeLayer(!!proofPopover, () => setProofPopover(null));
useEffect(() => () => clearTimeout(escTimer.current), []);
const openFind = useCallback((expanded: boolean) => {
setFindExpanded(expanded);
setFindOpen(true);
}, []);
const focusEditorSoon = useCallback(() => {
if (useBook.getState().pendingTitleFocus || focusTrapped()) return;
requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false }));
}, []);
const switchChapter = useCallback(
(dir: 1 | -1) => {
useBook.getState().goToAdjacentChapter(dir);
focusEditorSoon();
},
[focusEditorSoon]
);
const reproof = useCallback(() => {
if (!editor) return;
const state = useProofing.getState();
@@ -114,17 +164,50 @@ export function EditorView() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
if (focusTrapped()) return;
if (e.metaKey && e.altKey && e.key.toLowerCase() === "f") {
e.preventDefault();
saveNow();
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
openFind(true);
} else if (e.metaKey && e.altKey && e.key === "ArrowRight") {
e.preventDefault();
openFind(e.altKey);
e.stopPropagation();
switchChapter(1);
} else if (e.metaKey && e.altKey && e.key === "ArrowLeft") {
e.preventDefault();
e.stopPropagation();
switchChapter(-1);
} else if (e.ctrlKey && !e.metaKey && e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
switchChapter(e.shiftKey ? -1 : 1);
} else if (e.shiftKey && e.altKey && !e.metaKey && !e.ctrlKey && WRITING_TOOLS[e.code]) {
e.preventDefault();
e.stopPropagation();
runWritingTool(WRITING_TOOLS[e.code]).catch(() => {});
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [saveNow, openFind]);
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [openFind, switchChapter]);
const menuCommands = useRef({ saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter });
menuCommands.current = { saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter };
useEffect(() => {
if (!isDesktop) return;
const unlisten = listen<string>("menu-action", (e) => {
const cmd = menuCommands.current;
if (e.payload === "save") cmd.saveNow();
else if (e.payload === "find") cmd.openFind(false);
else if (e.payload === "settings") cmd.setSettingsOpen(true);
else if (e.payload === "toggle-chapters") cmd.toggleSidebar();
else if (e.payload === "next-chapter") cmd.switchChapter(1);
else if (e.payload === "prev-chapter") cmd.switchChapter(-1);
});
return () => {
unlisten.then((stop) => stop());
};
}, []);
useEffect(() => {
if (!notice) return;
@@ -134,7 +217,7 @@ export function EditorView() {
useEffect(() => {
setSidebarOpen(!isCompact);
setDock(!isCompact);
if (isCompact) setDock(false);
}, [isCompact]);
useEffect(() => {
@@ -177,8 +260,11 @@ export function EditorView() {
};
if (!book) return null;
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
const chapter = book.chapters[idx] ?? book.chapters[0];
const activeIdx = book.chapters.findIndex((c) => c.id === activeChapterId);
const activeChapter = book.chapters[activeIdx];
if (activeChapter) lastChapterRef.current = activeChapter.id;
const editorIdx = activeChapter ? activeIdx : book.chapters.findIndex((c) => c.id === lastChapterRef.current);
const chapter = book.chapters[editorIdx] ?? book.chapters[0];
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
const kind = chapter ? chapterKind(chapter) : "body";
const eyebrow =
@@ -222,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 && (
@@ -248,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 && (
@@ -279,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>
@@ -342,50 +413,51 @@ export function EditorView() {
/>
<div className="body">
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} />
<Sidebar
onNavigate={() => {
if (isCompact) setSidebarOpen(false);
focusEditorSoon();
}}
/>
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
<main className="editor-pane">
{coverActive ? (
<CoverView />
) : (
<>
<article className="sheet">
<header className="chapter-opener">
<div className="chapter-num">{eyebrow}</div>
<ChapterTitleInput
value={chapter.title}
placeholder={
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
}
disabled={!!chapter.noTitle}
onChange={(value) => setChapterTitle(chapter.id, value)}
{coverActive && <CoverView />}
<article className="sheet" hidden={coverActive}>
<header className="chapter-opener">
<div className="chapter-num">{eyebrow}</div>
<ChapterTitleInput
value={chapter.title}
placeholder={
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
}
disabled={!!chapter.noTitle}
focusRequest={pendingTitleFocus === chapter.id}
onFocused={clearTitleFocus}
onChange={(value) => setChapterTitle(chapter.id, value)}
/>
{(chapter.noTitle || !chapter.title.trim()) && (
<label className="no-title-check">
<input
type="checkbox"
checked={!!chapter.noTitle}
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
/>
{(chapter.noTitle || !chapter.title.trim()) && (
<label className="no-title-check">
<input
type="checkbox"
checked={!!chapter.noTitle}
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
/>
No title
</label>
)}
</header>
<Editor
key={chapter.id}
bookId={book.id}
chapterId={chapter.id}
content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor}
onContentError={() =>
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
}
/>
</article>
<FloatingToolbar editor={editor} />
</>
)}
No title
</label>
)}
</header>
<Editor
bookId={book.id}
chapterId={chapter.id}
content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor}
onContentError={() =>
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
}
/>
</article>
{!coverActive && <FloatingToolbar editor={editor} />}
</main>
{dock && !isCompact && <ResizeHandle pane="dock" />}
{dock && <Dock />}
@@ -416,6 +488,12 @@ export function EditorView() {
/>
)}
{escArmed && (
<div className="esc-hint">
Press <kbd>Esc</kbd> again for All books
</div>
)}
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && (
<div className="export-overlay">
@@ -436,11 +514,15 @@ function ChapterTitleInput({
value,
placeholder,
disabled,
focusRequest,
onFocused,
onChange,
}: {
value: string;
placeholder: string;
disabled: boolean;
focusRequest: boolean;
onFocused: () => void;
onChange: (value: string) => void;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
@@ -452,6 +534,15 @@ function ChapterTitleInput({
el.style.height = `${el.scrollHeight}px`;
}, [value, disabled]);
useEffect(() => {
if (!focusRequest || disabled) return;
const el = ref.current;
if (!el) return;
el.focus();
el.select();
onFocused();
}, [focusRequest, disabled, onFocused]);
return (
<textarea
ref={ref}
+13 -16
View File
@@ -7,6 +7,8 @@ import { useExportPreview } from "../store/useExportPreview";
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;
@@ -81,13 +83,8 @@ function ExportPreviewModal() {
};
}, [book]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [close]);
useEscapeLayer(true, close);
useFocusTrap(panelRef);
useEffect(() => {
const el = panelRef.current;
@@ -173,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>
@@ -212,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;
@@ -234,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
+11 -9
View File
@@ -3,6 +3,7 @@ import type { Editor as TiptapEditor } from "@tiptap/react";
import { buildRegex, getSearchState } from "../editor/search";
import { countMatches, replaceInContent } from "../search-book";
import { useBook } from "../store/useBook";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon";
interface FindBarProps {
@@ -58,7 +59,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
return () => {
editor.off("transaction", update);
};
}, [editor]);
}, [editor, activeChapterId]);
useEffect(() => {
if (!editor) return;
@@ -67,7 +68,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
} else {
editor.commands.clearSearch();
}
}, [editor, open, query, caseSensitive, wholeWord]);
}, [editor, open, query, caseSensitive, wholeWord, activeChapterId]);
useEffect(() => {
if (open) {
@@ -95,6 +96,13 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
};
}, [editor, pendingNav, activeChapterId]);
const close = () => {
editor?.commands.focus();
onClose();
};
useEscapeLayer(open, close);
if (!open || !editor) return null;
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0);
@@ -163,9 +171,6 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
if (e.key === "Enter") {
e.preventDefault();
e.shiftKey ? prev() : next();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
@@ -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>
@@ -256,9 +261,6 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
if (e.key === "Enter") {
e.preventDefault();
replaceOne();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
/>
+35 -2
View File
@@ -1,8 +1,19 @@
import { useEffect, useState } from "react";
import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library";
import {
type BookSummary,
createAndOpenBook,
deleteBook,
exampleBook,
lastBookId,
listBooks,
loadBook,
rememberLastBook,
saveBook,
} from "../library";
import type { Book } from "../model/book";
import { importEpub } from "../import/epub";
import { clearPositions } from "../editor/positions";
import { clearChapterStates } from "../editor/session";
import { isDesktop } from "../ipc";
import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon";
@@ -10,10 +21,12 @@ import { RowMenu } from "./RowMenu";
import { BackupButton } from "./BackupButton";
import { useBackup } from "../store/useBackup";
import { useBook } from "../store/useBook";
import { relativeTime } from "../time";
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
const [books, setBooks] = useState<BookSummary[]>([]);
const [loaded, setLoaded] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [busy, setBusy] = useState(false);
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
const notice = useBook((s) => s.notice);
@@ -30,6 +43,10 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
useEffect(() => {
refresh();
}, []);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (restoreNonce) refresh();
}, [restoreNonce]);
@@ -51,10 +68,17 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
onOpen(copy);
};
const openById = (id: string) =>
loadBook(id)
.then(onOpen)
.catch((e) => setNotice(`Could not open book: ${e}`));
const removeBook = async () => {
if (!pendingDelete) return;
await deleteBook(pendingDelete.id);
clearPositions(pendingDelete.id);
clearChapterStates(pendingDelete.id);
if (lastBookId() === pendingDelete.id) rememberLastBook(null);
setPendingDelete(null);
refresh();
};
@@ -108,10 +132,19 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<div
key={b.id}
className="card card-book"
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))}
role="button"
tabIndex={0}
onClick={() => openById(b.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openById(b.id);
}
}}
>
<span className="card-title">{b.title || "Untitled"}</span>
{b.author && <span className="card-author">{b.author}</span>}
{b.updatedAt > 0 && <span className="card-meta">Edited {relativeTime(b.updatedAt, now)}</span>}
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div>
),
+42
View File
@@ -0,0 +1,42 @@
import { useRef, type ReactNode } from "react";
import { useFocusTrap } from "../focus";
interface MenuProps {
open: boolean;
onClose: () => void;
children: ReactNode;
}
export function Menu({ open, onClose, children }: MenuProps) {
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref, open);
if (!open) return null;
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
e.preventDefault();
const items = Array.from(ref.current?.querySelectorAll<HTMLElement>("button") ?? []);
if (!items.length) return;
const at = items.indexOf(document.activeElement as HTMLElement);
const next =
e.key === "Home"
? 0
: e.key === "End"
? items.length - 1
: e.key === "ArrowDown"
? (at + 1) % items.length
: (at - 1 + items.length) % items.length;
items[next]?.focus();
};
return (
<>
<div className="menu-backdrop" onClick={onClose} />
<div ref={ref} className="menu" onKeyDown={onKeyDown}>
{children}
</div>
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import type { Chapter } from "../model/book";
import { type BookSummary, listBooks, moveChapterToBook, saveBook } from "../library";
import { useBook } from "../store/useBook";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
interface MoveChapterDialogProps {
chapter: Chapter;
label: string;
onClose: () => void;
}
export function MoveChapterDialog({ chapter, label, onClose }: MoveChapterDialogProps) {
const bookId = useBook((s) => s.book?.id ?? "");
const deleteChapter = useBook((s) => s.deleteChapter);
const setNotice = useBook((s) => s.setNotice);
const [books, setBooks] = useState<BookSummary[] | null>(null);
const [moving, setMoving] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useEscapeLayer(!moving, onClose);
useFocusTrap(panelRef, books !== null);
useEffect(() => {
listBooks()
.then((list) => setBooks(list.filter((b) => b.id !== bookId && !b.corrupt)))
.catch(() => setBooks([]));
}, [bookId]);
const move = async (target: BookSummary) => {
setMoving(true);
try {
const source = useBook.getState().book;
if (source) await saveBook(source);
await moveChapterToBook(chapter, target.id);
deleteChapter(chapter.id);
const trimmed = useBook.getState().book;
if (trimmed) await saveBook(trimmed);
setNotice(`Moved to ${target.title || "Untitled"}`);
onClose();
} catch (e) {
setNotice(`Could not move: ${e}`);
setMoving(false);
}
};
return (
<div className="overlay" onClick={moving ? undefined : onClose}>
<div ref={panelRef} className="panel panel-move" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>Move to book</h2>
<button className="icon-btn" onClick={onClose} title="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
</div>
<div className="panel-body">
<p className="confirm-text">
Move <strong>{label}</strong> out of this book and into:
</p>
{books === null ? (
<p className="move-empty">Loading books…</p>
) : books.length === 0 ? (
<p className="move-empty">There is no other book to move this into yet.</p>
) : (
<ul className="move-list">
{books.map((b) => (
<li key={b.id}>
<button className="move-target" disabled={moving} onClick={() => move(b)}>
<span className="move-title">{b.title || "Untitled"}</span>
{b.author && <span className="move-author">{b.author}</span>}
</button>
</li>
))}
</ul>
)}
</div>
<div className="panel-foot">
<button className="btn-ghost" onClick={onClose} disabled={moving}>
{moving ? "Moving…" : "Cancel"}
</button>
</div>
</div>
</div>
);
}
+3
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useFocusTrap } from "../focus";
import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing";
function humanize(category: string): string {
@@ -20,6 +21,8 @@ const POP_WIDTH = 264;
export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) {
const popRef = useRef<HTMLDivElement>(null);
useFocusTrap(popRef);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return;
+17 -1
View File
@@ -1,6 +1,8 @@
import type { PointerEvent } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { applyPaneWidth, currentPaneWidth, resetPaneWidth, type Pane } from "../panes";
const STEP = 16;
export function ResizeHandle({ pane }: { pane: Pane }) {
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault();
@@ -23,12 +25,26 @@ export function ResizeHandle({ pane }: { pane: Pane }) {
handle.addEventListener("pointerup", onUp);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const towards = pane === "sidebar" ? 1 : -1;
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
applyPaneWidth(pane, currentPaneWidth(pane) + (e.key === "ArrowRight" ? STEP : -STEP) * towards);
} else if (e.key === "Home" || e.key === "Enter") {
e.preventDefault();
resetPaneWidth(pane);
}
};
return (
<div
className="pane-resizer"
role="separator"
aria-orientation="vertical"
aria-label={pane === "sidebar" ? "Resize chapters panel" : "Resize preview panel"}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
onDoubleClick={() => resetPaneWidth(pane)}
title="Drag to resize · double-click to reset"
/>
+36 -2
View File
@@ -1,10 +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;
@@ -12,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(() => {
@@ -33,6 +37,23 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
setOpen((v) => !v);
};
useFocusTrap(popRef, open);
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
});
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".row-menu-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement);
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
items[nextIdx]?.focus();
}
};
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
@@ -68,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);
@@ -80,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}
@@ -88,7 +116,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
</button>
{open &&
createPortal(
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }} onKeyDown={onMenuKeyDown}>
{onToggleTitle && (
<button className="row-menu-item" onClick={toggleTitle}>
{titleHidden ? (
@@ -115,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
+15 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useBook } from "../store/useBook";
import { listSystemFonts } from "../ipc";
@@ -13,6 +13,8 @@ import {
fontStack,
pairingFor,
} from "../model/fonts";
import { useEscapeLayer } from "../escape";
import { useFocusTrap } from "../focus";
import { Icon } from "./Icon";
const LANGUAGES = [
@@ -85,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;
@@ -94,6 +103,9 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
});
}, []);
useEscapeLayer(true, onClose);
useFocusTrap(panelRef);
if (!book) return null;
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
@@ -112,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">
@@ -121,7 +133,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
</div>
<div className="panel-body">
<Field label="Title">
<input 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 })} />
+55 -22
View File
@@ -5,6 +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;
@@ -19,26 +22,6 @@ interface DropTarget {
index: number;
}
function formatEdited(ts: number, now: number): string {
const diff = Math.max(0, now - ts);
const min = 60_000;
const hour = 60 * min;
const day = 24 * hour;
if (diff < min) return "just now";
if (diff < hour) {
const m = Math.floor(diff / min);
return `${m} minute${m === 1 ? "" : "s"} ago`;
}
if (diff < day) {
const h = Math.floor(diff / hour);
return `${h} hour${h === 1 ? "" : "s"} ago`;
}
if (diff < 2 * day) return "yesterday";
const d = Math.floor(diff / day);
if (d < 7) return `${d} days ago`;
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const chapters = useBook((s) => s.book?.chapters ?? []);
const activeChapterId = useBook((s) => s.activeChapterId);
@@ -56,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());
@@ -155,6 +139,34 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
onNavigate?.();
};
const focusRow = (id: string) =>
requestAnimationFrame(() => {
document.querySelector<HTMLElement>(`.chapter[data-id="${CSS.escape(id)}"]`)?.focus();
});
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);
onNavigate?.();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Home" || e.key === "End") {
e.preventDefault();
const target =
e.key === "Home"
? chapters[0]
: e.key === "End"
? chapters[chapters.length - 1]
: chapters[index + (e.key === "ArrowDown" ? 1 : -1)];
if (target) {
setActiveChapter(target.id);
focusRow(target.id);
}
}
};
const tabStopId = chapters.some((c) => c.id === activeChapterId) ? activeChapterId : chapters[0]?.id;
return (
<aside className="sidebar">
<button className="brand" onClick={closeBook} title="All books">
@@ -193,6 +205,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
key={row.chapter.id}
className="chapter"
data-idx={row.index}
data-id={row.chapter.id}
data-kind={group.kind}
data-part={isPart}
data-indent={row.indent}
@@ -200,7 +213,11 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
data-dragging={dragIndex === row.index}
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === row.index}
data-drop-after={dropTarget?.kind === group.kind && i === group.rows.length - 1 && dropTarget.index === row.index + 1}
role="button"
tabIndex={row.chapter.id === tabStopId ? 0 : -1}
aria-current={row.chapter.id === activeChapterId}
onClick={() => onRowClick(row.chapter.id)}
onKeyDown={(e) => onRowKeyDown(e, row.index, row.chapter.id)}
onPointerDown={(e) => onRowPointerDown(e, row.index)}
>
<span className="grip" title="Drag to reorder">
@@ -222,11 +239,12 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
</span>
)}
{row.chapter.id === activeChapterId && (
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
<span className="meta">Edited {relativeTime(row.chapter.updatedAt, now)}</span>
)}
</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))
}
@@ -235,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>
@@ -247,7 +266,13 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
)}
</ul>
{group.kind === "body" && (
<button className="add-chapter" onClick={addChapter}>
<button
className="add-chapter"
onClick={() => {
addChapter();
onNavigate?.();
}}
>
<Icon d="M12 5v14M5 12h14" />
New chapter
</button>
@@ -257,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"
+111
View File
@@ -0,0 +1,111 @@
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> = {
checking: "Check for Updates",
available: "Update Available",
downloading: "Updating margin",
installing: "Updating margin",
uptodate: "Check for Updates",
error: "Update Failed",
};
export function UpdateDialog() {
const phase = useUpdater((s) => s.phase);
const version = useUpdater((s) => s.version);
const notes = useUpdater((s) => s.notes);
const downloaded = useUpdater((s) => s.downloaded);
const total = useUpdater((s) => s.total);
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;
const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : 0;
const close = busy ? undefined : dismissUpdate;
return (
<div className="overlay" onClick={close}>
<div ref={panelRef} className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
<div className="panel-head">
<h2>{TITLES[phase]}</h2>
{!busy && (
<button className="icon-btn" onClick={dismissUpdate} title="Close">
<Icon d="M6 6l12 12M18 6L6 18" />
</button>
)}
</div>
<div className="panel-body">
{phase === "checking" && (
<div className="update-status-row">
<span className="update-spinner" />
<span>Checking for updates…</span>
</div>
)}
{phase === "uptodate" && <p className="confirm-text">margin is up to date.</p>}
{phase === "available" && (
<>
<p className="confirm-text">
<strong>margin {version}</strong> is available.
</p>
{notes && <div className="update-notes">{notes}</div>}
</>
)}
{phase === "downloading" && (
<>
<div className={`update-progress${total > 0 ? "" : " indeterminate"}`}>
<div className="update-progress-fill" style={total > 0 ? { width: `${pct}%` } : undefined} />
</div>
<p className="update-status">{total > 0 ? `Downloading update… ${pct}%` : "Downloading update…"}</p>
</>
)}
{phase === "installing" && (
<div className="update-status-row">
<span className="update-spinner" />
<span>Installing update. margin will restart…</span>
</div>
)}
{phase === "error" && (
<>
<p className="confirm-text">Could not update margin.</p>
{error && <div className="update-notes">{error}</div>}
</>
)}
</div>
{phase === "available" && (
<div className="panel-foot">
<button className="btn-ghost" onClick={dismissUpdate}>
Later
</button>
<button className="btn-primary" onClick={installUpdate}>
Install &amp; Restart
</button>
</div>
)}
{(phase === "uptodate" || phase === "error") && (
<div className="panel-foot">
<button className="btn-primary" onClick={dismissUpdate}>
OK
</button>
</div>
)}
</div>
</div>
);
}
+105 -43
View File
@@ -1,8 +1,10 @@
import { useEffect, 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 {
bookId: string;
@@ -13,67 +15,127 @@ interface EditorProps {
onContentError: (error: Error) => void;
}
function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState {
const base = editor.view.state;
try {
const doc = editor.schema.nodeFromJSON(content);
doc.check();
return EditorState.create({ doc, plugins: base.plugins });
} catch (error) {
onError(error as Error);
return EditorState.create({ schema: base.schema, plugins: base.plugins });
}
}
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 latest = useRef<ChapterPosition | null>(null);
const [editor] = useState(() =>
sharedEditor({
onUpdate: (next) => onChangeRef.current(next),
onContentError: (error) => onContentErrorRef.current(error),
})
);
const editor = useEditor({
extensions: editorExtensions,
content,
immediatelyRender: false,
enableContentCheck: true,
editorProps: { attributes: { class: "prose" } },
onContentError: ({ error }) => onContentErrorRef.current(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
});
const active = useRef({ bookId: "", chapterId: "" });
const latest = useRef<ChapterPosition | null>(null);
const restoreToken = useRef(0);
const scrollerOf = (ed: TiptapEditor) => ed.view.dom.closest(".editor-pane") as HTMLElement | null;
const restorePosition = (ed: TiptapEditor, id: string, focus: boolean) => {
const scroller = scrollerOf(ed);
const saved = loadPosition(bookId, id);
const size = ed.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
const chain = ed.chain().setTextSelection(selection);
if (focus) chain.focus(undefined, { scrollIntoView: false });
chain.run();
if (scroller) {
const token = ++restoreToken.current;
const top = saved?.scroll ?? 0;
const apply = () => {
if (restoreToken.current === token) scroller.scrollTop = top;
};
apply();
requestAnimationFrame(apply);
document.fonts?.ready.then(apply).catch(() => {});
}
};
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 ?? 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(() => {
onReady(editor);
return () => onReady(null);
}, [editor, onReady]);
useEffect(() => {
if (!editor) return;
let active = true;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
const saved = loadPosition(bookId, chapterId);
const size = editor.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
if (scroller) {
const top = saved?.scroll ?? 0;
const restore = () => {
if (active) scroller.scrollTop = top;
};
restore();
requestAnimationFrame(restore);
document.fonts?.ready.then(restore).catch(() => {});
}
return () => {
active = false;
stash(editor);
onReady(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]);
useEffect(() => {
if (!editor) return;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
const scroller = scrollerOf(editor);
let timer: ReturnType<typeof setTimeout>;
const capture = () => {
const { from, to } = editor.state.selection;
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 };
const scroll = scroller?.scrollTop ?? 0;
latest.current = { from, to, scroll };
const entry = loadChapterState(active.current.bookId, active.current.chapterId);
if (entry) entry.scroll = scroll;
};
const persist = () => {
capture();
clearTimeout(timer);
timer = setTimeout(() => savePosition(bookId, chapterId, 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 });
@@ -81,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, chapterId, latest.current);
if (latest.current) savePosition(active.current.bookId, active.current.chapterId, latest.current);
};
}, [editor, bookId, chapterId]);
}, [editor]);
return <EditorContent editor={editor} className="editor-host" />;
}
+15 -1
View File
@@ -20,6 +20,14 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
const baseWidth = node.attrs.width ?? FIGURE_WIDTH[placement as FigurePlacement] ?? 100;
const width = resizable ? (dragWidth ?? baseWidth) : null;
const nudge = (delta: number) => updateAttributes({ width: clampWidth((width ?? baseWidth) + delta) });
const onHandleKey = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
nudge(e.key === "ArrowRight" ? 5 : -5);
};
const startResize = (e: React.PointerEvent) => {
e.preventDefault();
const wrapper = frameRef.current?.closest(".figure") as HTMLElement | null;
@@ -52,7 +60,13 @@ export function FigureView({ node, updateAttributes, selected }: NodeViewProps)
<div className="figure-frame" ref={frameRef}>
{src ? <img src={src} alt={alt} /> : <div className="figure-empty">No image</div>}
{selected && src && resizable && (
<div className="figure-handle" onPointerDown={startResize} title="Drag to resize" />
<button
className="figure-handle"
aria-label="Resize image"
title="Drag or press arrow keys to resize"
onPointerDown={startResize}
onKeyDown={onHandleKey}
/>
)}
</div>
<input
+36 -16
View File
@@ -1,6 +1,8 @@
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> = {
@@ -27,15 +29,21 @@ 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);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => {
if (linkOpen) linkInputRef.current?.focus();
}, [linkOpen]);
const openLink = () => {
if (!editor) return;
setLinkValue((editor.getAttributes("link").href as string) ?? "");
setLinkOpen(true);
};
useFocusTrap(linkPopRef, linkOpen);
useFocusTrap(alignPopRef, alignOpen);
useEffect(() => {
if (!editor) return;
@@ -46,6 +54,27 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
};
}, [editor]);
useEffect(() => {
if (!editor) return;
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() !== "k" || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
if (!editor.isFocused && !linkOpen) return;
e.preventDefault();
e.stopPropagation();
if (linkOpen) setLinkOpen(false);
else openLink();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, linkOpen]);
useEscapeLayer(linkOpen, () => {
setLinkOpen(false);
editor?.commands.focus();
});
useEscapeLayer(alignOpen, () => setAlignOpen(false));
if (!editor) return null;
const insertImage = async (file: File) => {
@@ -53,11 +82,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run();
};
const openLink = () => {
setLinkValue((editor.getAttributes("link").href as string) ?? "");
setLinkOpen(true);
};
const applyLink = () => {
const href = normalizeUrl(linkValue);
if (!href) {
@@ -107,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}
@@ -126,13 +150,12 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
</span>
<span className="tool-sep" />
<span className="tool-wrap">
{tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />)}
{tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link (⌘K)", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />)}
{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://…"
@@ -142,9 +165,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
} else if (e.key === "Escape") {
e.preventDefault();
setLinkOpen(false);
}
}}
/>
+2
View File
@@ -7,6 +7,7 @@ import { TextAlign } from "./align";
import { SearchHighlight } from "./search";
import { Proofing } from "./proofing";
import { Paste } from "./paste";
import { Shortcuts } from "./shortcuts";
export const editorExtensions: Extensions = [
StarterKit.configure({
@@ -23,4 +24,5 @@ export const editorExtensions: Extensions = [
SearchHighlight,
Proofing,
Paste,
Shortcuts,
];
+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);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Extension } from "@tiptap/core";
export const Shortcuts = Extension.create({
name: "shortcuts",
addKeyboardShortcuts() {
return {
"Mod-Shift-x": () => this.editor.commands.toggleStrike(),
};
},
});
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
type Handler = () => void;
const layers: Handler[] = [];
let bound = false;
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Escape" || e.isComposing || e.defaultPrevented) return;
const top = layers[layers.length - 1];
if (!top) return;
e.preventDefault();
e.stopPropagation();
top();
}
function pushLayer(handler: Handler): () => void {
if (!bound) {
window.addEventListener("keydown", onKeyDown, true);
bound = true;
}
layers.push(handler);
return () => {
const i = layers.lastIndexOf(handler);
if (i !== -1) layers.splice(i, 1);
};
}
export function useEscapeLayer(active: boolean, onEscape: () => void): void {
const latest = useRef(onEscape);
latest.current = onEscape;
useEffect(() => {
if (!active) return;
return pushLayer(() => latest.current());
}, [active]);
}
+1 -1
View File
@@ -20,7 +20,7 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
a5: { w: "148mm", h: "210mm" },
};
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
const INLINE_SPECIAL = /[\\#$*_`<>@~()[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string {
+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]);
}
+5
View File
@@ -2,6 +2,11 @@ import { invoke } from "@tauri-apps/api/core";
export const isDesktop = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
export async function runWritingTool(tool: "Proofread" | "Rewrite"): Promise<void> {
if (!isDesktop) return;
await invoke("run_writing_tool", { tool });
}
export interface ImageInput {
path: string;
data: string;
+28 -1
View File
@@ -1,11 +1,21 @@
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;
title: string;
author: string;
updatedAt: number;
corrupt?: boolean;
}
@@ -38,6 +48,23 @@ export async function deleteBook(id: string): Promise<void> {
await invoke("delete_book", { id });
}
export async function moveChapterToBook(chapter: Chapter, targetId: string): Promise<void> {
const target = normalizeBook(await loadBook(targetId));
const moved = { ...chapter, content: structuredClone(chapter.content), updatedAt: Date.now() };
await saveBook({ ...target, chapters: insertByKind(target.chapters, moved) });
}
const LAST_BOOK_KEY = "margin-last-book";
export function lastBookId(): string | null {
return localStorage.getItem(LAST_BOOK_KEY);
}
export function rememberLastBook(id: string | null): void {
if (id) localStorage.setItem(LAST_BOOK_KEY, id);
else localStorage.removeItem(LAST_BOOK_KEY);
}
export function newBook(): Book {
return createBook();
}
+13
View File
@@ -171,6 +171,19 @@ export function createPart(title = ""): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: "part", updatedAt: Date.now() };
}
export function insertByKind(chapters: Chapter[], chapter: Chapter): Chapter[] {
const kind = chapterKind(chapter);
const next = [...chapters];
const at =
kind === "front"
? next.filter((c) => chapterKind(c) === "front").length
: kind === "back"
? next.length
: next.length - next.filter((c) => chapterKind(c) === "back").length;
next.splice(at, 0, chapter);
return next;
}
export function cloneChapter(chapter: Chapter): Chapter {
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
+49 -7
View File
@@ -7,6 +7,7 @@ import {
gdriveDisconnect,
gdriveRestore,
gdriveStatus,
gdriveSync,
} from "../backup";
import { isDesktop } from "../ipc";
import { useBook } from "./useBook";
@@ -18,6 +19,7 @@ interface BackupState {
email: string | null;
lastBackup: number | null;
pending: boolean;
needsReauth: boolean;
phase: Phase;
error: string | null;
authUrl: string | null;
@@ -33,6 +35,7 @@ interface BackupState {
copyAuthUrl: () => Promise<void>;
disconnect: () => Promise<void>;
backup: (silent?: boolean) => Promise<void>;
sync: (silent?: boolean) => Promise<void>;
restore: () => Promise<void>;
restoreFromDrive: () => Promise<void>;
openSettings: () => void;
@@ -43,11 +46,31 @@ function notify(message: string) {
useBook.getState().setNotice(message);
}
const RECONNECT = /Connect again/;
function describe(error: unknown, fallback: string): string {
const message = String(error).replace(/^Error:\s*/, "");
return RECONNECT.test(message) ? message : `${fallback}: ${message}`;
}
function plural(n: number, word: string): string {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
function syncMessage(uploaded: number, downloaded: number): string {
if (!uploaded && !downloaded) return "Everything is already in sync";
const parts = [];
if (downloaded) parts.push(`brought back ${plural(downloaded, "file")}`);
if (uploaded) parts.push(`backed up ${plural(uploaded, "file")}`);
return `Synced with Google Drive: ${parts.join(", ")}`;
}
export const useBackup = create<BackupState>((set, get) => ({
connected: false,
email: null,
lastBackup: null,
pending: false,
needsReauth: false,
phase: "idle",
error: null,
authUrl: null,
@@ -60,6 +83,7 @@ export const useBackup = create<BackupState>((set, get) => ({
email: status.email,
lastBackup: status.lastBackup,
pending: status.pending,
needsReauth: status.needsReauth,
}),
refresh: async () => {
if (!isDesktop) return;
@@ -75,7 +99,7 @@ export const useBackup = create<BackupState>((set, get) => ({
.then((url) => set({ authUrl: url }))
.catch((e) => {
set({ phase: "error", error: String(e), resolveConnect: null });
notify(`Could not connect: ${e}`);
notify(describe(e, "Could not connect"));
resolve(false);
});
}),
@@ -91,11 +115,13 @@ export const useBackup = create<BackupState>((set, get) => ({
await get().refresh();
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
notify("Connected to Google Drive");
} else {
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
notify(`Could not connect: ${error ?? "authorization failed"}`);
await get().sync();
resolve?.(true);
return;
}
resolve?.(ok);
set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null });
notify(describe(error ?? "authorization failed", "Could not connect"));
resolve?.(false);
},
openAuthUrl: () => {
const url = get().authUrl;
@@ -132,7 +158,22 @@ export const useBackup = create<BackupState>((set, get) => ({
if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up");
} catch (e) {
set({ phase: "error", error: String(e) });
if (!silent) notify(`Backup failed: ${e}`);
await get().refresh();
if (!silent || RECONNECT.test(String(e))) notify(describe(e, "Backup failed"));
}
},
sync: async (silent = false) => {
if (!get().connected || get().phase === "working") return;
set({ phase: "working", error: null });
try {
const outcome = await gdriveSync();
get().apply(outcome);
set((s) => ({ phase: "idle", restoreNonce: outcome.downloaded > 0 ? s.restoreNonce + 1 : s.restoreNonce }));
if (!silent) notify(syncMessage(outcome.uploaded, outcome.downloaded));
} catch (e) {
set({ phase: "error", error: String(e) });
await get().refresh();
if (!silent || RECONNECT.test(String(e))) notify(describe(e, "Sync failed"));
}
},
restore: async () => {
@@ -144,7 +185,8 @@ export const useBackup = create<BackupState>((set, get) => ({
notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive");
} catch (e) {
set({ phase: "error", error: String(e) });
notify(`Restore failed: ${e}`);
await get().refresh();
notify(describe(e, "Restore failed"));
}
},
restoreFromDrive: async () => {
+28 -6
View File
@@ -14,7 +14,7 @@ import {
normalizeBook,
} from "../model/book";
import { loadActiveChapter, saveActiveChapter } from "../editor/positions";
import { saveBook } from "../library";
import { rememberLastBook, saveBook } from "../library";
import { applyBookFonts, resetBookFonts } from "../book-style";
export const COVER_ID = "__cover__";
@@ -25,11 +25,14 @@ interface BookState {
dirty: boolean;
exporting: string | null;
notice: string | null;
pendingTitleFocus: string | null;
clearTitleFocus: () => void;
setExporting: (label: string | null) => void;
setNotice: (message: string | null) => void;
openBook: (book: Book) => void;
closeBook: () => void;
setActiveChapter: (id: string) => void;
goToAdjacentChapter: (dir: 1 | -1) => void;
setChapterContent: (id: string, content: JSONContent) => void;
setChapterTitle: (id: string, title: string) => void;
setChapterNoTitle: (id: string, noTitle: boolean) => void;
@@ -56,6 +59,8 @@ export const useBook = create<BookState>((set, get) => ({
dirty: false,
exporting: null,
notice: null,
pendingTitleFocus: null,
clearTitleFocus: () => set({ pendingTitleFocus: null }),
setExporting: (label) => set({ exporting: label }),
setNotice: (message) => set({ notice: message }),
openBook: (book) => {
@@ -65,14 +70,25 @@ 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);
set({ book: normalized, activeChapterId, dirty: false });
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 });
set({ book: null, activeChapterId: "", dirty: false, pendingTitleFocus: null });
},
setActiveChapter: (id) => set({ activeChapterId: id, pendingTitleFocus: null }),
goToAdjacentChapter: (dir) => {
const { book, activeChapterId } = get();
if (!book) return;
const ids = [COVER_ID, ...book.chapters.map((c) => c.id)];
const i = ids.indexOf(activeChapterId);
if (i === -1) return;
const next = ids[(i + dir + ids.length) % ids.length];
if (next !== activeChapterId) set({ activeChapterId: next, pendingTitleFocus: null });
},
setActiveChapter: (id) => set({ activeChapterId: id }),
setChapterContent: (id, content) =>
set((s) =>
s.book
@@ -100,11 +116,16 @@ export const useBook = create<BookState>((set, get) => ({
addChapter: () =>
set((s) => {
if (!s.book) return {};
const chapter = createChapter();
const chapter = createChapter("");
const chapters = [...s.book.chapters];
const backCount = chapters.filter((c) => chapterKind(c) === "back").length;
chapters.splice(chapters.length - backCount, 0, chapter);
return { activeChapterId: chapter.id, dirty: true, book: { ...s.book, chapters } };
return {
activeChapterId: chapter.id,
pendingTitleFocus: chapter.id,
dirty: true,
book: { ...s.book, chapters },
};
}),
addPage: (group, title) =>
set((s) => {
@@ -144,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 } };
+39
View File
@@ -0,0 +1,39 @@
import { create } from "zustand";
import type { Update } from "@tauri-apps/plugin-updater";
export type UpdatePhase =
| "idle"
| "checking"
| "available"
| "downloading"
| "installing"
| "uptodate"
| "error";
interface UpdaterState {
phase: UpdatePhase;
version: string;
notes: string;
downloaded: number;
total: number;
error: string;
update: Update | null;
set: (partial: Partial<UpdaterState>) => void;
reset: () => void;
}
const initial = {
phase: "idle" as UpdatePhase,
version: "",
notes: "",
downloaded: 0,
total: 0,
error: "",
update: null as Update | null,
};
export const useUpdater = create<UpdaterState>((set) => ({
...initial,
set: (partial) => set(partial),
reset: () => set(initial),
}));
+205 -1
View File
@@ -40,6 +40,22 @@ button {
padding: 0;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chapter:focus-visible,
.card:focus-visible {
outline-offset: -2px;
}
.prose:focus,
.prose:focus-visible,
.editor-host:focus-visible {
outline: none;
}
.app {
display: flex;
flex-direction: column;
@@ -153,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;
@@ -176,6 +200,7 @@ body.resizing {
width: 0;
border-right-width: 0;
overflow: hidden;
visibility: hidden;
}
body.resizing .sidebar {
@@ -385,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;
}
@@ -1550,6 +1576,19 @@ body.resizing .sidebar {
color: var(--accent);
}
.card-meta {
position: absolute;
left: 14px;
right: 14px;
bottom: 14px;
font-family: var(--font-ui);
font-size: var(--t-1);
color: var(--ink-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-action {
background: transparent;
border: 1px dashed var(--line-strong);
@@ -1571,6 +1610,7 @@ body.resizing .sidebar {
}
.card-book:hover .card-menu,
.card-menu:focus-visible,
.card-menu[data-open="true"] {
opacity: 1;
}
@@ -1599,6 +1639,129 @@ 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;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--raised);
font-size: var(--t-3);
line-height: 1.55;
color: var(--ink-soft);
white-space: pre-line;
}
.update-status-row {
display: flex;
align-items: center;
gap: 11px;
font-size: var(--t-4);
color: var(--ink-soft);
}
.update-spinner {
flex: none;
width: 18px;
height: 18px;
border: 2px solid var(--line-strong);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.update-progress {
width: 100%;
height: 7px;
border-radius: 999px;
background: var(--accent-wash);
overflow: hidden;
}
.update-progress-fill {
height: 100%;
border-radius: 999px;
background: var(--accent);
transition: width 160ms var(--ease);
}
.update-progress.indeterminate .update-progress-fill {
width: 40%;
animation: update-indeterminate 1.1s ease-in-out infinite;
}
@keyframes update-indeterminate {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(320%);
}
}
.update-status {
margin: 0;
font-size: var(--t-3);
color: var(--ink-soft);
}
.btn-primary,
.btn-ghost,
.btn-danger {
@@ -2001,6 +2164,32 @@ body.resizing .sidebar {
}
}
.esc-hint {
position: fixed;
top: 54px;
left: 50%;
z-index: 30;
padding: 7px 14px;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-soft);
background: var(--glass);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: 999px;
box-shadow: var(--shadow-pop);
pointer-events: none;
transform: translateX(-50%);
animation: find-drop 140ms var(--ease);
}
.esc-hint kbd {
font-family: var(--font-ui);
font-weight: 600;
color: var(--ink);
}
.find-expand {
flex: none;
width: 20px;
@@ -2658,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);
@@ -2694,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 {
+19
View File
@@ -0,0 +1,19 @@
export function relativeTime(ts: number, now: number): string {
const diff = Math.max(0, now - ts);
const min = 60_000;
const hour = 60 * min;
const day = 24 * hour;
if (diff < min) return "just now";
if (diff < hour) {
const m = Math.floor(diff / min);
return `${m} minute${m === 1 ? "" : "s"} ago`;
}
if (diff < day) {
const h = Math.floor(diff / hour);
return `${h} hour${h === 1 ? "" : "s"} ago`;
}
if (diff < 2 * day) return "yesterday";
const d = Math.floor(diff / day);
if (d < 7) return `${d} days ago`;
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
+43 -16
View File
@@ -1,31 +1,58 @@
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { ask, message } from "@tauri-apps/plugin-dialog";
import { useUpdater } from "./store/useUpdater";
let running = false;
let checking = false;
export async function checkForUpdates(silent: boolean) {
if (running) return;
running = true;
if (checking) return;
checking = true;
const store = useUpdater.getState();
if (!silent) store.set({ phase: "checking", error: "" });
try {
const update = await check();
if (!update) {
if (!silent) await message("margin is up to date.", { title: "Check for Updates" });
store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
return;
}
const notes = update.body ? `\n\n${update.body}` : "";
const install = await ask(`margin ${update.version} is available.${notes}\n\nInstall now and restart?`, {
title: "Update Available",
kind: "info",
store.set({
phase: "available",
update,
version: update.version,
notes: update.body ?? "",
downloaded: 0,
total: 0,
error: "",
});
if (!install) return;
await update.downloadAndInstall();
await relaunch();
} catch (err) {
if (!silent) {
await message(`Could not check for updates.\n\n${err}`, { title: "Update Failed", kind: "error" });
}
store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) });
} finally {
running = false;
checking = false;
}
}
export async function installUpdate() {
const { update, phase } = useUpdater.getState();
if (!update || phase === "downloading" || phase === "installing") return;
try {
useUpdater.getState().set({ phase: "downloading", downloaded: 0, total: 0 });
await update.downloadAndInstall((event) => {
const s = useUpdater.getState();
if (event.event === "Started") {
s.set({ total: event.data.contentLength ?? 0, downloaded: 0 });
} else if (event.event === "Progress") {
s.set({ downloaded: s.downloaded + event.data.chunkLength });
} else if (event.event === "Finished") {
s.set({ phase: "installing" });
}
});
useUpdater.getState().set({ phase: "installing" });
await relaunch();
} catch (err) {
useUpdater.getState().set({ phase: "error", error: String(err) });
}
}
export function dismissUpdate() {
useUpdater.getState().reset();
}