mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc93fc97d | ||
|
|
b954a3135d | ||
|
|
9e310a34bb | ||
|
|
fe39274ed7 | ||
|
|
2e15dc2dc9 | ||
|
|
dad0ac86d0 | ||
|
|
efd45cf61e | ||
|
|
742e42d65b | ||
|
|
abcd133b19 | ||
|
|
4fa14306d1 | ||
|
|
acf78dfe29 | ||
|
|
866a971729 | ||
|
|
7ea33b5bfb | ||
|
|
0abeb3c5d4 | ||
|
|
a31f99cb76 | ||
|
|
20365dd02d | ||
|
|
af389c7097 | ||
|
|
f11ddd290f |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "margin-app",
|
||||
"private": true,
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+26
-1
@@ -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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "margin-app"
|
||||
version = "0.1.11"
|
||||
version = "0.1.16"
|
||||
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
|
||||
|
||||
+97
-16
@@ -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,116 @@ 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(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 +152,7 @@ pub fn run() {
|
||||
.manage(gdrive::GDriveState::default())
|
||||
.setup(|app| {
|
||||
gdrive::init_session(app.handle());
|
||||
writingtools::label_writing_tool_shortcuts();
|
||||
Ok(())
|
||||
});
|
||||
|
||||
@@ -93,7 +163,17 @@ 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();
|
||||
}
|
||||
@@ -115,6 +195,7 @@ 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,
|
||||
|
||||
@@ -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,11 +108,16 @@ 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,
|
||||
});
|
||||
}
|
||||
Ok(books)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,11 +218,25 @@ pub async fn proof_text(
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut issues = Vec::new();
|
||||
if spelling {
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
if grammar {
|
||||
if engine.harper.is_none() {
|
||||
engine.harper = Some(build_harper());
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Margin",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.16",
|
||||
"identifier": "studio.margin.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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";
|
||||
@@ -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}`);
|
||||
@@ -87,6 +91,7 @@ function App() {
|
||||
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
||||
{isDesktop && <BackupSettings />}
|
||||
{isDesktop && <ExportPreview />}
|
||||
{isDesktop && <UpdateDialog />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PAGE_TYPES } from "../model/book";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface AddPageMenuProps {
|
||||
@@ -16,6 +17,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 +40,26 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
|
||||
}, [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 +78,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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { formatBackupTime, gdriveListBackups } from "../backup";
|
||||
|
||||
export function BackupSettings() {
|
||||
@@ -38,6 +39,8 @@ export function BackupSettings() {
|
||||
};
|
||||
}, [open, connected, lastBackup]);
|
||||
|
||||
useEscapeLayer(open, close);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
@@ -10,6 +11,14 @@ interface ConfirmDialogProps {
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
confirmRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEscapeLayer(true, onClose);
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -26,7 +35,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>
|
||||
|
||||
+123
-20
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -21,11 +22,17 @@ 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 { 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 +43,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,6 +53,9 @@ 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 [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -53,6 +65,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 +94,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) 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 +162,49 @@ export function EditorView() {
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
||||
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;
|
||||
@@ -177,8 +257,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 =
|
||||
@@ -342,14 +425,16 @@ 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">
|
||||
{coverActive && <CoverView />}
|
||||
<article className="sheet" hidden={coverActive}>
|
||||
<header className="chapter-opener">
|
||||
<div className="chapter-num">{eyebrow}</div>
|
||||
<ChapterTitleInput
|
||||
@@ -358,6 +443,8 @@ export function EditorView() {
|
||||
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()) && (
|
||||
@@ -372,7 +459,6 @@ export function EditorView() {
|
||||
)}
|
||||
</header>
|
||||
<Editor
|
||||
key={chapter.id}
|
||||
bookId={book.id}
|
||||
chapterId={chapter.id}
|
||||
content={chapter.content}
|
||||
@@ -383,9 +469,7 @@ export function EditorView() {
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
<FloatingToolbar editor={editor} />
|
||||
</>
|
||||
)}
|
||||
{!coverActive && <FloatingToolbar editor={editor} />}
|
||||
</main>
|
||||
{dock && !isCompact && <ResizeHandle pane="dock" />}
|
||||
{dock && <Dock />}
|
||||
@@ -416,6 +500,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 +526,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 +546,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}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useExportPreview } from "../store/useExportPreview";
|
||||
import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
|
||||
import { compilePdf } from "../ipc";
|
||||
import { saveBytes } from "../project";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
@@ -81,13 +82,7 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
const el = panelRef.current;
|
||||
|
||||
@@ -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,8 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
||||
};
|
||||
}, [editor, pendingNav, activeChapterId]);
|
||||
|
||||
useEscapeLayer(open, onClose);
|
||||
|
||||
if (!open || !editor) return null;
|
||||
|
||||
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0);
|
||||
@@ -163,9 +166,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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,9 +256,6 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
replaceOne();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -10,10 +10,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 +32,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,6 +57,11 @@ 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);
|
||||
@@ -108,10 +119,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>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
interface RowMenuProps {
|
||||
@@ -33,6 +34,26 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
|
||||
}, [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) => {
|
||||
@@ -88,7 +109,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 ? (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
fontStack,
|
||||
pairingFor,
|
||||
} from "../model/fonts";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
const LANGUAGES = [
|
||||
@@ -94,6 +95,8 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEscapeLayer(true, onClose);
|
||||
|
||||
if (!book) return null;
|
||||
|
||||
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
@@ -121,7 +124,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 autoFocus 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 })} />
|
||||
|
||||
+41
-22
@@ -5,6 +5,7 @@ import { AddPageMenu } from "./AddPageMenu";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { Icon } from "./Icon";
|
||||
import { RowMenu } from "./RowMenu";
|
||||
import { relativeTime } from "../time";
|
||||
|
||||
interface Row {
|
||||
chapter: Chapter;
|
||||
@@ -19,26 +20,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);
|
||||
@@ -155,6 +136,33 @@ 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.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 +201,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 +209,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,7 +235,7 @@ 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
|
||||
@@ -247,7 +260,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>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useUpdater } from "../store/useUpdater";
|
||||
import { installUpdate, dismissUpdate } from "../updater";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
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";
|
||||
useEscapeLayer(phase !== "idle" && !busy, dismissUpdate);
|
||||
|
||||
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 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 & Restart
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phase === "uptodate" || phase === "error") && (
|
||||
<div className="panel-foot">
|
||||
<button className="btn-primary" onClick={dismissUpdate}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+96
-32
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { EditorContent, useEditor, 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 { useBook } from "../store/useBook";
|
||||
|
||||
interface EditorProps {
|
||||
bookId: string;
|
||||
@@ -13,67 +15,129 @@ interface EditorProps {
|
||||
onContentError: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface Cached {
|
||||
state: EditorState;
|
||||
content: JSONContent;
|
||||
scroll: number;
|
||||
}
|
||||
|
||||
const SCROLL_KEEPOUT = { top: 32, right: 0, bottom: 96, left: 0 };
|
||||
|
||||
function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState {
|
||||
const base = editor.view.state;
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const onContentErrorRef = useRef(onContentError);
|
||||
onContentErrorRef.current = onContentError;
|
||||
|
||||
const cache = useRef(new Map<string, Cached>());
|
||||
const activeId = useRef(chapterId);
|
||||
const latest = useRef<ChapterPosition | null>(null);
|
||||
const restoreToken = useRef(0);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: editorExtensions,
|
||||
content,
|
||||
immediatelyRender: false,
|
||||
enableContentCheck: true,
|
||||
editorProps: { attributes: { class: "prose" } },
|
||||
editorProps: {
|
||||
attributes: { class: "prose" },
|
||||
scrollThreshold: SCROLL_KEEPOUT,
|
||||
scrollMargin: SCROLL_KEEPOUT,
|
||||
},
|
||||
onContentError: ({ error }) => onContentErrorRef.current(error),
|
||||
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onReady(editor);
|
||||
return () => onReady(null);
|
||||
}, [editor, onReady]);
|
||||
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, id: string) => {
|
||||
const scroller = scrollerOf(ed);
|
||||
const { from, to } = ed.state.selection;
|
||||
const scroll = scroller?.scrollTop ?? 0;
|
||||
const stored = useBook.getState().book?.chapters.find((c) => c.id === id)?.content ?? cache.current.get(id)?.content ?? ed.getJSON();
|
||||
cache.current.set(id, { state: ed.view.state, content: stored, scroll });
|
||||
savePosition(bookId, id, { from, to, scroll });
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
|
||||
activeId.current = chapterId;
|
||||
restorePosition(editor, chapterId, true);
|
||||
onReady(editor);
|
||||
return () => onReady(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!editor) return;
|
||||
const prev = activeId.current;
|
||||
if (prev === chapterId) return;
|
||||
|
||||
stash(editor, prev);
|
||||
const entry = cache.current.get(chapterId);
|
||||
if (entry && entry.content === content) {
|
||||
editor.view.updateState(entry.state);
|
||||
const scroller = scrollerOf(editor);
|
||||
if (scroller) scroller.scrollTop = entry.scroll;
|
||||
++restoreToken.current;
|
||||
} else {
|
||||
editor.view.updateState(buildState(editor, content, onContentErrorRef.current));
|
||||
cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
|
||||
restorePosition(editor, chapterId, false);
|
||||
}
|
||||
activeId.current = chapterId;
|
||||
latest.current = null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chapterId, content, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const scroller = 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 = cache.current.get(activeId.current);
|
||||
if (entry) entry.scroll = scroll;
|
||||
};
|
||||
const persist = () => {
|
||||
capture();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400);
|
||||
timer = setTimeout(() => savePosition(bookId, activeId.current, latest.current!), 400);
|
||||
};
|
||||
editor.on("selectionUpdate", persist);
|
||||
scroller?.addEventListener("scroll", persist, { passive: true });
|
||||
@@ -81,9 +145,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(bookId, activeId.current, latest.current);
|
||||
};
|
||||
}, [editor, bookId, chapterId]);
|
||||
}, [editor, bookId]);
|
||||
|
||||
return <EditorContent editor={editor} className="editor-host" />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { useEscapeLayer } from "../escape";
|
||||
import type { Alignment } from "./align";
|
||||
|
||||
const ALIGN_ICONS: Record<Alignment, string> = {
|
||||
@@ -33,6 +34,12 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
const [alignOpen, setAlignOpen] = useState(false);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const openLink = () => {
|
||||
if (!editor) return;
|
||||
setLinkValue((editor.getAttributes("link").href as string) ?? "");
|
||||
setLinkOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (linkOpen) linkInputRef.current?.focus();
|
||||
}, [linkOpen]);
|
||||
@@ -46,6 +53,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 +81,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) {
|
||||
@@ -126,7 +149,7 @@ 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)} />
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface BookSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
updatedAt: number;
|
||||
corrupt?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+24
-5
@@ -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,23 @@ 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 });
|
||||
set({ book: normalized, activeChapterId, dirty: false, pendingTitleFocus: null });
|
||||
},
|
||||
closeBook: () => {
|
||||
flushOutgoing(get().book, get().dirty);
|
||||
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 +114,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) => {
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -1550,6 +1566,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);
|
||||
@@ -1599,6 +1628,72 @@ body.resizing .sidebar {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.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 +2096,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;
|
||||
|
||||
+19
@@ -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
@@ -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();
|
||||
} catch (err) {
|
||||
store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) });
|
||||
} finally {
|
||||
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) {
|
||||
if (!silent) {
|
||||
await message(`Could not check for updates.\n\n${err}`, { title: "Update Failed", kind: "error" });
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
useUpdater.getState().set({ phase: "error", error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissUpdate() {
|
||||
useUpdater.getState().reset();
|
||||
}
|
||||
Reference in new issue
Block a user