12 Commits
Author SHA1 Message Date
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
32 changed files with 770 additions and 138 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "margin-app", "name": "margin-app",
"private": true, "private": true,
"version": "0.1.14", "version": "0.1.16",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -4713,7 +4713,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "margin-app" name = "margin-app"
version = "0.1.11" version = "0.1.15"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"fontdb", "fontdb",
+10 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "margin-app" name = "margin-app"
version = "0.1.14" version = "0.1.16"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -45,7 +45,15 @@ url = "2"
# in the graph via Tauri, so this adds nothing new to the build. # in the graph via Tauri, so this adds nothing new to the build.
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6" objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] } objc2-app-kit = { version = "0.3", features = [
"NSSpellChecker",
"NSApplication",
"NSEvent",
"NSMenu",
"NSMenuItem",
"NSResponder",
"NSWritingToolsCoordinator",
] }
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] } objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
# Other platforms fall back to the bundled Hunspell dictionary via spellbook. # Other platforms fall back to the bundled Hunspell dictionary via spellbook.
+95 -16
View File
@@ -7,6 +7,7 @@ mod macspell;
mod pdf; mod pdf;
mod project; mod project;
mod proofing; mod proofing;
mod writingtools;
#[cfg(desktop)] #[cfg(desktop)]
use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder}; use tauri::menu::{Menu, MenuItemBuilder, MenuItemKind, PredefinedMenuItem, SubmenuBuilder};
@@ -16,50 +17,116 @@ use tauri::{Emitter, Runtime};
#[cfg(desktop)] #[cfg(desktop)]
fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> { fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
let menu = Menu::default(handle)?; let menu = Menu::default(handle)?;
let new_book = MenuItemBuilder::with_id("new-book", "New Book") let new_book = MenuItemBuilder::with_id("new-book", "New Book")
.accelerator("CmdOrCtrl+N") .accelerator("CmdOrCtrl+N")
.build(handle)?; .build(handle)?;
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…").build(handle)?; let save = MenuItemBuilder::with_id("save", "Save")
let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…").build(handle)?; .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 = let check_updates =
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?; 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()? .items()?
.into_iter() .into_iter()
.find_map(|item| match item { .filter_map(|item| match item {
MenuItemKind::Submenu(submenu) MenuItemKind::Submenu(submenu) => Some(submenu),
if submenu.text().map(|t| t == "File").unwrap_or(false) =>
{
Some(submenu)
}
_ => None, _ => 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) => { Some(submenu) => {
submenu.prepend_items(&[ submenu.prepend_items(&[
&new_book, &new_book,
&PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::separator(handle)?,
&save,
&PredefinedMenuItem::separator(handle)?,
&export_pdf, &export_pdf,
&export_epub, &export_epub,
&PredefinedMenuItem::separator(handle)?, &PredefinedMenuItem::separator(handle)?,
&check_updates,
&PredefinedMenuItem::separator(handle)?,
])?; ])?;
} }
None => { None => {
let submenu = SubmenuBuilder::new(handle, "File") let submenu = SubmenuBuilder::new(handle, "File")
.item(&new_book) .item(&new_book)
.item(&PredefinedMenuItem::separator(handle)?) .item(&PredefinedMenuItem::separator(handle)?)
.item(&save)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&export_pdf) .item(&export_pdf)
.item(&export_epub) .item(&export_epub)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&check_updates)
.build()?; .build()?;
menu.insert(&submenu, 1)?; 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) Ok(menu)
} }
@@ -85,6 +152,7 @@ pub fn run() {
.manage(gdrive::GDriveState::default()) .manage(gdrive::GDriveState::default())
.setup(|app| { .setup(|app| {
gdrive::init_session(app.handle()); gdrive::init_session(app.handle());
writingtools::label_writing_tool_shortcuts();
Ok(()) Ok(())
}); });
@@ -95,7 +163,17 @@ pub fn run() {
.on_menu_event(|app, event| { .on_menu_event(|app, event| {
if matches!( if matches!(
event.id().0.as_str(), 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(); app.emit("menu-action", event.id().0.as_str()).ok();
} }
@@ -117,6 +195,7 @@ pub fn run() {
library::delete_book, library::delete_book,
proofing::proof_text, proofing::proof_text,
proofing::remember_word, proofing::remember_word,
writingtools::run_writing_tool,
gdrive::gdrive_connect, gdrive::gdrive_connect,
gdrive::gdrive_disconnect, gdrive::gdrive_disconnect,
gdrive::gdrive_status, gdrive::gdrive_status,
+33 -1
View File
@@ -1,13 +1,16 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use tauri::Manager; use tauri::Manager;
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BookSummary { pub struct BookSummary {
id: String, id: String,
title: String, title: String,
author: String, author: String,
corrupt: bool, corrupt: bool,
updated_at: u64,
} }
fn corrupt_summary(stem: &str) -> BookSummary { fn corrupt_summary(stem: &str) -> BookSummary {
@@ -16,9 +19,33 @@ fn corrupt_summary(stem: &str) -> BookSummary {
title: "Unreadable book".to_string(), title: "Unreadable book".to_string(),
author: String::new(), author: String::new(),
corrupt: true, 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> { 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())?; let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&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()) .and_then(|v| v.as_str())
.unwrap_or("") .unwrap_or("")
.to_string(); .to_string();
let updated_at = match latest_chapter_ms(&value) {
0 => file_modified_ms(&path),
ms => ms,
};
books.push(BookSummary { books.push(BookSummary {
id: id.to_string(), id: id.to_string(),
title, title,
author, author,
corrupt: false, corrupt: false,
updated_at,
}); });
} }
Ok(books) Ok(books)
+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", "$schema": "https://schema.tauri.app/config/2",
"productName": "Margin", "productName": "Margin",
"version": "0.1.14", "version": "0.1.16",
"identifier": "studio.margin.app", "identifier": "studio.margin.app",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+5
View File
@@ -1,10 +1,12 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { Library } from "./components/Library"; import { Library } from "./components/Library";
import { EditorView } from "./components/EditorView"; import { EditorView } from "./components/EditorView";
import { BackupSettings } from "./components/BackupSettings"; import { BackupSettings } from "./components/BackupSettings";
import { ExportPreview } from "./components/ExportPreview"; import { ExportPreview } from "./components/ExportPreview";
import { UpdateDialog } from "./components/UpdateDialog";
import { useBook } from "./store/useBook"; import { useBook } from "./store/useBook";
import { useBackup } from "./store/useBackup"; import { useBackup } from "./store/useBackup";
import { useExportPreview } from "./store/useExportPreview"; import { useExportPreview } from "./store/useExportPreview";
@@ -28,6 +30,8 @@ function App() {
} }
else if (event.payload === "export-epub") runExport("epub"); else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false); 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) => { const unlistenWarn = listen<string>("pdf-warnings", (event) => {
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`); useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
@@ -87,6 +91,7 @@ function App() {
{book ? <EditorView /> : <Library onOpen={openBook} />} {book ? <EditorView /> : <Library onOpen={openBook} />}
{isDesktop && <BackupSettings />} {isDesktop && <BackupSettings />}
{isDesktop && <ExportPreview />} {isDesktop && <ExportPreview />}
{isDesktop && <UpdateDialog />}
</> </>
); );
} }
+7 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PAGE_TYPES } from "../model/book"; import { PAGE_TYPES } from "../model/book";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface AddPageMenuProps { interface AddPageMenuProps {
@@ -44,12 +45,13 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus(); popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
}, [open]); }, [open]);
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
});
const onMenuKeyDown = (e: React.KeyboardEvent) => { const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(false);
btnRef.current?.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []); const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement); const idx = items.indexOf(document.activeElement as HTMLElement);
+3
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { useBackup } from "../store/useBackup"; import { useBackup } from "../store/useBackup";
import { useEscapeLayer } from "../escape";
import { formatBackupTime, gdriveListBackups } from "../backup"; import { formatBackupTime, gdriveListBackups } from "../backup";
export function BackupSettings() { export function BackupSettings() {
@@ -38,6 +39,8 @@ export function BackupSettings() {
}; };
}, [open, connected, lastBackup]); }, [open, connected, lastBackup]);
useEscapeLayer(open, close);
if (!open) return null; if (!open) return null;
return ( return (
+3 -9
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface ConfirmDialogProps { interface ConfirmDialogProps {
@@ -14,17 +15,10 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
useEffect(() => { useEffect(() => {
confirmRef.current?.focus(); confirmRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEscapeLayer(true, onClose);
return ( return (
<div className="overlay" onClick={onClose}> <div className="overlay" onClick={onClose}>
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}> <div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
+80 -19
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import type { Editor as TiptapEditor } from "@tiptap/react"; import type { Editor as TiptapEditor } from "@tiptap/react";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { Dock } from "./Dock"; import { Dock } from "./Dock";
@@ -21,11 +22,17 @@ import { useWidth } from "../store/useWidth";
import { WIDTH_OPTIONS } from "../width"; import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book"; import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
import { saveBook } from "../library"; import { saveBook } from "../library";
import { isDesktop } from "../ipc"; import { useEscapeLayer } from "../escape";
import { isDesktop, runWritingTool } from "../ipc";
import { useCompact } from "../useMedia"; import { useCompact } from "../useMedia";
import { issueSignature, rememberWord, runProof } from "../proofing"; import { issueSignature, rememberWord, runProof } from "../proofing";
import { runExport } from "../export/run"; import { runExport } from "../export/run";
const WRITING_TOOLS: Record<string, "Proofread" | "Rewrite"> = {
KeyF: "Proofread",
KeyR: "Rewrite",
};
export function EditorView() { export function EditorView() {
const book = useBook((s) => s.book); const book = useBook((s) => s.book);
const activeChapterId = useBook((s) => s.activeChapterId); const activeChapterId = useBook((s) => s.activeChapterId);
@@ -36,6 +43,8 @@ export function EditorView() {
const setChapterContent = useBook((s) => s.setChapterContent); const setChapterContent = useBook((s) => s.setChapterContent);
const setChapterTitle = useBook((s) => s.setChapterTitle); const setChapterTitle = useBook((s) => s.setChapterTitle);
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle); 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 markSaved = useBook((s) => s.markSaved);
const theme = useTheme((s) => s.theme); const theme = useTheme((s) => s.theme);
const toggleTheme = useTheme((s) => s.toggle); const toggleTheme = useTheme((s) => s.toggle);
@@ -56,6 +65,10 @@ export function EditorView() {
const [findOpen, setFindOpen] = useState(false); const [findOpen, setFindOpen] = useState(false);
const [findExpanded, setFindExpanded] = useState(false); const [findExpanded, setFindExpanded] = useState(false);
const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null); 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 spelling = useProofing((s) => s.spelling);
const grammar = useProofing((s) => s.grammar); const grammar = useProofing((s) => s.grammar);
@@ -81,12 +94,32 @@ export function EditorView() {
setDock(false); 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) => { const openFind = useCallback((expanded: boolean) => {
setFindExpanded(expanded); setFindExpanded(expanded);
setFindOpen(true); setFindOpen(true);
}, []); }, []);
const focusEditorSoon = useCallback(() => { const focusEditorSoon = useCallback(() => {
if (useBook.getState().pendingTitleFocus) return;
requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false })); requestAnimationFrame(() => editorRef.current?.commands.focus(undefined, { scrollIntoView: false }));
}, []); }, []);
@@ -129,17 +162,14 @@ export function EditorView() {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { if (e.metaKey && e.altKey && e.key.toLowerCase() === "f") {
e.preventDefault(); e.preventDefault();
saveNow(); openFind(true);
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { } else if (e.metaKey && e.altKey && e.key === "ArrowRight") {
e.preventDefault();
openFind(e.altKey);
} else if (e.metaKey && e.altKey && (e.key === "ArrowDown" || e.key === "ArrowRight")) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
switchChapter(1); switchChapter(1);
} else if (e.metaKey && e.altKey && (e.key === "ArrowUp" || e.key === "ArrowLeft")) { } else if (e.metaKey && e.altKey && e.key === "ArrowLeft") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
switchChapter(-1); switchChapter(-1);
@@ -147,24 +177,34 @@ export function EditorView() {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
switchChapter(e.shiftKey ? -1 : 1); 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, true); window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true);
}, [saveNow, openFind, switchChapter]); }, [openFind, switchChapter]);
const menuCommands = useRef({ saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter });
menuCommands.current = { saveNow, openFind, setSettingsOpen, toggleSidebar, switchChapter };
useEffect(() => { useEffect(() => {
if (!widthOpen && !exportOpen && !moreOpen) return; if (!isDesktop) return;
const onKey = (e: KeyboardEvent) => { const unlisten = listen<string>("menu-action", (e) => {
if (e.key === "Escape") { const cmd = menuCommands.current;
setWidthOpen(false); if (e.payload === "save") cmd.saveNow();
setExportOpen(false); else if (e.payload === "find") cmd.openFind(false);
setMoreOpen(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());
}; };
window.addEventListener("keydown", onKey); }, []);
return () => window.removeEventListener("keydown", onKey);
}, [widthOpen, exportOpen, moreOpen]);
useEffect(() => { useEffect(() => {
if (!notice) return; if (!notice) return;
@@ -403,6 +443,8 @@ export function EditorView() {
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title" chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
} }
disabled={!!chapter.noTitle} disabled={!!chapter.noTitle}
focusRequest={pendingTitleFocus === chapter.id}
onFocused={clearTitleFocus}
onChange={(value) => setChapterTitle(chapter.id, value)} onChange={(value) => setChapterTitle(chapter.id, value)}
/> />
{(chapter.noTitle || !chapter.title.trim()) && ( {(chapter.noTitle || !chapter.title.trim()) && (
@@ -458,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} />} {settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && ( {exporting && (
<div className="export-overlay"> <div className="export-overlay">
@@ -478,11 +526,15 @@ function ChapterTitleInput({
value, value,
placeholder, placeholder,
disabled, disabled,
focusRequest,
onFocused,
onChange, onChange,
}: { }: {
value: string; value: string;
placeholder: string; placeholder: string;
disabled: boolean; disabled: boolean;
focusRequest: boolean;
onFocused: () => void;
onChange: (value: string) => void; onChange: (value: string) => void;
}) { }) {
const ref = useRef<HTMLTextAreaElement>(null); const ref = useRef<HTMLTextAreaElement>(null);
@@ -494,6 +546,15 @@ function ChapterTitleInput({
el.style.height = `${el.scrollHeight}px`; el.style.height = `${el.scrollHeight}px`;
}, [value, disabled]); }, [value, disabled]);
useEffect(() => {
if (!focusRequest || disabled) return;
const el = ref.current;
if (!el) return;
el.focus();
el.select();
onFocused();
}, [focusRequest, disabled, onFocused]);
return ( return (
<textarea <textarea
ref={ref} ref={ref}
+2 -7
View File
@@ -7,6 +7,7 @@ import { useExportPreview } from "../store/useExportPreview";
import { bookToPdfInputs, unsupportedScripts } from "../export/typst"; import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
import { compilePdf } from "../ipc"; import { compilePdf } from "../ipc";
import { saveBytes } from "../project"; import { saveBytes } from "../project";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
@@ -81,13 +82,7 @@ function ExportPreviewModal() {
}; };
}, [book]); }, [book]);
useEffect(() => { useEscapeLayer(true, close);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [close]);
useEffect(() => { useEffect(() => {
const el = panelRef.current; const el = panelRef.current;
+3 -6
View File
@@ -3,6 +3,7 @@ import type { Editor as TiptapEditor } from "@tiptap/react";
import { buildRegex, getSearchState } from "../editor/search"; import { buildRegex, getSearchState } from "../editor/search";
import { countMatches, replaceInContent } from "../search-book"; import { countMatches, replaceInContent } from "../search-book";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface FindBarProps { interface FindBarProps {
@@ -95,6 +96,8 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
}; };
}, [editor, pendingNav, activeChapterId]); }, [editor, pendingNav, activeChapterId]);
useEscapeLayer(open, onClose);
if (!open || !editor) return null; if (!open || !editor) return null;
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0); 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") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
e.shiftKey ? prev() : next(); 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") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
replaceOne(); replaceOne();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
} }
}} }}
/> />
+7
View File
@@ -10,10 +10,12 @@ import { RowMenu } from "./RowMenu";
import { BackupButton } from "./BackupButton"; import { BackupButton } from "./BackupButton";
import { useBackup } from "../store/useBackup"; import { useBackup } from "../store/useBackup";
import { useBook } from "../store/useBook"; import { useBook } from "../store/useBook";
import { relativeTime } from "../time";
export function Library({ onOpen }: { onOpen: (book: Book) => void }) { export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
const [books, setBooks] = useState<BookSummary[]>([]); const [books, setBooks] = useState<BookSummary[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null); const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
const notice = useBook((s) => s.notice); const notice = useBook((s) => s.notice);
@@ -30,6 +32,10 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); }, []);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(timer);
}, []);
useEffect(() => { useEffect(() => {
if (restoreNonce) refresh(); if (restoreNonce) refresh();
}, [restoreNonce]); }, [restoreNonce]);
@@ -125,6 +131,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
> >
<span className="card-title">{b.title || "Untitled"}</span> <span className="card-title">{b.title || "Untitled"}</span>
{b.author && <span className="card-author">{b.author}</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)} /> <RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
</div> </div>
), ),
+7 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
interface RowMenuProps { interface RowMenuProps {
@@ -38,12 +39,13 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus(); popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
}, [open]); }, [open]);
useEscapeLayer(open, () => {
setOpen(false);
btnRef.current?.focus();
});
const onMenuKeyDown = (e: React.KeyboardEvent) => { const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(false);
btnRef.current?.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".row-menu-item") ?? []); const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".row-menu-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement); const idx = items.indexOf(document.activeElement as HTMLElement);
+2 -10
View File
@@ -13,6 +13,7 @@ import {
fontStack, fontStack,
pairingFor, pairingFor,
} from "../model/fonts"; } from "../model/fonts";
import { useEscapeLayer } from "../escape";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
const LANGUAGES = [ const LANGUAGES = [
@@ -94,16 +95,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
}); });
}, []); }, []);
useEffect(() => { useEscapeLayer(true, onClose);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
if (!book) return null; if (!book) return null;
+9 -22
View File
@@ -5,6 +5,7 @@ import { AddPageMenu } from "./AddPageMenu";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { RowMenu } from "./RowMenu"; import { RowMenu } from "./RowMenu";
import { relativeTime } from "../time";
interface Row { interface Row {
chapter: Chapter; chapter: Chapter;
@@ -19,26 +20,6 @@ interface DropTarget {
index: number; 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 }) { export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const chapters = useBook((s) => s.book?.chapters ?? []); const chapters = useBook((s) => s.book?.chapters ?? []);
const activeChapterId = useBook((s) => s.activeChapterId); const activeChapterId = useBook((s) => s.activeChapterId);
@@ -254,7 +235,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
</span> </span>
)} )}
{row.chapter.id === activeChapterId && ( {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> </span>
<RowMenu <RowMenu
@@ -279,7 +260,13 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
)} )}
</ul> </ul>
{group.kind === "body" && ( {group.kind === "body" && (
<button className="add-chapter" onClick={addChapter}> <button
className="add-chapter"
onClick={() => {
addChapter();
onNavigate?.();
}}
>
<Icon d="M12 5v14M5 12h14" /> <Icon d="M12 5v14M5 12h14" />
New chapter New chapter
</button> </button>
+107
View File
@@ -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 &amp; Restart
</button>
</div>
)}
{(phase === "uptodate" || phase === "error") && (
<div className="panel-foot">
<button className="btn-primary" onClick={dismissUpdate}>
OK
</button>
</div>
)}
</div>
</div>
);
}
+7 -1
View File
@@ -21,6 +21,8 @@ interface Cached {
scroll: number; scroll: number;
} }
const SCROLL_KEEPOUT = { top: 32, right: 0, bottom: 96, left: 0 };
function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState { function buildState(editor: TiptapEditor, content: JSONContent, onError: (error: Error) => void): EditorState {
const base = editor.view.state; const base = editor.view.state;
try { try {
@@ -49,7 +51,11 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
content, content,
immediatelyRender: false, immediatelyRender: false,
enableContentCheck: true, enableContentCheck: true,
editorProps: { attributes: { class: "prose" } }, editorProps: {
attributes: { class: "prose" },
scrollThreshold: SCROLL_KEEPOUT,
scrollMargin: SCROLL_KEEPOUT,
},
onContentError: ({ error }) => onContentErrorRef.current(error), onContentError: ({ error }) => onContentErrorRef.current(error),
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()), onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
}); });
+29 -9
View File
@@ -1,6 +1,7 @@
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react"; import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { useEscapeLayer } from "../escape";
import type { Alignment } from "./align"; import type { Alignment } from "./align";
const ALIGN_ICONS: Record<Alignment, string> = { const ALIGN_ICONS: Record<Alignment, string> = {
@@ -33,6 +34,12 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
const [alignOpen, setAlignOpen] = useState(false); const [alignOpen, setAlignOpen] = useState(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0); const [, forceUpdate] = useReducer((x) => x + 1, 0);
const openLink = () => {
if (!editor) return;
setLinkValue((editor.getAttributes("link").href as string) ?? "");
setLinkOpen(true);
};
useEffect(() => { useEffect(() => {
if (linkOpen) linkInputRef.current?.focus(); if (linkOpen) linkInputRef.current?.focus();
}, [linkOpen]); }, [linkOpen]);
@@ -46,6 +53,27 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
}; };
}, [editor]); }, [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; if (!editor) return null;
const insertImage = async (file: File) => { 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(); 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 applyLink = () => {
const href = normalizeUrl(linkValue); const href = normalizeUrl(linkValue);
if (!href) { if (!href) {
@@ -126,7 +149,7 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
</span> </span>
<span className="tool-sep" /> <span className="tool-sep" />
<span className="tool-wrap"> <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 && ( {linkOpen && (
<> <>
<div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} /> <div className="link-pop-backdrop" onMouseDown={() => setLinkOpen(false)} />
@@ -142,9 +165,6 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
applyLink(); 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 { SearchHighlight } from "./search";
import { Proofing } from "./proofing"; import { Proofing } from "./proofing";
import { Paste } from "./paste"; import { Paste } from "./paste";
import { Shortcuts } from "./shortcuts";
export const editorExtensions: Extensions = [ export const editorExtensions: Extensions = [
StarterKit.configure({ StarterKit.configure({
@@ -23,4 +24,5 @@ export const editorExtensions: Extensions = [
SearchHighlight, SearchHighlight,
Proofing, Proofing,
Paste, Paste,
Shortcuts,
]; ];
+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" }, a5: { w: "148mm", h: "210mm" },
}; };
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g; const INLINE_SPECIAL = /[\\#$*_`<>@~()[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g; const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string { function esc(text: string): string {
+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 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 { export interface ImageInput {
path: string; path: string;
data: string; data: string;
+1
View File
@@ -6,6 +6,7 @@ export interface BookSummary {
id: string; id: string;
title: string; title: string;
author: string; author: string;
updatedAt: number;
corrupt?: boolean; corrupt?: boolean;
} }
+15 -6
View File
@@ -25,6 +25,8 @@ interface BookState {
dirty: boolean; dirty: boolean;
exporting: string | null; exporting: string | null;
notice: string | null; notice: string | null;
pendingTitleFocus: string | null;
clearTitleFocus: () => void;
setExporting: (label: string | null) => void; setExporting: (label: string | null) => void;
setNotice: (message: string | null) => void; setNotice: (message: string | null) => void;
openBook: (book: Book) => void; openBook: (book: Book) => void;
@@ -57,6 +59,8 @@ export const useBook = create<BookState>((set, get) => ({
dirty: false, dirty: false,
exporting: null, exporting: null,
notice: null, notice: null,
pendingTitleFocus: null,
clearTitleFocus: () => set({ pendingTitleFocus: null }),
setExporting: (label) => set({ exporting: label }), setExporting: (label) => set({ exporting: label }),
setNotice: (message) => set({ notice: message }), setNotice: (message) => set({ notice: message }),
openBook: (book) => { openBook: (book) => {
@@ -66,14 +70,14 @@ export const useBook = create<BookState>((set, get) => ({
const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved); const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved);
const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? ""; const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? "";
applyBookFonts(normalized.settings.fonts); applyBookFonts(normalized.settings.fonts);
set({ book: normalized, activeChapterId, dirty: false }); set({ book: normalized, activeChapterId, dirty: false, pendingTitleFocus: null });
}, },
closeBook: () => { closeBook: () => {
flushOutgoing(get().book, get().dirty); flushOutgoing(get().book, get().dirty);
resetBookFonts(); resetBookFonts();
set({ book: null, activeChapterId: "", dirty: false }); set({ book: null, activeChapterId: "", dirty: false, pendingTitleFocus: null });
}, },
setActiveChapter: (id) => set({ activeChapterId: id }), setActiveChapter: (id) => set({ activeChapterId: id, pendingTitleFocus: null }),
goToAdjacentChapter: (dir) => { goToAdjacentChapter: (dir) => {
const { book, activeChapterId } = get(); const { book, activeChapterId } = get();
if (!book) return; if (!book) return;
@@ -81,7 +85,7 @@ export const useBook = create<BookState>((set, get) => ({
const i = ids.indexOf(activeChapterId); const i = ids.indexOf(activeChapterId);
if (i === -1) return; if (i === -1) return;
const next = ids[(i + dir + ids.length) % ids.length]; const next = ids[(i + dir + ids.length) % ids.length];
if (next !== activeChapterId) set({ activeChapterId: next }); if (next !== activeChapterId) set({ activeChapterId: next, pendingTitleFocus: null });
}, },
setChapterContent: (id, content) => setChapterContent: (id, content) =>
set((s) => set((s) =>
@@ -110,11 +114,16 @@ export const useBook = create<BookState>((set, get) => ({
addChapter: () => addChapter: () =>
set((s) => { set((s) => {
if (!s.book) return {}; if (!s.book) return {};
const chapter = createChapter(); const chapter = createChapter("");
const chapters = [...s.book.chapters]; const chapters = [...s.book.chapters];
const backCount = chapters.filter((c) => chapterKind(c) === "back").length; const backCount = chapters.filter((c) => chapterKind(c) === "back").length;
chapters.splice(chapters.length - backCount, 0, chapter); 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) => addPage: (group, title) =>
set((s) => { set((s) => {
+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),
}));
+105
View File
@@ -1566,6 +1566,19 @@ body.resizing .sidebar {
color: var(--accent); 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 { .card-action {
background: transparent; background: transparent;
border: 1px dashed var(--line-strong); border: 1px dashed var(--line-strong);
@@ -1615,6 +1628,72 @@ body.resizing .sidebar {
color: var(--ink); 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-primary,
.btn-ghost, .btn-ghost,
.btn-danger { .btn-danger {
@@ -2017,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 { .find-expand {
flex: none; flex: none;
width: 20px; width: 20px;
+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 { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process"; 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) { export async function checkForUpdates(silent: boolean) {
if (running) return; if (checking) return;
running = true; checking = true;
const store = useUpdater.getState();
if (!silent) store.set({ phase: "checking", error: "" });
try { try {
const update = await check(); const update = await check();
if (!update) { if (!update) {
if (!silent) await message("margin is up to date.", { title: "Check for Updates" }); store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
return; return;
} }
const notes = update.body ? `\n\n${update.body}` : ""; store.set({
const install = await ask(`margin ${update.version} is available.${notes}\n\nInstall now and restart?`, { phase: "available",
title: "Update Available", update,
kind: "info", version: update.version,
notes: update.body ?? "",
downloaded: 0,
total: 0,
error: "",
}); });
if (!install) return;
await update.downloadAndInstall();
await relaunch();
} catch (err) { } catch (err) {
if (!silent) { store.set(silent ? { phase: "idle" } : { phase: "error", error: String(err) });
await message(`Could not check for updates.\n\n${err}`, { title: "Update Failed", kind: "error" });
}
} finally { } 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();
}